diff --git a/.gitignore b/.gitignore index 471d46fd..17d85cdc 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md index 927f2561..956de442 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 61b7c01f..23cd9f7b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini index 47b33935..dc170438 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -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] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 3b2a3673..097cb079 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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() \ No newline at end of file + run_migrations_online() diff --git a/backend/alembic/versions/3a012dff0274_increase_port_description_length.py b/backend/alembic/versions/3a012dff0274_increase_port_description_length.py new file mode 100644 index 00000000..802b6983 --- /dev/null +++ b/backend/alembic/versions/3a012dff0274_increase_port_description_length.py @@ -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 diff --git a/backend/alembic/versions/531bf8cdae06_create_material_types_table.py b/backend/alembic/versions/531bf8cdae06_create_material_types_table.py index 91c608bf..bfdc01b1 100644 --- a/backend/alembic/versions/531bf8cdae06_create_material_types_table.py +++ b/backend/alembic/versions/531bf8cdae06_create_material_types_table.py @@ -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 ### diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 0d5cd591..864f6c72 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -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;") diff --git a/backend/api/v1/common/base_models.py b/backend/api/v1/common/base_models.py new file mode 100644 index 00000000..ddfbafd6 --- /dev/null +++ b/backend/api/v1/common/base_models.py @@ -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) diff --git a/backend/api/v1/common/crud_routes.py b/backend/api/v1/common/crud_routes.py new file mode 100644 index 00000000..2f208636 --- /dev/null +++ b/backend/api/v1/common/crud_routes.py @@ -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 diff --git a/backend/api/v1/common/dto_mixins.py b/backend/api/v1/common/dto_mixins.py new file mode 100644 index 00000000..f1e256f1 --- /dev/null +++ b/backend/api/v1/common/dto_mixins.py @@ -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") diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py new file mode 100644 index 00000000..9a3c65ae --- /dev/null +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -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 diff --git a/backend/api/v1/modules/a24/fa/fa_classes/models.py b/backend/api/v1/modules/a24/fa/fa_classes/models.py new file mode 100644 index 00000000..1f8f5fc4 --- /dev/null +++ b/backend/api/v1/modules/a24/fa/fa_classes/models.py @@ -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 diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/models.py b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py new file mode 100644 index 00000000..5aa2d038 --- /dev/null +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py @@ -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 \ No newline at end of file diff --git a/backend/api/v1/modules/a24/inv/inv_classes/models.py b/backend/api/v1/modules/a24/inv/inv_classes/models.py new file mode 100644 index 00000000..229e5502 --- /dev/null +++ b/backend/api/v1/modules/a24/inv/inv_classes/models.py @@ -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) diff --git a/backend/api/v1/modules/a24/inv/location/__init__.py b/backend/api/v1/modules/a24/inv/location/__init__.py new file mode 100644 index 00000000..935e07ae --- /dev/null +++ b/backend/api/v1/modules/a24/inv/location/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de localización +""" diff --git a/backend/api/v1/modules/a24/inv/location/dto.py b/backend/api/v1/modules/a24/inv/location/dto.py new file mode 100644 index 00000000..a6fc6735 --- /dev/null +++ b/backend/api/v1/modules/a24/inv/location/dto.py @@ -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 diff --git a/backend/api/v1/modules/a24/inv/location/models.py b/backend/api/v1/modules/a24/inv/location/models.py new file mode 100644 index 00000000..53d3202c --- /dev/null +++ b/backend/api/v1/modules/a24/inv/location/models.py @@ -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"" diff --git a/backend/api/v1/modules/a24/inv/location/routes.py b/backend/api/v1/modules/a24/inv/location/routes.py new file mode 100644 index 00000000..0920bd4e --- /dev/null +++ b/backend/api/v1/modules/a24/inv/location/routes.py @@ -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 diff --git a/backend/api/v1/modules/a24/inv/location/service.py b/backend/api/v1/modules/a24/inv/location/service.py new file mode 100644 index 00000000..0ef5726c --- /dev/null +++ b/backend/api/v1/modules/a24/inv/location/service.py @@ -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") diff --git a/backend/api/v1/modules/a76/classes/__init__.py b/backend/api/v1/modules/a76/classes/__init__.py new file mode 100644 index 00000000..1a3d8bed --- /dev/null +++ b/backend/api/v1/modules/a76/classes/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de Class +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/classes/dto.py b/backend/api/v1/modules/a76/classes/dto.py new file mode 100644 index 00000000..a59dac9c --- /dev/null +++ b/backend/api/v1/modules/a76/classes/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/classes/models.py b/backend/api/v1/modules/a76/classes/models.py new file mode 100644 index 00000000..e12374f7 --- /dev/null +++ b/backend/api/v1/modules/a76/classes/models.py @@ -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"" diff --git a/backend/api/v1/modules/a76/classes/routes.py b/backend/api/v1/modules/a76/classes/routes.py new file mode 100644 index 00000000..14860e93 --- /dev/null +++ b/backend/api/v1/modules/a76/classes/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py new file mode 100644 index 00000000..ff7e2a93 --- /dev/null +++ b/backend/api/v1/modules/a76/classes/service.py @@ -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] diff --git a/backend/api/v1/modules/a76/classes/test_classes.py b/backend/api/v1/modules/a76/classes/test_classes.py new file mode 100644 index 00000000..f899748c --- /dev/null +++ b/backend/api/v1/modules/a76/classes/test_classes.py @@ -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) diff --git a/backend/api/v1/modules/a76/clients_and_providers/__init__.py b/backend/api/v1/modules/a76/clients_and_providers/__init__.py new file mode 100644 index 00000000..bdcba0ad --- /dev/null +++ b/backend/api/v1/modules/a76/clients_and_providers/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de Client & Provider +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/clients_and_providers/dto.py b/backend/api/v1/modules/a76/clients_and_providers/dto.py new file mode 100644 index 00000000..f77df843 --- /dev/null +++ b/backend/api/v1/modules/a76/clients_and_providers/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/clients_and_providers/models.py b/backend/api/v1/modules/a76/clients_and_providers/models.py new file mode 100644 index 00000000..4cf08fe0 --- /dev/null +++ b/backend/api/v1/modules/a76/clients_and_providers/models.py @@ -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") diff --git a/backend/api/v1/modules/a76/clients_and_providers/routes.py b/backend/api/v1/modules/a76/clients_and_providers/routes.py new file mode 100644 index 00000000..970b71ad --- /dev/null +++ b/backend/api/v1/modules/a76/clients_and_providers/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py new file mode 100644 index 00000000..5a329673 --- /dev/null +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -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" + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/clients_and_providers/test_client_and_provider.py b/backend/api/v1/modules/a76/clients_and_providers/test_client_and_provider.py new file mode 100644 index 00000000..ba8afa4a --- /dev/null +++ b/backend/api/v1/modules/a76/clients_and_providers/test_client_and_provider.py @@ -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) diff --git a/backend/api/v1/modules/a76/country_rule_oct/dto.py b/backend/api/v1/modules/a76/country_rule_oct/dto.py new file mode 100644 index 00000000..8352bb4d --- /dev/null +++ b/backend/api/v1/modules/a76/country_rule_oct/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/country_rule_oct/models.py b/backend/api/v1/modules/a76/country_rule_oct/models.py new file mode 100644 index 00000000..fd1fce50 --- /dev/null +++ b/backend/api/v1/modules/a76/country_rule_oct/models.py @@ -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)) diff --git a/backend/api/v1/modules/a76/country_rule_oct/routes.py b/backend/api/v1/modules/a76/country_rule_oct/routes.py new file mode 100644 index 00000000..a1551276 --- /dev/null +++ b/backend/api/v1/modules/a76/country_rule_oct/routes.py @@ -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") diff --git a/backend/api/v1/modules/a76/country_rule_oct/services.py b/backend/api/v1/modules/a76/country_rule_oct/services.py new file mode 100644 index 00000000..6a355b7e --- /dev/null +++ b/backend/api/v1/modules/a76/country_rule_oct/services.py @@ -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 diff --git a/backend/api/v1/modules/a76/country_rule_oct/test_country_rule_oct.py b/backend/api/v1/modules/a76/country_rule_oct/test_country_rule_oct.py new file mode 100644 index 00000000..93ffbd21 --- /dev/null +++ b/backend/api/v1/modules/a76/country_rule_oct/test_country_rule_oct.py @@ -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) diff --git a/backend/api/v1/modules/a76/customs_brokers/dto.py b/backend/api/v1/modules/a76/customs_brokers/dto.py new file mode 100644 index 00000000..07923e14 --- /dev/null +++ b/backend/api/v1/modules/a76/customs_brokers/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/customs_brokers/models.py b/backend/api/v1/modules/a76/customs_brokers/models.py new file mode 100644 index 00000000..a5925a62 --- /dev/null +++ b/backend/api/v1/modules/a76/customs_brokers/models.py @@ -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") diff --git a/backend/api/v1/modules/a76/customs_brokers/routes.py b/backend/api/v1/modules/a76/customs_brokers/routes.py new file mode 100644 index 00000000..b4a756e5 --- /dev/null +++ b/backend/api/v1/modules/a76/customs_brokers/routes.py @@ -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 + diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py new file mode 100644 index 00000000..ea72abe5 --- /dev/null +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -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 diff --git a/backend/api/v1/modules/a76/fraction_rule_octave/dto.py b/backend/api/v1/modules/a76/fraction_rule_octave/dto.py new file mode 100644 index 00000000..562ef34d --- /dev/null +++ b/backend/api/v1/modules/a76/fraction_rule_octave/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/fraction_rule_octave/models.py b/backend/api/v1/modules/a76/fraction_rule_octave/models.py new file mode 100644 index 00000000..838bf5e3 --- /dev/null +++ b/backend/api/v1/modules/a76/fraction_rule_octave/models.py @@ -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)) diff --git a/backend/api/v1/modules/a76/fraction_rule_octave/routes.py b/backend/api/v1/modules/a76/fraction_rule_octave/routes.py new file mode 100644 index 00000000..2e09474e --- /dev/null +++ b/backend/api/v1/modules/a76/fraction_rule_octave/routes.py @@ -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") diff --git a/backend/api/v1/modules/a76/fraction_rule_octave/services.py b/backend/api/v1/modules/a76/fraction_rule_octave/services.py new file mode 100644 index 00000000..bd8d1dc9 --- /dev/null +++ b/backend/api/v1/modules/a76/fraction_rule_octave/services.py @@ -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 diff --git a/backend/api/v1/modules/a76/fraction_rule_octave/test_fraction_rule_octave.py b/backend/api/v1/modules/a76/fraction_rule_octave/test_fraction_rule_octave.py new file mode 100644 index 00000000..5ec8416e --- /dev/null +++ b/backend/api/v1/modules/a76/fraction_rule_octave/test_fraction_rule_octave.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/classification_concepts/__init__.py b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/classification_concepts/dto.py b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/dto.py new file mode 100644 index 00000000..3aec09a8 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/classification_concepts/models.py b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/models.py new file mode 100644 index 00000000..129d05fc --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/models.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/classification_concepts/routes.py b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/routes.py new file mode 100644 index 00000000..9f9ba069 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/classification_concepts/service.py b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/service.py new file mode 100644 index 00000000..785403d7 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/classification_concepts/service.py @@ -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." + ) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/__init__.py b/backend/api/v1/modules/a76/general_catalogs/company/__init__.py new file mode 100644 index 00000000..6f72af22 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/company/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de Company +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/general_catalogs/company/dto.py b/backend/api/v1/modules/a76/general_catalogs/company/dto.py new file mode 100644 index 00000000..03f8c9ae --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/company/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py new file mode 100644 index 00000000..b291dbb5 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py new file mode 100644 index 00000000..294ff490 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -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 \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py new file mode 100644 index 00000000..63835cd1 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -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)}") \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/company/test_company.py b/backend/api/v1/modules/a76/general_catalogs/company/test_company.py new file mode 100644 index 00000000..0b718bbd --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/company/test_company.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/concepts/__init__.py b/backend/api/v1/modules/a76/general_catalogs/concepts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/concepts/dto.py b/backend/api/v1/modules/a76/general_catalogs/concepts/dto.py new file mode 100644 index 00000000..e8c030e0 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/concepts/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/concepts/models.py b/backend/api/v1/modules/a76/general_catalogs/concepts/models.py new file mode 100644 index 00000000..d3824d1d --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/concepts/models.py @@ -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]) diff --git a/backend/api/v1/modules/a76/general_catalogs/concepts/routes.py b/backend/api/v1/modules/a76/general_catalogs/concepts/routes.py new file mode 100644 index 00000000..f579cef0 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/concepts/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/concepts/service.py b/backend/api/v1/modules/a76/general_catalogs/concepts/service.py new file mode 100644 index 00000000..b0117771 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/concepts/service.py @@ -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") diff --git a/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/__init__.py b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/dto.py b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/dto.py new file mode 100644 index 00000000..30416dea --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/models.py b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/models.py new file mode 100644 index 00000000..301e8d48 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/models.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/routes.py b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/routes.py new file mode 100644 index 00000000..c8ec83ad --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/service.py b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/service.py new file mode 100644 index 00000000..643981e4 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/customs_broker_concepts/service.py @@ -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") diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/__init__.py b/backend/api/v1/modules/a76/general_catalogs/doda/__init__.py new file mode 100644 index 00000000..9c5e22f4 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de DODA (Documentos de Operación de Aduana) +""" diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/dto.py b/backend/api/v1/modules/a76/general_catalogs/doda/dto.py new file mode 100644 index 00000000..0d188199 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/models.py b/backend/api/v1/modules/a76/general_catalogs/doda/models.py new file mode 100644 index 00000000..810d4977 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/models.py @@ -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"" + + +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"" + + +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"" + + +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"" + + +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"" diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py new file mode 100644 index 00000000..b687bdfd --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/service.py b/backend/api/v1/modules/a76/general_catalogs/doda/service.py new file mode 100644 index 00000000..cccb1314 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/doda/service.py @@ -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() + ) diff --git a/backend/api/v1/modules/a76/general_catalogs/electronic_notices/__init__.py b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/__init__.py new file mode 100644 index 00000000..12753c03 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de avisos electrónicos +""" diff --git a/backend/api/v1/modules/a76/general_catalogs/electronic_notices/dto.py b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/dto.py new file mode 100644 index 00000000..97edc4a5 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/electronic_notices/models.py b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/models.py new file mode 100644 index 00000000..e2722d47 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/models.py @@ -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"" diff --git a/backend/api/v1/modules/a76/general_catalogs/electronic_notices/routes.py b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/routes.py new file mode 100644 index 00000000..576f4cf0 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/routes.py @@ -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] diff --git a/backend/api/v1/modules/a76/general_catalogs/electronic_notices/service.py b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/service.py new file mode 100644 index 00000000..0fadd40f --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/electronic_notices/service.py @@ -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() + ) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/__init__.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py new file mode 100644 index 00000000..f2017259 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py new file mode 100644 index 00000000..5211bc10 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py @@ -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() diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py new file mode 100644 index 00000000..d7c1c42f --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py new file mode 100644 index 00000000..3580ef22 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/__init__.py b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/__init__.py new file mode 100644 index 00000000..4537d3f3 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de catálogos de errores +""" diff --git a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/dto.py b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/dto.py new file mode 100644 index 00000000..fd8e5033 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/models.py b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/models.py new file mode 100644 index 00000000..147770c3 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/models.py @@ -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"" + + +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"" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py new file mode 100644 index 00000000..97af7429 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/service.py b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/service.py new file mode 100644 index 00000000..06ebf664 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/service.py @@ -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" + ) diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/dto.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/dto.py new file mode 100644 index 00000000..ce0ddea8 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/models.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/models.py new file mode 100644 index 00000000..92f8e4f7 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/models.py @@ -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)) diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py new file mode 100644 index 00000000..546ead36 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py new file mode 100644 index 00000000..469b212f --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/test_exchange_rate.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/test_exchange_rate.py new file mode 100644 index 00000000..3bf1cb12 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/test_exchange_rate.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/identifiers/__init__.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/identifiers/dto.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/dto.py new file mode 100644 index 00000000..9432a023 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/identifiers/dto.py @@ -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) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/identifiers/models.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/models.py new file mode 100644 index 00000000..daabe759 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/identifiers/models.py @@ -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") diff --git a/backend/api/v1/modules/a76/general_catalogs/identifiers/routes.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/routes.py new file mode 100644 index 00000000..26d5fb3e --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/identifiers/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/identifiers/service.py b/backend/api/v1/modules/a76/general_catalogs/identifiers/service.py new file mode 100644 index 00000000..c4ff7578 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/identifiers/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/inpc/__init__.py b/backend/api/v1/modules/a76/general_catalogs/inpc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/inpc/dto.py b/backend/api/v1/modules/a76/general_catalogs/inpc/dto.py new file mode 100644 index 00000000..72afc5c6 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/inpc/dto.py @@ -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) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/inpc/models.py b/backend/api/v1/modules/a76/general_catalogs/inpc/models.py new file mode 100644 index 00000000..65e79049 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/inpc/models.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/inpc/routes.py b/backend/api/v1/modules/a76/general_catalogs/inpc/routes.py new file mode 100644 index 00000000..a91b276d --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/inpc/routes.py @@ -0,0 +1,16 @@ +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from .models import INPC +from .dto import INPCCreate, INPCResponse, INPCUpdate +from .service import INPCService + +# Usamos TenantCRUDRoutes directamente +router = TenantCRUDRoutes( + service=INPCService, + create_schema=INPCCreate, + update_schema=INPCUpdate, + response_schema=INPCResponse, + prefix="/inpc", + tags=["a76.general_catalogs.inpc"], + resource_name="INPC", + enable_list=True, +).router \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/inpc/service.py b/backend/api/v1/modules/a76/general_catalogs/inpc/service.py new file mode 100644 index 00000000..af4e6d20 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/inpc/service.py @@ -0,0 +1,95 @@ +from typing import List, Optional, Tuple, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import select + +from .models import INPC +from .dto import INPCCreate, INPCUpdate + + +class INPCService: + @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[INPC], int]: + query = db.query(INPC).filter( + INPC.tenant_id == tenant_id, + INPC.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[INPC]: + return db.query(INPC).filter( + INPC.id == id, + INPC.tenant_id == tenant_id, + INPC.company_id == company_id + ).first() + + @staticmethod + def create( + db: Session, + data: INPCCreate, + tenant_id: int, + company_id: int + ) -> INPC: + db_obj = INPC( + **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: INPCUpdate, + company_id: int + ) -> Optional[INPC]: + db_obj = INPCService.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 = INPCService.get_by_id(db, id, tenant_id, company_id) + if not db_obj: + return False + + db.delete(db_obj) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/general_catalogs/legends/__init__.py b/backend/api/v1/modules/a76/general_catalogs/legends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/legends/dto.py b/backend/api/v1/modules/a76/general_catalogs/legends/dto.py new file mode 100644 index 00000000..2f3b9570 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/legends/dto.py @@ -0,0 +1,25 @@ +from typing import Optional +from pydantic import BaseModel, Field, ConfigDict + + +class LegendBase(BaseModel): + code: int = Field(..., description="Legend Code (CLAVELEY)") + description: Optional[str] = Field( + None, max_length=2000, description="Description") + + +class LegendCreate(LegendBase): + pass + + +class LegendUpdate(BaseModel): + code: Optional[int] = None + description: Optional[str] = Field(None, max_length=2000) + + +class LegendResponse(LegendBase): + id: int + + model_config = ConfigDict(from_attributes=True) + tenant_id : int + company_id : int diff --git a/backend/api/v1/modules/a76/general_catalogs/legends/models.py b/backend/api/v1/modules/a76/general_catalogs/legends/models.py new file mode 100644 index 00000000..f6ea4591 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/legends/models.py @@ -0,0 +1,22 @@ +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 Legend(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "legends" + __table_args__ = ( + UniqueConstraint("code", "tenant_id", "company_id", + name="uq_legend_code"), + {"schema": "a76"} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + + code: Mapped[int] = mapped_column(Integer, nullable=False) # CLAVELEY + + description: Mapped[Optional[str]] = mapped_column( + String(2000), nullable=True) # DESCLEYENDA diff --git a/backend/api/v1/modules/a76/general_catalogs/legends/routes.py b/backend/api/v1/modules/a76/general_catalogs/legends/routes.py new file mode 100644 index 00000000..69772309 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/legends/routes.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from .models import Legend +from .dto import LegendCreate, LegendResponse, LegendUpdate +from .service import LegendService + +router = TenantCRUDRoutes( + service=LegendService, + create_schema=LegendCreate, + update_schema=LegendUpdate, + response_schema=LegendResponse, + prefix="/legends", + tags=["a76.general_catalogs.legends"], + resource_name="Legend", + enable_list=True, +).router diff --git a/backend/api/v1/modules/a76/general_catalogs/legends/service.py b/backend/api/v1/modules/a76/general_catalogs/legends/service.py new file mode 100644 index 00000000..0351f16a --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/legends/service.py @@ -0,0 +1,108 @@ +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 Legend +from .dto import LegendCreate, LegendUpdate + +logger = logging.getLogger(__name__) + + +class LegendService: + @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[Legend], int]: + query = db.query(Legend).filter( + Legend.tenant_id == tenant_id, + Legend.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[Legend]: + return db.query(Legend).filter( + Legend.id == id, + Legend.tenant_id == tenant_id, + Legend.company_id == company_id + ).first() + + @staticmethod + def create( + db: Session, + data: LegendCreate, + tenant_id: int, + company_id: int + ) -> Legend: + db_obj = Legend( + **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: LegendUpdate, + company_id: int + ) -> Optional[Legend]: + db_obj = LegendService.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 = LegendService.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 leyenda {id}: {str(e)}") + raise HTTPException( + status_code=400, + detail="No se puede eliminar esta leyenda porque tiene registros relacionados (pedimentos, facturas, etc.). Primero debe eliminar o reasignar esos registros." + ) diff --git a/backend/api/v1/modules/a76/general_catalogs/locations/__init__.py b/backend/api/v1/modules/a76/general_catalogs/locations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/__init__.py b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/dto.py b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/dto.py new file mode 100644 index 00000000..b76b798f --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/dto.py @@ -0,0 +1,33 @@ +from typing import Optional +from decimal import Decimal +from pydantic import BaseModel, Field, ConfigDict + + +class MultiCurrencyTypeBase(BaseModel): + currency_type_code: str = Field(..., max_length=3, + description="Currency Type Code") + country_key: Optional[str] = Field( + None, max_length=3, description="Country Key") + conversion_factor: Optional[Decimal] = Field( + None, description="Conversion Factor") + publication_date: int = Field(..., + description="Publication Date (YYYYMMDD)") + + +class MultiCurrencyTypeCreate(MultiCurrencyTypeBase): + pass + + +class MultiCurrencyTypeUpdate(BaseModel): + currency_type_code: Optional[str] = Field(None, max_length=3) + country_key: Optional[str] = Field(None, max_length=3) + conversion_factor: Optional[Decimal] = None + publication_date: Optional[int] = None + + +class MultiCurrencyTypeResponse(MultiCurrencyTypeBase): + id: int + company_id: int + tenant_id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/models.py b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/models.py new file mode 100644 index 00000000..e119b8b8 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/models.py @@ -0,0 +1,35 @@ +from typing import Optional +from decimal import Decimal +from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, Numeric +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.public.reference_data.currency_types.models import CurrencyType +from api.v1.modules.public.reference_data.countries.models import Country + + +class MultiCurrencyType(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "multi_currency_types" + __table_args__ = ( + UniqueConstraint("currency_type_code", "publication_date", "tenant_id", "company_id", + name="uq_multi_currency_type_code_date"), + {"schema": "a76"} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + + currency_type_code: Mapped[str] = mapped_column( + String(3), ForeignKey("public.currency_types.code"), nullable=False) + + country_key: Mapped[Optional[str]] = mapped_column( + String(3), ForeignKey("public.countries.m3_key"), nullable=True) + + conversion_factor: Mapped[Optional[Decimal]] = mapped_column( + Numeric(13, 6), nullable=True) + + publication_date: Mapped[int] = mapped_column(Integer, nullable=False) + + currency_type: Mapped["CurrencyType"] = relationship() + + country: Mapped[Optional["Country"]] = relationship() diff --git a/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/routes.py b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/routes.py new file mode 100644 index 00000000..96f26034 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/routes.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from .models import MultiCurrencyType +from .dto import MultiCurrencyTypeCreate, MultiCurrencyTypeResponse, MultiCurrencyTypeUpdate +from .service import MultiCurrencyTypeService + +router = APIRouter(prefix="/multi-currency-types", + tags=["a76.general_catalogs.multi_currency_types"]) + +multi_currency_type_crud = TenantCRUDRoutes( + service=MultiCurrencyTypeService, + create_schema=MultiCurrencyTypeCreate, + update_schema=MultiCurrencyTypeUpdate, + response_schema=MultiCurrencyTypeResponse, + prefix="", + tags=["Multi Currency Types"], + resource_name="MultiCurrencyType", + enable_list=True, +) + +router.include_router(multi_currency_type_crud.router) diff --git a/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/service.py b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/service.py new file mode 100644 index 00000000..979ff3d3 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/multi_currency_types/service.py @@ -0,0 +1,97 @@ +from typing import List, Optional, Tuple, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import select + +from .models import MultiCurrencyType +from .dto import MultiCurrencyTypeCreate, MultiCurrencyTypeUpdate + + +class MultiCurrencyTypeService: + @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[MultiCurrencyType], int]: + query = db.query(MultiCurrencyType).filter( + MultiCurrencyType.tenant_id == tenant_id, + MultiCurrencyType.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[MultiCurrencyType]: + return db.query(MultiCurrencyType).filter( + MultiCurrencyType.id == id, + MultiCurrencyType.tenant_id == tenant_id, + MultiCurrencyType.company_id == company_id + ).first() + + @staticmethod + def create( + db: Session, + data: MultiCurrencyTypeCreate, + tenant_id: int, + company_id: int + ) -> MultiCurrencyType: + db_obj = MultiCurrencyType( + **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, + data: MultiCurrencyTypeUpdate, + tenant_id: int, + company_id: int + ) -> Optional[MultiCurrencyType]: + db_obj = MultiCurrencyTypeService.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 = MultiCurrencyTypeService.get_by_id( + db, id, tenant_id, company_id) + if not db_obj: + return False + + db.delete(db_obj) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/general_catalogs/packages/dto.py b/backend/api/v1/modules/a76/general_catalogs/packages/dto.py new file mode 100644 index 00000000..09e421df --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/packages/dto.py @@ -0,0 +1,41 @@ +""" +DTOs for Packages (GBultos). +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class PackageBaseDTO(BaseModel): + key: str = Field(..., description="Package key (primary identifier)", max_length=5) + description_es: Optional[str] = Field(None, description="Description in Spanish", max_length=40) + description_en: Optional[str] = Field(None, description="Description in English", max_length=40) + weight_unit: Optional[float] = Field(None, description="Weight unit") + plurals: Optional[str] = Field(None, max_length=4) + plural_in: Optional[str] = Field(None, max_length=4) + code_ace: Optional[str] = Field(None, max_length=4) + code_aamex: Optional[str] = Field(None, max_length=9) + + +class PackageCreateDTO(PackageBaseDTO): + """Schema for creating a package""" + pass + + +class PackageUpdateDTO(PackageBaseDTO): + """Schema for updating a package""" + key: Optional[str] = Field(None, description="Package key (cannot be modified)", max_length=5) + + +class PackageResponseDTO(PackageBaseDTO): + """Schema for package response""" + id: int + company_id: int + tenant_id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/general_catalogs/packages/models.py b/backend/api/v1/modules/a76/general_catalogs/packages/models.py new file mode 100644 index 00000000..f191fa94 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/packages/models.py @@ -0,0 +1,34 @@ +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, + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + + +class Package(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "packages" # GBultos + __table_args__ = ( + PrimaryKeyConstraint("id", name="packages_pkey"), + UniqueConstraint("tenant_id", "company_id", "key", name="packages_key_ukey"), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + key: Mapped[str] = mapped_column(String(5)) + description_es: Mapped[Optional[str]] = mapped_column(String(40)) + description_en: Mapped[Optional[str]] = mapped_column(String(40)) + weight_unit: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(19, 8)) + plurals: Mapped[Optional[str]] = mapped_column(String(4)) + plural_in: Mapped[Optional[str]] = mapped_column(String(4)) + code_ace: Mapped[Optional[str]] = mapped_column(String(4)) + code_aamex: Mapped[Optional[str]] = mapped_column(String(9)) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/packages/routes.py b/backend/api/v1/modules/a76/general_catalogs/packages/routes.py new file mode 100644 index 00000000..88a6eba4 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/packages/routes.py @@ -0,0 +1,24 @@ +""" +Routes for managing Packages (GBultos). +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from .dto import PackageCreateDTO, PackageResponseDTO, PackageUpdateDTO +from .services import PackageService + +# Create router using TenantCRUDRoutes factory +router = TenantCRUDRoutes( + service=PackageService, + create_schema=PackageCreateDTO, + update_schema=PackageUpdateDTO, + response_schema=PackageResponseDTO, + prefix="/packages", + tags=["a76 / packages"], + resource_name="Package", + id_name="package_id", + enable_list=True, # Enable GET /packages with pagination + enable_filters=True, # Enable filtering by key and description_es + default_page_size=50, + max_page_size=100, +).router diff --git a/backend/api/v1/modules/a76/general_catalogs/packages/services.py b/backend/api/v1/modules/a76/general_catalogs/packages/services.py new file mode 100644 index 00000000..cea4d882 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/packages/services.py @@ -0,0 +1,133 @@ +""" +Service layer for Packages (GBultos). +""" + +from typing import Optional, Tuple, List, Dict, Any +import logging + +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException + +from . import dto, models + +logger = logging.getLogger(__name__) + + +class PackageService: + """Service for Package 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.Package], int]: + """Get all packages for a tenant/company with pagination""" + query = db.query(models.Package).filter( + models.Package.tenant_id == tenant_id, + models.Package.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("key"): + query = query.filter( + models.Package.key.ilike(f"%{filters['key']}%") + ) + if filters.get("description_es"): + query = query.filter( + models.Package.description_es.ilike( + f"%{filters['description_es']}%") + ) + + total = query.count() + packages = query.offset(skip).limit(limit).all() + + return packages, total + + @staticmethod + def get_by_id( + db: Session, package_id: int, tenant_id: int, company_id: int + ) -> Optional[models.Package]: + """Get package by ID""" + return ( + db.query(models.Package) + .filter( + models.Package.id == package_id, + models.Package.tenant_id == tenant_id, + models.Package.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + package_data: dto.PackageCreateDTO, + tenant_id: int, + company_id: int, + ) -> models.Package: + """Create a new package""" + new_package = models.Package( + **package_data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + db.add(new_package) + db.commit() + db.refresh(new_package) + return new_package + + @staticmethod + def update( + db: Session, + package_id: int, + tenant_id: int, + package_data: dto.PackageUpdateDTO, + company_id: int, + ) -> Optional[models.Package]: + """Update a package""" + package = PackageService.get_by_id( + db, package_id, tenant_id, company_id) + if not package: + return None + + # Update fields (excluding key if it's meant to be immutable) + update_data = package_data.model_dump( + exclude_unset=True, exclude={"key"}) + for field, value in update_data.items(): + setattr(package, field, value) + + db.commit() + db.refresh(package) + return package + + @staticmethod + def delete( + db: Session, package_id: int, tenant_id: int, company_id: int + ) -> bool: + """Delete a package""" + package = PackageService.get_by_id( + db, package_id, tenant_id, company_id) + if not package: + return False + + try: + db.delete(package) + db.commit() + return True + except IntegrityError as e: + db.rollback() + logger.error(f"IntegrityError deleting package {package_id}: {str(e)}") + if "foreign key constraint" in str(e).lower(): + raise HTTPException( + status_code=400, + detail="No se puede eliminar el bulto porque tiene registros relacionados" + ) + raise HTTPException(status_code=400, detail="Error al eliminar el bulto") + except Exception as e: + db.rollback() + logger.error(f"Error deleting package {package_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error al eliminar el bulto") diff --git a/backend/api/v1/modules/a76/general_catalogs/packages/test_package.py b/backend/api/v1/modules/a76/general_catalogs/packages/test_package.py new file mode 100644 index 00000000..4bb4c007 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/packages/test_package.py @@ -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_packages(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/bultos/", 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_package_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/bultos/invalid_id", headers=headers) + assert response.status_code == 404 + + +def test_create_package_forbidden(): + response = client.post("/bultos/", json={"name": "Test Package"}) + assert response.status_code in (403, 405, 404) + + +def test_update_package_forbidden(): + response = client.put("/bultos/1", json={"name": "Updated Package"}) + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/__init__.py b/backend/api/v1/modules/a76/general_catalogs/ports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/dto.py b/backend/api/v1/modules/a76/general_catalogs/ports/dto.py new file mode 100644 index 00000000..ebc91255 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/ports/dto.py @@ -0,0 +1,32 @@ +from typing import Optional +from pydantic import BaseModel, Field, ConfigDict +from .models import PortType + + +class PortBase(BaseModel): + port_code: str = Field(..., max_length=6, description="Port Code") + description: Optional[str] = Field( + None, max_length=20, description="Description") + location_code: str = Field(..., max_length=4, description="Location Code") + location_description: Optional[str] = Field( + None, max_length=20, description="Location Description") + port_type: PortType = Field( + default=PortType.ENTRY, description="Port Type (ENTRY, EXIT, BOTH)") + + +class PortCreate(PortBase): + pass + + +class PortUpdate(BaseModel): + port_code: Optional[str] = Field(None, max_length=6) + description: Optional[str] = Field(None, max_length=20) + location_code: Optional[str] = Field(None, max_length=4) + location_description: Optional[str] = Field(None, max_length=20) + port_type: Optional[PortType] = None + + +class PortResponse(PortBase): + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/models.py b/backend/api/v1/modules/a76/general_catalogs/ports/models.py new file mode 100644 index 00000000..01aa45df --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/ports/models.py @@ -0,0 +1,35 @@ +from typing import Optional +from sqlalchemy import Integer, String, UniqueConstraint, Enum +from sqlalchemy.orm import Mapped, mapped_column +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +import enum + + +class PortType(str, enum.Enum): + ENTRY = "ENTRY" + EXIT = "EXIT" + DESTINATION = "DESTINATION" + + +class Port(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "ports" + __table_args__ = ( + UniqueConstraint("port_code", "location_code", "tenant_id", "company_id", + name="uq_port_location"), + {"schema": "a76"} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + port_code: Mapped[str] = mapped_column(String(6), nullable=False) # PUERTO + description: Mapped[Optional[str]] = mapped_column( + String(20), nullable=True) # DESCRIPCION + location_code: Mapped[str] = mapped_column( + String(4), nullable=False) # LOCALIZACION + location_description: Mapped[Optional[str]] = mapped_column( + String(20), nullable=True) # DESCLOCALIZACION + + # New column requested + port_type: Mapped[PortType] = mapped_column( + String(15), nullable=False, default=PortType.ENTRY) diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/routes.py b/backend/api/v1/modules/a76/general_catalogs/ports/routes.py new file mode 100644 index 00000000..3d03c33d --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/ports/routes.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from .models import Port +from .dto import PortCreate, PortResponse, PortUpdate +from .service import PortService + +router = TenantCRUDRoutes( + service=PortService, + create_schema=PortCreate, + update_schema=PortUpdate, + response_schema=PortResponse, + prefix="/ports", + tags=["a76.general_catalogs.ports"], + resource_name="Port", + enable_list=True, +).router diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/service.py b/backend/api/v1/modules/a76/general_catalogs/ports/service.py new file mode 100644 index 00000000..4d4d9cc2 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/ports/service.py @@ -0,0 +1,95 @@ +from typing import List, Optional, Tuple, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import select + +from .models import Port +from .dto import PortCreate, PortUpdate + + +class PortService: + @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[Port], int]: + query = db.query(Port).filter( + Port.tenant_id == tenant_id, + Port.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[Port]: + return db.query(Port).filter( + Port.id == id, + Port.tenant_id == tenant_id, + Port.company_id == company_id + ).first() + + @staticmethod + def create( + db: Session, + data: PortCreate, + tenant_id: int, + company_id: int + ) -> Port: + db_obj = Port( + **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: PortUpdate, + company_id: int + ) -> Optional[Port]: + db_obj = PortService.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 = PortService.get_by_id(db, id, tenant_id, company_id) + if not db_obj: + return False + + db.delete(db_obj) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/general_catalogs/prevalidators/__init__.py b/backend/api/v1/modules/a76/general_catalogs/prevalidators/__init__.py new file mode 100644 index 00000000..c55b2979 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/prevalidators/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de prevalidadores +""" diff --git a/backend/api/v1/modules/a76/general_catalogs/prevalidators/dto.py b/backend/api/v1/modules/a76/general_catalogs/prevalidators/dto.py new file mode 100644 index 00000000..1f974685 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/prevalidators/dto.py @@ -0,0 +1,58 @@ +""" +DTOs (Data Transfer Objects) para módulo de prevalidadores +""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class PrevalidatorCreateDTO(BaseModel): + """DTO para crear un prevalidador""" + + code: str = Field(..., max_length=20, description="Prevalidator code") + customs_prevalidator: Optional[str] = Field( + None, max_length=20, description="Customs prevalidator" + ) + patent_prevalidator: Optional[str] = Field( + None, max_length=20, description="Patent prevalidator" + ) + description: Optional[str] = Field( + None, max_length=50, description="Description" + ) + + class Config: + from_attributes = True + + +class PrevalidatorUpdateDTO(BaseModel): + """DTO para actualizar un prevalidador""" + + code: Optional[str] = Field( + None, max_length=20, description="Prevalidator code" + ) + customs_prevalidator: Optional[str] = Field( + None, max_length=20, description="Customs prevalidator" + ) + patent_prevalidator: Optional[str] = Field( + None, max_length=20, description="Patent prevalidator" + ) + description: Optional[str] = Field( + None, max_length=50, description="Description" + ) + + class Config: + from_attributes = True + + +class PrevalidatorResponseDTO(BaseModel): + """DTO para responder con datos de un prevalidador""" + + id: int + code: str + customs_prevalidator: Optional[str] = None + patent_prevalidator: Optional[str] = None + description: Optional[str] = None + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/general_catalogs/prevalidators/models.py b/backend/api/v1/modules/a76/general_catalogs/prevalidators/models.py new file mode 100644 index 00000000..a9d0f2b8 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/prevalidators/models.py @@ -0,0 +1,40 @@ +""" +Modelos ORM para gestión de prevalidadores +""" + +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 Prevalidator(Base, TenantScopedMixin, TimestampMixin): + """ + Modelo para la tabla Prevalidator - Prevalidadores + """ + + __tablename__ = "prevalidators" # GPrevalidadores + __table_args__ = ( + PrimaryKeyConstraint("id", name="prevalidators_pkey"), + UniqueConstraint("code", "tenant_id", "company_id", + name="prevalidators_code_unique"), + {"schema": "a76"}, + ) + + # Primary key + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + # Prevalidator code (unique) + code: Mapped[str] = mapped_column(String(20), nullable=False) + + # Prevalidator information + customs_prevalidator: Mapped[Optional[str]] = mapped_column(String(20)) + + patent_prevalidator: Mapped[Optional[str]] = mapped_column(String(20)) + + description: Mapped[Optional[str]] = mapped_column(String(50)) + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py b/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py new file mode 100644 index 00000000..71ee0120 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py @@ -0,0 +1,127 @@ +""" +Rutas para gestión de prevalidadores +""" + +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 ( + PrevalidatorCreateDTO, + PrevalidatorResponseDTO, + PrevalidatorUpdateDTO, +) +from .models import Prevalidator +from .service import PrevalidatorService + +router = APIRouter(prefix="/prevalidators", tags=["prevalidators"]) + +prevalidator_crud = TenantCRUDRoutes( + service=PrevalidatorService, + create_schema=PrevalidatorCreateDTO, + update_schema=PrevalidatorUpdateDTO, + response_schema=PrevalidatorResponseDTO, + prefix="", + tags=["Prevalidators"], + resource_name="Prevalidator", + enable_list=True, +) + +# Add custom endpoints + + +@prevalidator_crud.router.get( + "/code/{code}", + response_model=PrevalidatorResponseDTO, + summary="Get prevalidator by code", +) +async def get_prevalidator_by_code( + code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """Get a prevalidator by its code""" + prevalidator = PrevalidatorService.get_by_code( + db, + code, + tenant_id=current_user["tenant_id"], + company_id=current_user["company_id"] + ) + if not prevalidator: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prevalidator not found", + ) + return PrevalidatorResponseDTO.model_validate(prevalidator) + +router.include_router(prevalidator_crud.router) + + +async def create_prevalidator( + prevalidator_data: PrevalidatorCreateDTO, + db: Session = Depends(get_core_db), +): + """Create a new prevalidator""" + prevalidator = PrevalidatorService.create(db, prevalidator_data) + return PrevalidatorResponseDTO.model_validate(prevalidator) + + +@router.put( + "/{prevalidator_id}", + response_model=PrevalidatorResponseDTO, + summary="Update prevalidator", +) +async def update_prevalidator( + prevalidator_id: int, + prevalidator_data: PrevalidatorUpdateDTO, + db: Session = Depends(get_core_db), +): + """Update a prevalidator""" + prevalidator = PrevalidatorService.update( + db, prevalidator_id, prevalidator_data) + if not prevalidator: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prevalidator not found", + ) + return PrevalidatorResponseDTO.model_validate(prevalidator) + + +@router.delete( + "/{prevalidator_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete prevalidator", +) +async def delete_prevalidator( + prevalidator_id: int, + db: Session = Depends(get_core_db), +): + """Delete a prevalidator""" + success = PrevalidatorService.delete(db, prevalidator_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prevalidator not found", + ) + return None + + +@router.get( + "/by-customs/{customs}", + response_model=List[PrevalidatorResponseDTO], + summary="Get prevalidators by customs", +) +async def get_prevalidators_by_customs( + customs: str, + db: Session = Depends(get_core_db), +): + """Get all prevalidators for a specific customs""" + prevalidators = PrevalidatorService.get_by_customs(db, customs) + return [ + PrevalidatorResponseDTO.model_validate(prevalidator) + for prevalidator in prevalidators + ] diff --git a/backend/api/v1/modules/a76/general_catalogs/prevalidators/service.py b/backend/api/v1/modules/a76/general_catalogs/prevalidators/service.py new file mode 100644 index 00000000..4f51d8f0 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/prevalidators/service.py @@ -0,0 +1,193 @@ +""" +Capa de servicio para lógica de negocio de prevalidadores +""" + +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 ( + PrevalidatorCreateDTO, + PrevalidatorResponseDTO, + PrevalidatorUpdateDTO, +) +from .models import Prevalidator + +logger = logging.getLogger(__name__) + + +class PrevalidatorService: + """Servicio para gestión de prevalidadores""" + + @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[Prevalidator], int]: + """Get all prevalidators with pagination""" + query = db.query(Prevalidator).filter( + Prevalidator.tenant_id == tenant_id, + Prevalidator.company_id == company_id + ) + + # Apply filters if provided + if filters: + if filters.get("code"): + query = query.filter( + Prevalidator.code.ilike(f"%{filters['code']}%") + ) + if filters.get("description"): + query = query.filter( + Prevalidator.description.ilike( + f"%{filters['description']}%") + ) + if filters.get("customs_prevalidator"): + query = query.filter( + Prevalidator.customs_prevalidator.ilike( + f"%{filters['customs_prevalidator']}%" + ) + ) + + total = query.count() + prevalidators = query.offset(skip).limit(limit).all() + + return prevalidators, total + + @staticmethod + def get_by_code( + db: Session, code: str, tenant_id: int, company_id: int + ) -> Optional[Prevalidator]: + """Get prevalidator by code""" + return db.query(Prevalidator).filter( + Prevalidator.code == code, + Prevalidator.tenant_id == tenant_id, + Prevalidator.company_id == company_id + ).first() + + @staticmethod + def get_by_id( + db: Session, prevalidator_id: int, tenant_id: int, company_id: int + ) -> Optional[Prevalidator]: + """Get prevalidator by ID""" + return db.query(Prevalidator).filter( + Prevalidator.id == prevalidator_id, + Prevalidator.tenant_id == tenant_id, + Prevalidator.company_id == company_id + ).first() + + @staticmethod + def create( + db: Session, + prevalidator_data: PrevalidatorCreateDTO, + tenant_id: int, + company_id: int + ) -> Prevalidator: + """Create a new prevalidator""" + try: + db_prevalidator = Prevalidator( + **prevalidator_data.model_dump(exclude_unset=True), + tenant_id=tenant_id, + company_id=company_id + ) + + db.add(db_prevalidator) + db.commit() + db.refresh(db_prevalidator) + + return db_prevalidator + + except IntegrityError as e: + db.rollback() + logger.error(f"IntegrityError creating prevalidator: {str(e)}") + raise HTTPException( + status_code=400, + detail="Prevalidator already exists", + ) + except Exception as e: + db.rollback() + logger.error(f"Error creating prevalidator: {str(e)}") + raise HTTPException( + status_code=500, detail="Error creating prevalidator") + + @staticmethod + def update( + db: Session, + prevalidator_id: int, + tenant_id: int, + prevalidator_data: PrevalidatorUpdateDTO, + company_id: int + ) -> Optional[Prevalidator]: + """Update a prevalidator""" + try: + db_prevalidator = PrevalidatorService.get_by_id( + db, prevalidator_id, tenant_id, company_id + ) + if not db_prevalidator: + return None + + for key, value in prevalidator_data.model_dump(exclude_unset=True).items(): + setattr(db_prevalidator, key, value) + + db.commit() + db.refresh(db_prevalidator) + + return db_prevalidator + + except IntegrityError as e: + db.rollback() + logger.error(f"IntegrityError updating prevalidator: {str(e)}") + raise HTTPException( + status_code=400, + detail="Error updating prevalidator", + ) + except Exception as e: + db.rollback() + logger.error(f"Error updating prevalidator: {str(e)}") + raise HTTPException( + status_code=500, detail="Error updating prevalidator" + ) + + @staticmethod + def delete( + db: Session, prevalidator_id: int, tenant_id: int, company_id: int + ) -> bool: + """Delete a prevalidator""" + try: + db_prevalidator = PrevalidatorService.get_by_id( + db, prevalidator_id, tenant_id, company_id + ) + if not db_prevalidator: + return False + + db.delete(db_prevalidator) + db.commit() + + return True + + except Exception as e: + db.rollback() + logger.error(f"Error deleting prevalidator: {str(e)}") + raise HTTPException( + status_code=500, detail="Error deleting prevalidator") + + @staticmethod + def get_by_customs( + db: Session, customs: str, tenant_id: int, company_id: int + ) -> List[Prevalidator]: + """Get all prevalidators by customs""" + return ( + db.query(Prevalidator) + .filter( + Prevalidator.customs_prevalidator == customs, + Prevalidator.tenant_id == tenant_id, + Prevalidator.company_id == company_id + ) + .all() + ) diff --git a/backend/api/v1/modules/a76/general_catalogs/seal/dto.py b/backend/api/v1/modules/a76/general_catalogs/seal/dto.py new file mode 100644 index 00000000..a0d919e8 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/seal/dto.py @@ -0,0 +1,31 @@ +""" +DTOs for Seal. +""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class SealBaseDTO(BaseModel): + seal: str = Field(..., description="Seal identifier", max_length=15) + + +class SealCreateDTO(SealBaseDTO): + """Schema for creating a seal""" + pass + + +class SealUpdateDTO(SealBaseDTO): + """Schema for updating a seal""" + seal: Optional[str] = Field(None, description="Seal identifier", max_length=15) + + +class SealResponseDTO(SealBaseDTO): + """Schema for seal response""" + id: int + company_id: int + tenant_id: int + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/general_catalogs/seal/models.py b/backend/api/v1/modules/a76/general_catalogs/seal/models.py new file mode 100644 index 00000000..2e254b40 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/seal/models.py @@ -0,0 +1,22 @@ +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 Seal(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "seal" + __table_args__ = ( + PrimaryKeyConstraint("id", name="seal_pkey"), + UniqueConstraint("tenant_id", "company_id", "seal", name="seal_ukey"), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + seal: Mapped[str] = mapped_column(String(15)) diff --git a/backend/api/v1/modules/a76/general_catalogs/seal/routes.py b/backend/api/v1/modules/a76/general_catalogs/seal/routes.py new file mode 100644 index 00000000..bc32231a --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/seal/routes.py @@ -0,0 +1,24 @@ +""" +Routes for managing Seal entries. +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from .dto import SealCreateDTO, SealResponseDTO, SealUpdateDTO +from .services import SealService + +# Create router using TenantCRUDRoutes factory +router = TenantCRUDRoutes( + service=SealService, + create_schema=SealCreateDTO, + update_schema=SealUpdateDTO, + response_schema=SealResponseDTO, + prefix="/seals", + tags=[], + resource_name="Seal", + id_name="id", # Using numeric ID + enable_list=True, # Enable GET /seals with pagination + enable_filters=True, # Enable filtering by seal + default_page_size=50, + max_page_size=100, +).router diff --git a/backend/api/v1/modules/a76/general_catalogs/seal/services.py b/backend/api/v1/modules/a76/general_catalogs/seal/services.py new file mode 100644 index 00000000..f29fcb2f --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/seal/services.py @@ -0,0 +1,106 @@ +""" +Service layer for Seal. +""" + +from typing import Optional, Tuple, List, Dict, Any + +from sqlalchemy.orm import Session + +from . import dto, models + + +class SealService: + """Service for Seal 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.Seal], int]: + """Get all seals for a tenant/company with pagination""" + query = db.query(models.Seal).filter( + models.Seal.tenant_id == tenant_id, + models.Seal.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("seal"): + query = query.filter( + models.Seal.seal.ilike(f"%{filters['seal']}%") + ) + + total = query.count() + seals = query.offset(skip).limit(limit).all() + + return seals, total + + @staticmethod + def get_by_id( + db: Session, seal_id: int, tenant_id: int, company_id: int + ) -> Optional[models.Seal]: + """Get seal by ID""" + return ( + db.query(models.Seal) + .filter( + models.Seal.id == seal_id, + models.Seal.tenant_id == tenant_id, + models.Seal.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + seal_data: dto.SealCreateDTO, + tenant_id: int, + company_id: int, + ) -> models.Seal: + """Create a new seal""" + new_seal = models.Seal( + **seal_data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + db.add(new_seal) + db.commit() + db.refresh(new_seal) + return new_seal + + @staticmethod + def update( + db: Session, + seal_id: int, + tenant_id: int, + seal_data: dto.SealUpdateDTO, + company_id: int, + ) -> Optional[models.Seal]: + """Update a seal""" + seal = SealService.get_by_id(db, seal_id, tenant_id, company_id) + if not seal: + return None + + # Update fields + update_data = seal_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(seal, field, value) + + db.commit() + db.refresh(seal) + return seal + + @staticmethod + def delete( + db: Session, seal_id: int, tenant_id: int, company_id: int + ) -> bool: + """Delete a seal""" + seal = SealService.get_by_id(db, seal_id, tenant_id, company_id) + if not seal: + return False + + db.delete(seal) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/general_catalogs/seal/test_seal.py b/backend/api/v1/modules/a76/general_catalogs/seal/test_seal.py new file mode 100644 index 00000000..22ac8ece --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/seal/test_seal.py @@ -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_seals(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/seal/", 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_seal_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/seal/invalid_id", headers=headers) + assert response.status_code == 404 + + +def test_create_seal_forbidden(): + response = client.post("/seal/", json={"name": "Test Seal"}) + assert response.status_code in (403, 405, 404) + + +def test_update_seal_forbidden(): + response = client.put("/seal/1", json={"name": "Updated Seal"}) + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/__init__.py b/backend/api/v1/modules/a76/general_catalogs/signatures/__init__.py new file mode 100644 index 00000000..4ee29dba --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de firmas +""" diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/dto.py b/backend/api/v1/modules/a76/general_catalogs/signatures/dto.py new file mode 100644 index 00000000..fc317714 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/dto.py @@ -0,0 +1,47 @@ +""" +DTOs (Data Transfer Objects) para módulo de firmas +""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class SignatureCreateDTO(BaseModel): + """DTO para crear una firma""" + + code: str = Field(..., max_length=10, description="Signature code") + + signature: Optional[str] = Field( + None, max_length=1000, description="Signature") + photo_path: Optional[str] = Field( + None, max_length=1000, description="Photo path") + + class Config: + from_attributes = True + + +class SignatureUpdateDTO(BaseModel): + """DTO para actualizar una firma""" + + signature: Optional[str] = Field( + None, max_length=1000, description="Signature") + photo_path: Optional[str] = Field( + None, max_length=1000, description="Photo path") + + class Config: + from_attributes = True + + +class SignatureResponseDTO(BaseModel): + """DTO para responder con datos de una firma""" + + id: int + code: str + signature: Optional[str] = None + photo_path: Optional[str] = None + tenant_id: int + company_id: int + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/models.py b/backend/api/v1/modules/a76/general_catalogs/signatures/models.py new file mode 100644 index 00000000..0885a276 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/models.py @@ -0,0 +1,39 @@ +""" +Modelos ORM para gestión de firmas +""" + +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 Signature(Base, TenantScopedMixin, TimestampMixin): + """ + Modelo para la tabla Signature - Firmas + """ + + __tablename__ = "signatures" # GFirmas + __table_args__ = ( + PrimaryKeyConstraint("id", name="signatures_pkey"), + UniqueConstraint("code", "tenant_id", "company_id", + name="signatures_code_unique"), + {"schema": "a76"}, + ) + + # Primary key + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + + # Signature code (unique) + code: Mapped[str] = mapped_column(String(10), nullable=False) + + # Signature information + signature: Mapped[Optional[str]] = mapped_column(String(1000)) + + photo_path: Mapped[Optional[str]] = mapped_column(String(1000)) + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py new file mode 100644 index 00000000..f7c390a5 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py @@ -0,0 +1,73 @@ +""" +Rutas para gestión de firmas +""" + +from typing import Any, Dict + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource +from fastapi import Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from core.database import get_core_db + +from .dto import SignatureCreateDTO, SignatureResponseDTO, SignatureUpdateDTO +from .service import SignatureService + +# Create router using TenantCRUDRoutes factory +signature_crud = TenantCRUDRoutes( + service=SignatureService, + create_schema=SignatureCreateDTO, + update_schema=SignatureUpdateDTO, + response_schema=SignatureResponseDTO, + prefix="/signatures", + tags=["signatures"], + resource_name="Signature", + id_name="id", # Using numeric ID + enable_list=True, # Enable GET /signatures with pagination + enable_filters=True, # Enable filtering by code + default_page_size=50, + max_page_size=100, +) + +router = signature_crud.router + + +@router.get( + "/code/{code}", + response_model=SignatureResponseDTO, + summary="Get signature by code", +) +async def get_signature_by_code( + code: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(signature_crud.db_dependency), + current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency), +): + """Get a signature by its code""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + signature = SignatureService.get_by_code(db, code, tenant_id, company_id) + if not signature: + raise HTTPException( + status_code=404, + detail="Signature not found", + ) + return SignatureResponseDTO.model_validate(signature) + + +@router.delete( + "/{signature_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete signature", +) +async def delete_signature( + signature_id: int, + db: Session = Depends(get_core_db), +): + """Delete a signature""" + success = SignatureService.delete(db, signature_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Signature not found", + ) + return None diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/service.py b/backend/api/v1/modules/a76/general_catalogs/signatures/service.py new file mode 100644 index 00000000..1a2a5e26 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/service.py @@ -0,0 +1,132 @@ +""" +Capa de servicio para lógica de negocio de firmas +""" + +import logging +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from . import dto, models + +logger = logging.getLogger(__name__) + + +class SignatureService: + """Servicio para gestión de firmas""" + + @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.Signature], int]: + """Get all signatures with pagination""" + query = db.query(models.Signature).filter( + models.Signature.tenant_id == tenant_id, + models.Signature.company_id == company_id, + ) + + if filters: + if filters.get("code"): + query = query.filter( + models.Signature.code.ilike(f"%{filters['code']}%")) + + total = query.count() + signatures = query.offset(skip).limit(limit).all() + + return signatures, total + + @staticmethod + def get_by_id( + db: Session, signature_id: int, tenant_id: int, company_id: int + ) -> Optional[models.Signature]: + """Get signature by ID""" + return ( + db.query(models.Signature) + .filter( + models.Signature.id == signature_id, + models.Signature.tenant_id == tenant_id, + models.Signature.company_id == company_id, + ) + .first() + ) + + @staticmethod + def get_by_code( + db: Session, code: str, tenant_id: int, company_id: int + ) -> Optional[models.Signature]: + """Get signature by code""" + return ( + db.query(models.Signature) + .filter( + models.Signature.code == code, + models.Signature.tenant_id == tenant_id, + models.Signature.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + signature_data: dto.SignatureCreateDTO, + tenant_id: int, + company_id: int, + ) -> models.Signature: + """Create a new signature""" + new_signature = models.Signature( + **signature_data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + try: + db.add(new_signature) + db.commit() + db.refresh(new_signature) + return new_signature + except IntegrityError as exc: + db.rollback() + # Constraint names: signatures_code_unique (code, tenant_id, company_id) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ya existe una firma con ese código para esta compañía", + ) from exc + + @staticmethod + def update( + db: Session, + signature_id: int, + tenant_id: int, + signature_data: dto.SignatureUpdateDTO, + company_id: int, + ) -> Optional[models.Signature]: + """Update a signature""" + signature = SignatureService.get_by_id( + db, signature_id, tenant_id, company_id) + if not signature: + return None + + for key, value in signature_data.model_dump(exclude_unset=True).items(): + setattr(signature, key, value) + + db.commit() + db.refresh(signature) + return signature + + @staticmethod + def delete( + db: Session, signature_id: int, tenant_id: int, company_id: int + ) -> bool: + """Delete a signature""" + signature = SignatureService.get_by_id( + db, signature_id, tenant_id, company_id) + if not signature: + return False + + db.delete(signature) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/general_catalogs/unit_conversions/__init__.py b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/unit_conversions/dto.py b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/dto.py new file mode 100644 index 00000000..0bb41aed --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/dto.py @@ -0,0 +1,24 @@ +from typing import Optional +from decimal import Decimal +from pydantic import BaseModel, Field, ConfigDict + +class UnitConversionBase(BaseModel): + # 👇 OJO: Son códigos (Strings), no IDs + from_unit_code: str = Field(..., max_length=5, description="Source Unit Code") + to_unit_code: str = Field(..., max_length=5, description="Target Unit Code") + conversion_factor: Optional[Decimal] = Field(None, description="Conversion Factor") + +class UnitConversionCreate(UnitConversionBase): + pass + +class UnitConversionUpdate(BaseModel): + from_unit_code: Optional[str] = Field(None, max_length=5) + to_unit_code: Optional[str] = Field(None, max_length=5) + conversion_factor: Optional[Decimal] = None + +class UnitConversionResponse(UnitConversionBase): + id: int + tenant_id: int + company_id: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/unit_conversions/models.py b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/models.py new file mode 100644 index 00000000..26893749 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/models.py @@ -0,0 +1,39 @@ +from typing import Optional +from decimal import Decimal +from sqlalchemy import Integer, String, ForeignKeyConstraint, UniqueConstraint, Numeric +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 UnitConversion(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "unit_conversions" + __table_args__ = ( + UniqueConstraint("from_unit_code", "to_unit_code", "tenant_id", "company_id", + name="uq_unit_conversion_pair"), + ForeignKeyConstraint( + ["from_unit_code", "tenant_id", "company_id"], + ["a76.units_of_measure.code", "a76.units_of_measure.tenant_id", + "a76.units_of_measure.company_id"], + ), + ForeignKeyConstraint( + ["to_unit_code", "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) + + from_unit_code: Mapped[str] = mapped_column(String(5), nullable=False) + + to_unit_code: Mapped[str] = mapped_column(String(5), nullable=False) + + conversion_factor: Mapped[Optional[Decimal]] = mapped_column( + Numeric(13, 6), nullable=True) + + # Relationships + # Note: Complex composite foreign keys might require explicit primaryjoin if used diff --git a/backend/api/v1/modules/a76/general_catalogs/unit_conversions/routes.py b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/routes.py new file mode 100644 index 00000000..caf14f2b --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/routes.py @@ -0,0 +1,16 @@ +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from .dto import UnitConversionCreate, UnitConversionResponse, UnitConversionUpdate +from .service import UnitConversionService + +router = TenantCRUDRoutes( + service=UnitConversionService, + create_schema=UnitConversionCreate, + update_schema=UnitConversionUpdate, + response_schema=UnitConversionResponse, + prefix="/unit-conversions", + tags=["a76.general_catalogs.unit_conversions"], + resource_name="UnitConversion", + id_name="id", + enable_list=True, + enable_filters=True, +).router diff --git a/backend/api/v1/modules/a76/general_catalogs/unit_conversions/service.py b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/service.py new file mode 100644 index 00000000..146b9729 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/unit_conversions/service.py @@ -0,0 +1,107 @@ +from typing import List, Optional, Tuple, Dict, Any + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .models import UnitConversion +from .dto import UnitConversionCreate, UnitConversionUpdate + + +class UnitConversionService: + @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[UnitConversion], int]: + query = db.query(UnitConversion).filter( + UnitConversion.tenant_id == tenant_id, + UnitConversion.company_id == company_id, + ) + + if filters: + # Add filters 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[UnitConversion]: + return db.query(UnitConversion).filter( + UnitConversion.id == id, + UnitConversion.tenant_id == tenant_id, + UnitConversion.company_id == company_id, + ).first() + + @staticmethod + def create( + db: Session, + data: UnitConversionCreate, + tenant_id: int, + company_id: int, + ) -> UnitConversion: + db_obj = UnitConversion( + **data.model_dump(), + tenant_id=tenant_id, + company_id=company_id + ) + try: + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + except IntegrityError as exc: + db.rollback() + # Puede ser FK de unidades o duplicado de (from_unit_code, to_unit_code, tenant, company) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verifica que los códigos de unidad existan y que la conversión no esté duplicada", + ) from exc + + @staticmethod + def update( + db: Session, + id: int, + tenant_id: int, + data: UnitConversionUpdate, + company_id: int, + ) -> Optional[UnitConversion]: + db_obj = UnitConversionService.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) + + try: + db.commit() + db.refresh(db_obj) + return db_obj + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verifica que los códigos de unidad existan y que la conversión no esté duplicada", + ) from exc + + @staticmethod + def delete( + db: Session, id: int, tenant_id: int, company_id: int + ) -> bool: + db_obj = UnitConversionService.get_by_id(db, id, tenant_id, company_id) + if not db_obj: + return False + + db.delete(db_obj) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/__init__.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py new file mode 100644 index 00000000..2864c135 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/dto.py @@ -0,0 +1,147 @@ +from typing import Optional +from decimal import Decimal +from pydantic import BaseModel, Field, ConfigDict + +# --- Base DTOs --- + + +class UnitOfMeasureACEBase(BaseModel): + code: str = Field(..., max_length=4, description="ACE Code") + description: Optional[str] = Field(None, max_length=49) + + +class UnitOfMeasureOMABase(BaseModel): + code: str = Field(..., max_length=10, description="OMA Code") + description: Optional[str] = Field(None, max_length=200) + + +class UnitOfMeasureAmericanBase(BaseModel): + code: str = Field(..., max_length=3, description="American Code") + description: Optional[str] = Field(None, max_length=40) + + +class UnitOfMeasureCustomsBase(BaseModel): + code: str = Field(..., max_length=2, description="Customs Code") + description: Optional[str] = Field(None, max_length=20) + scaii_unit_code: Optional[str] = Field(None, max_length=5) + + +class UnitOfMeasureBase(BaseModel): + code: str = Field(..., max_length=5, description="Unit Code") + description: Optional[str] = Field(None, max_length=100) + description_en: Optional[str] = Field(None, max_length=100) + customs_code: Optional[str] = Field(None, max_length=2) + american_code: Optional[str] = Field(None, max_length=3) + ace_code: Optional[str] = Field(None, max_length=4) + oma_code: Optional[str] = Field(None, max_length=10) + + +class UnitOfMeasureGeneralBase(BaseModel): + code: str = Field(..., max_length=5, description="Unit Code") + description: Optional[str] = Field(None, max_length=100) + conversion_factor: Optional[Decimal] = None + mexico_unit: Optional[str] = Field(None, max_length=5) + american_unit_code: Optional[str] = Field(None, max_length=5) + customs_code: Optional[str] = Field(None, max_length=2) + ace_code: Optional[str] = Field(None, max_length=4) + +# --- Create DTOs --- + + +class UnitOfMeasureACECreate(UnitOfMeasureACEBase): + pass + + +class UnitOfMeasureOMACreate(UnitOfMeasureOMABase): + pass + + +class UnitOfMeasureAmericanCreate(UnitOfMeasureAmericanBase): + pass + + +class UnitOfMeasureCustomsCreate(UnitOfMeasureCustomsBase): + pass + + +class UnitOfMeasureCreate(UnitOfMeasureBase): + pass + + +class UnitOfMeasureGeneralCreate(UnitOfMeasureGeneralBase): + pass + +# --- Update DTOs --- + + +class UnitOfMeasureACEUpdate(BaseModel): + code: Optional[str] = Field(None, max_length=4) + description: Optional[str] = Field(None, max_length=49) + + +class UnitOfMeasureOMAUpdate(BaseModel): + code: Optional[str] = Field(None, max_length=10) + description: Optional[str] = Field(None, max_length=200) + + +class UnitOfMeasureAmericanUpdate(BaseModel): + code: Optional[str] = Field(None, max_length=3) + description: Optional[str] = Field(None, max_length=40) + + +class UnitOfMeasureCustomsUpdate(BaseModel): + code: Optional[str] = Field(None, max_length=2) + description: Optional[str] = Field(None, max_length=20) + scaii_unit_code: Optional[str] = Field(None, max_length=5) + + +class UnitOfMeasureUpdate(BaseModel): + code: Optional[str] = Field(None, max_length=5) + description: Optional[str] = Field(None, max_length=100) + description_en: Optional[str] = Field(None, max_length=100) + customs_code: Optional[str] = Field(None, max_length=2) + american_code: Optional[str] = Field(None, max_length=3) + ace_code: Optional[str] = Field(None, max_length=4) + oma_code: Optional[str] = Field(None, max_length=10) + + +class UnitOfMeasureGeneralUpdate(BaseModel): + code: Optional[str] = Field(None, max_length=5) + description: Optional[str] = Field(None, max_length=100) + conversion_factor: Optional[Decimal] = None + mexico_unit: Optional[str] = Field(None, max_length=5) + american_unit_code: Optional[str] = Field(None, max_length=5) + customs_code: Optional[str] = Field(None, max_length=2) + ace_code: Optional[str] = Field(None, max_length=4) + +# --- Response DTOs --- + + +class UnitOfMeasureACEResponse(UnitOfMeasureACEBase): + id: int + model_config = ConfigDict(from_attributes=True) + + +class UnitOfMeasureOMAResponse(UnitOfMeasureOMABase): + id: int + model_config = ConfigDict(from_attributes=True) + + +class UnitOfMeasureAmericanResponse(UnitOfMeasureAmericanBase): + id: int + model_config = ConfigDict(from_attributes=True) + + +class UnitOfMeasureCustomsResponse(UnitOfMeasureCustomsBase): + id: int + model_config = ConfigDict(from_attributes=True) + + +class UnitOfMeasureResponse(UnitOfMeasureBase): + id: int + model_config = ConfigDict(from_attributes=True) + + +class UnitOfMeasureGeneralResponse(UnitOfMeasureGeneralBase): + id: int + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py new file mode 100644 index 00000000..ffd359db --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py @@ -0,0 +1,184 @@ +from typing import Optional +from decimal import Decimal +from sqlalchemy import ForeignKey, Integer, String, ForeignKeyConstraint, UniqueConstraint, Numeric +from sqlalchemy.orm import Mapped, mapped_column, relationship +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + +# 1. GUniMedACE + + +class UnitOfMeasureACE(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "unit_of_measure_ace" + __table_args__ = ( + UniqueConstraint("code", "tenant_id", "company_id", + name="uq_uom_ace_code"), + {"schema": "a76", "extend_existing": True} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(4), nullable=False) # CLAVEACE + description: Mapped[Optional[str]] = mapped_column( + String(49), nullable=True) + +# 2. GUMOMA + + +class UnitOfMeasureOMA(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "unit_of_measure_oma" + __table_args__ = ( + UniqueConstraint("code", "tenant_id", "company_id", + name="uq_uom_oma_code"), + {"schema": "a76", "extend_existing": True} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(10), nullable=False) # CLAVEUM + description: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True) + +# 3. GUMAme + + +class UnitOfMeasureAmerican(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "unit_of_measure_american" + __table_args__ = ( + UniqueConstraint("code", "tenant_id", "company_id", + name="uq_uom_american_code"), + {"schema": "a76", "extend_existing": True} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(3), nullable=False) # CLAVE + description: Mapped[Optional[str]] = mapped_column( + String(40), nullable=True) + +# 4. GUMAduana + + +class UnitOfMeasureCustoms(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "unit_of_measure_customs" + __table_args__ = ( + UniqueConstraint("code", "tenant_id", "company_id", + name="uq_uom_customs_code"), + {"schema": "a76", "extend_existing": True} + ) + + 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(20), nullable=True) + scaii_unit_code: Mapped[Optional[str]] = mapped_column( + String(5), nullable=True) # UNIDADSCAII + +# 5. GUniMedida (Main) + + +class UnitOfMeasure(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "units_of_measure" + __table_args__ = ( + UniqueConstraint("code", "tenant_id", + "company_id", name="uq_uom_code"), + ForeignKeyConstraint( + ["customs_code", "tenant_id", "company_id"], + ["a76.unit_of_measure_customs.code", "a76.unit_of_measure_customs.tenant_id", + "a76.unit_of_measure_customs.company_id"], + use_alter=True, + name="fk_uom_customs" + ), + ForeignKeyConstraint( + ["american_code", "tenant_id", "company_id"], + ["a76.unit_of_measure_american.code", "a76.unit_of_measure_american.tenant_id", + "a76.unit_of_measure_american.company_id"], + use_alter=True, + name="fk_uom_american" + ), + ForeignKeyConstraint( + ["ace_code", "tenant_id", "company_id"], + ["a76.unit_of_measure_ace.code", "a76.unit_of_measure_ace.tenant_id", + "a76.unit_of_measure_ace.company_id"], + use_alter=True, + name="fk_uom_ace" + ), + ForeignKeyConstraint( + ["oma_code", "tenant_id", "company_id"], + ["a76.unit_of_measure_oma.code", "a76.unit_of_measure_oma.tenant_id", + "a76.unit_of_measure_oma.company_id"], + use_alter=True, + name="fk_uom_oma" + ), + {"schema": "a76", "extend_existing": True} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(5), nullable=False) # CLAVEUNI + description: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True) + description_en: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True) + + customs_code: Mapped[Optional[str]] = mapped_column( + String(2), nullable=True) # CLAVE_AMEX + american_code: Mapped[Optional[str]] = mapped_column( + String(3), nullable=True) # CLAVE_AAMER + ace_code: Mapped[Optional[str]] = mapped_column( + String(4), nullable=True) # CLAVEACE + oma_code: Mapped[Optional[str]] = mapped_column( + String(10), nullable=True) # CLAVEOMA + + # Relationships omitted for simplicity or need explicit primaryjoin + customs_unit: Mapped[Optional["UnitOfMeasureCustoms"]] = relationship() + american_unit: Mapped[Optional["UnitOfMeasureAmerican"]] = relationship() + ace_unit: Mapped[Optional["UnitOfMeasureACE"]] = relationship() + oma_unit: Mapped[Optional["UnitOfMeasureOMA"]] = relationship() + +# 6. GUniMed (General/Conversion) + + +class UnitOfMeasureGeneral(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "units_of_measure_general" + __table_args__ = ( + UniqueConstraint("code", "tenant_id", "company_id", + name="uq_uom_general_code"), + ForeignKeyConstraint( + ["customs_code", "tenant_id", "company_id"], + ["a76.unit_of_measure_customs.code", "a76.unit_of_measure_customs.tenant_id", + "a76.unit_of_measure_customs.company_id"], + use_alter=True, + name="fk_uom_general_customs" + ), + ForeignKeyConstraint( + ["ace_code", "tenant_id", "company_id"], + ["a76.unit_of_measure_ace.code", "a76.unit_of_measure_ace.tenant_id", + "a76.unit_of_measure_ace.company_id"], + use_alter=True, + name="fk_uom_general_ace" + ), + {"schema": "a76", "extend_existing": True} + ) + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(5), nullable=False) # UNIDAD + description: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True) + conversion_factor: Mapped[Optional[Decimal]] = mapped_column( + Numeric(13, 6), nullable=True) + mexico_unit: Mapped[Optional[str]] = mapped_column( + String(5), nullable=True) + # UNIDAD_AME (Note: GUniMed has UNIDAD_AME varchar(5), but GUMAme has CLAVE varchar(3). Keeping as string for now) + american_unit_code: Mapped[Optional[str] + ] = mapped_column(String(5), nullable=True) + + customs_code: Mapped[Optional[str]] = mapped_column( + String(2), nullable=True) # CLAVE_ADUANA + ace_code: Mapped[Optional[str]] = mapped_column( + String(4), nullable=True) # CLAVEACE + + customs_unit: Mapped[Optional["UnitOfMeasureCustoms"]] = relationship() + ace_unit: Mapped[Optional["UnitOfMeasureACE"]] = relationship(overlaps="customs_unit") diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/routes.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/routes.py new file mode 100644 index 00000000..9a9b3db7 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/routes.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from . import dto, service + +router = APIRouter(prefix="/units-of-measure", + tags=["a76.general_catalogs.units_of_measure"]) + +# ACE +ace_router = TenantCRUDRoutes( + service=service.UnitOfMeasureACEService, + create_schema=dto.UnitOfMeasureACECreate, + update_schema=dto.UnitOfMeasureACEUpdate, + response_schema=dto.UnitOfMeasureACEResponse, + prefix="/ace", + tags=["a76.general_catalogs.units_of_measure"], + resource_name="UnitOfMeasureACE", + id_name="id", + enable_list=True, + enable_filters=True, +).router +router.include_router(ace_router) + +# OMA +oma_router = TenantCRUDRoutes( + service=service.UnitOfMeasureOMAService, + create_schema=dto.UnitOfMeasureOMACreate, + update_schema=dto.UnitOfMeasureOMAUpdate, + response_schema=dto.UnitOfMeasureOMAResponse, + prefix="/oma", + tags=["a76.general_catalogs.units_of_measure"], + resource_name="UnitOfMeasureOMA", + id_name="id", + enable_list=True, + enable_filters=True, +).router +router.include_router(oma_router) + +# American +american_router = TenantCRUDRoutes( + service=service.UnitOfMeasureAmericanService, + create_schema=dto.UnitOfMeasureAmericanCreate, + update_schema=dto.UnitOfMeasureAmericanUpdate, + response_schema=dto.UnitOfMeasureAmericanResponse, + prefix="/american", + tags=["a76.general_catalogs.units_of_measure"], + resource_name="UnitOfMeasureAmerican", + id_name="id", + enable_list=True, + enable_filters=True, +).router +router.include_router(american_router) + +# Customs +customs_router = TenantCRUDRoutes( + service=service.UnitOfMeasureCustomsService, + create_schema=dto.UnitOfMeasureCustomsCreate, + update_schema=dto.UnitOfMeasureCustomsUpdate, + response_schema=dto.UnitOfMeasureCustomsResponse, + prefix="/customs", + tags=["a76.general_catalogs.units_of_measure"], + resource_name="UnitOfMeasureCustoms", + id_name="id", + enable_list=True, + enable_filters=True, +).router +router.include_router(customs_router) + +# General +general_router = TenantCRUDRoutes( + service=service.UnitOfMeasureGeneralService, + create_schema=dto.UnitOfMeasureGeneralCreate, + update_schema=dto.UnitOfMeasureGeneralUpdate, + response_schema=dto.UnitOfMeasureGeneralResponse, + prefix="/general", + tags=["a76.general_catalogs.units_of_measure"], + resource_name="UnitOfMeasureGeneral", + id_name="id", + enable_list=True, + enable_filters=True, +).router +router.include_router(general_router) + +# Main UnitOfMeasure +# Note: We use prefix="" to map to /units-of-measure/ +main_router = TenantCRUDRoutes( + service=service.UnitOfMeasureService, + create_schema=dto.UnitOfMeasureCreate, + update_schema=dto.UnitOfMeasureUpdate, + response_schema=dto.UnitOfMeasureResponse, + prefix="", + tags=["a76.general_catalogs.units_of_measure"], + resource_name="UnitOfMeasure", + id_name="id", + enable_list=True, + enable_filters=True, +).router +router.include_router(main_router) diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py new file mode 100644 index 00000000..19304472 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py @@ -0,0 +1,156 @@ +from typing import List, Optional, Tuple, Dict, Any, Type +from sqlalchemy import Sequence +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException +import logging + +logger = logging.getLogger(__name__) + +from .models import ( + UnitOfMeasureACE, UnitOfMeasureOMA, UnitOfMeasureAmerican, UnitOfMeasureCustoms, + UnitOfMeasure, UnitOfMeasureGeneral +) +from .dto import ( + UnitOfMeasureACECreate, UnitOfMeasureACEUpdate, + UnitOfMeasureOMACreate, UnitOfMeasureOMAUpdate, + UnitOfMeasureAmericanCreate, UnitOfMeasureAmericanUpdate, + UnitOfMeasureCustomsCreate, UnitOfMeasureCustomsUpdate, + UnitOfMeasureCreate, UnitOfMeasureUpdate, + UnitOfMeasureGeneralCreate, UnitOfMeasureGeneralUpdate +) + + +class BaseService: + model = None + + @classmethod + def get_all( + cls, + db: Session, + tenant_id: int, + company_id: int, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None, + ) -> Tuple[List[Any], int]: + query = db.query(cls.model).filter( + cls.model.tenant_id == tenant_id, + cls.model.company_id == company_id, + ) + + if filters: + if filters.get("code"): + query = query.filter( + cls.model.code.ilike(f"%{filters['code']}%")) + if filters.get("description"): + if hasattr(cls.model, "description"): + query = query.filter(cls.model.description.ilike( + f"%{filters['description']}%")) + + total = query.count() + items = query.offset(skip).limit(limit).all() + return items, total + + @classmethod + def get_by_id( + cls, db: Session, id: int, tenant_id: int, company_id: int + ) -> Optional[Any]: + return db.query(cls.model).filter( + cls.model.id == id, + cls.model.tenant_id == tenant_id, + cls.model.company_id == company_id, + ).first() + + @classmethod + def create( + cls, + db: Session, + data: Any, + tenant_id: int, + company_id: int, + ) -> Any: + db_obj = cls.model( + **data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + @classmethod + def update( + cls, + db: Session, + id: int, + tenant_id: int, + data: Any, + company_id: int, + ) -> Optional[Any]: + db_obj = cls.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 + + @classmethod + def delete( + cls, db: Session, id: int, tenant_id: int, company_id: int + ) -> bool: + db_obj = cls.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 {cls.model.__name__} {id}: {str(e)}") + raise HTTPException( + status_code=400, + detail="No se puede eliminar esta unidad de medida porque tiene registros relacionados. Primero debe eliminar o reasignar esos registros." + ) + + +class UnitOfMeasureACEService(BaseService): + model = UnitOfMeasureACE + + +class UnitOfMeasureOMAService(BaseService): + model = UnitOfMeasureOMA + + +class UnitOfMeasureAmericanService(BaseService): + model = UnitOfMeasureAmerican + + +class UnitOfMeasureCustomsService(BaseService): + model = UnitOfMeasureCustoms + + +class UnitOfMeasureService(BaseService): + model = UnitOfMeasure + + +class UnitOfMeasureGeneralService(BaseService): + model = UnitOfMeasureGeneral + + +def get_all_uom_general(session: Session, skip: int = 0, limit: int = 100) -> Sequence[UnitOfMeasureGeneral]: + return BaseService._get_all(session, UnitOfMeasureGeneral, skip, limit) + + +def update_uom_general(session: Session, db_obj: UnitOfMeasureGeneral, data: UnitOfMeasureGeneralUpdate) -> UnitOfMeasureGeneral: + return BaseService._update(session, db_obj, data) + + +def delete_uom_general(session: Session, db_obj: UnitOfMeasureGeneral) -> UnitOfMeasureGeneral: + return BaseService._delete(session, db_obj) diff --git a/backend/api/v1/modules/a76/invoices/models.py b/backend/api/v1/modules/a76/invoices/models.py new file mode 100644 index 00000000..a5337664 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/models.py @@ -0,0 +1,368 @@ +from enum import Enum +from typing import Optional, List +from sqlalchemy import BigInteger, Boolean, Date, ForeignKey, Integer, Numeric, String, Text, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column, relationship +from core.database import Base +from datetime import datetime +from ....common.base_models import TenantScopedMixin, TimestampMixin + + +class OperationType(str, Enum): + IMP = "imp" # Importación + EXP = "exp" # Exportación + SM_IN = "sm_in" # Entrada SM + SM_OUT = "sm_out" # Salida SM + CTM_SEND = "ctm_send" # Envío CTM + CTM_RECEIVE = "ctm_receive" # Recibo CTM + + +class TransportType(str, Enum): + NONE = "none" + TRANSPORT = "transport" + BOX = "box" + PLATES = "licence plates" + TRUCK = "truck" + VESSEL = "vessel" + BARGE = "rail barge" + CONTAINER = "container" + AIRPLANE = "airplane" + GONDOLA = "gondola" + FLATBED = "flatbed" + + +# --- 1. Invoice Header (invoice_header) --- +class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_header" + __table_args__ = ({"schema": "a76"},) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + + # Identifiers + system: Mapped[Optional[str]] = mapped_column(String(10)) # SISTEMA / Sistema de origen <-- no tiene campo en la antigua base de datos, sera para fixed-asset(scaf), inventory(scaii) + operation_type: Mapped[OperationType] = mapped_column(String(10)) # TIPOMOVIMIENTO / Clasifica imp/exp/sm/ctm + invoice_type: Mapped[Optional[str]] = mapped_column(ForeignKey("public.invoice_types.key")) # TIPOFACTURA / TIPODOC + invoice_number: Mapped[Optional[str]] = mapped_column(String(20)) # FACTURAIMPO/FACTURAEXPO/FACTURAREMISION/FACTURAENVIO/FACTURASALIDA + project_number: Mapped[Optional[str]] = mapped_column(String(14)) # NUMPROYECTO + purchase_order: Mapped[Optional[str]] = mapped_column(String(50)) # ORDENCOMPRA + related_doc_id: Mapped[Optional[int]] = mapped_column(Integer) # IDRELDOC / Para Rectificaciones + alternate_invoice: Mapped[Optional[str]] = mapped_column(String(99)) # FACTURAALTERNA + invoice_ref: Mapped[Optional[str]] = mapped_column(String(19)) # FACTURAEXPOREF + proforma_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMEROPROFORMA + + # Dates + invoice_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAFACTURA + capture_date: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=False), default=datetime.now) # FECHACAPTURA + HORAACTUAL + emission_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAEMISION + + # Status & Control + is_updated: Mapped[Optional[bool]] = mapped_column(Boolean) # ESTATUS + updated_date: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=False)) # FECHAACTUALIZACION / FECHAACTUAL + who_updated: Mapped[Optional[str]] = mapped_column(String(20)) # USUARIOACT / Quien actualizó + capture_user: Mapped[Optional[str]] = mapped_column(String(20)) # USUARIOCAP / Usuario que capturó + + traffic_light_status: Mapped[Optional[str]] = mapped_column(String(50)) # SEMAFORO / SEMAFOROEXPO/IMPO + process_log: Mapped[Optional[str]] = mapped_column(String(300)) # COMOFUEPROCESADA + status_rec: Mapped[Optional[int]] = mapped_column(Integer) # ESTATUSREC / Estatus de recepción + status_rep: Mapped[Optional[str]] = mapped_column(String(2)) # ESTATUSREP / Estatus de reporte + + # Comments + observation_es: Mapped[Optional[str]] = mapped_column(Text) # OBSERVACIONE / Observaciones en español + observation_en: Mapped[Optional[str]] = mapped_column(Text) # OBSERVACIONI / Observaciones en inglés + comments_status: Mapped[Optional[str]] = mapped_column(Text) # COMENTARIOSESTATUS + vu_observations: Mapped[Optional[str]] = mapped_column(String(500)) # OBSERVACIONESVU / Observaciones VUCEM + + # Digital Archive Links + cfdi_uuid: Mapped[Optional[str]] = mapped_column(String(100)) # CFDIUUID + path_pdf: Mapped[Optional[str]] = mapped_column(String(500)) # CFDIPATHPDF + path_xml: Mapped[Optional[str]] = mapped_column(String(500)) # CFDIPATHXML + + # Control & Subcompany + subcompany: Mapped[Optional[str]] = mapped_column(String(5)) # SUBEMPRESA + party_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_PARTIDAS / Cantidad de partidas + + # Generation flags + generate_id: Mapped[Optional[str]] = mapped_column(String(1)) # GENERAID + generate_desc_parties: Mapped[Optional[str]] = mapped_column(String(12)) # GENDESCPARTIDAS / Generar descripción de partidas + apply_manual_discount: Mapped[Optional[str]] = mapped_column(String(1)) # APLICADESCMANUAL + + # Bulk & Downloads + is_bulk: Mapped[Optional[bool]] = mapped_column(Boolean) # ESAGRANEL / Es a granel + download_substance: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGASUST / Descarga de sustancia + download_class: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGACLASE / Descarga de clase + download_def: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGADEF / Descarga definitiva + + # Additional fields + payment_terms: Mapped[Optional[str]] = mapped_column(String(200)) # TERMINOSPAGO / Términos de pago + handling_fees: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # MANIOBRAS + option_iv18: Mapped[Optional[str]] = mapped_column(String(50)) # OPCIONIV18 + enajenation_goods: Mapped[Optional[bool]] = mapped_column(Boolean) # ENAJENACIONBIENES / Enajenación de bienes + + # Relationships + compliance_mx: Mapped[Optional["InvoiceComplianceMx"]] = relationship( + back_populates="header", cascade="all, delete-orphan", uselist=False) + financials: Mapped[Optional["InvoiceFinancials"]] = relationship( + back_populates="header", cascade="all, delete-orphan", uselist=False) + details: Mapped[List["InvoiceSalesDetails"]] = relationship( + back_populates="header", cascade="all, delete-orphan") + collections: Mapped[List["InvoiceCollections"]] = relationship( + back_populates="header", cascade="all, delete-orphan") + logistics: Mapped[List["InvoiceLogistics"]] = relationship( + back_populates="header", cascade="all, delete-orphan") + + +# --- 2. Compliance MX (invoice_compliance_mx) --- +class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_compliance_mx" + __table_args__ = ({"schema": "a76"},) + + invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"), primary_key=True) + + # Core Customs Data + pedimento: Mapped[Optional[str]] = mapped_column(String(19)) # PEDIMENTO/PEDIMENTOIMPO/EXPO + pedimento_code: Mapped[Optional[str]] = mapped_column(String(5)) # PEDIMENTOR1 + pedimento_k1: Mapped[Optional[str]] = mapped_column(String(15)) # PEDIMENTOK1 + remesa: Mapped[Optional[int]] = mapped_column(Integer) # REMESA + aduana: Mapped[Optional[str]] = mapped_column(ForeignKey("public.customs_sections.customs_code")) # ADUANA_CRUCE + port_of_entry: Mapped[Optional[str]] = mapped_column(String(6)) # PUERTOENTRADA / Puerto de entrada + destination: Mapped[Optional[str]] = mapped_column(String(3)) # DESTINO / Código de destino + manifest_number: Mapped[Optional[str]] = mapped_column(String(15)) # MANIFIESTO / Número de manifiesto + + # Clients & Providers + provider_header: Mapped[Optional[str]] = mapped_column(String(20)) # PROVEEDOREXPORTADOR + provider_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # PROVEEDOR + sold_to_header: Mapped[Optional[str]] = mapped_column(String(20)) # VENDIDOCONSIGNADO + sold_to_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOA + shipped_to_header: Mapped[Optional[str]] = mapped_column(String(20)) # ENVIADOTRANSFERIDO + shipped_to_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # ENVIADOA + shipped_by_header: Mapped[Optional[str]] = mapped_column(String(20)) # ENVIADOPORVENDIDOPOR + shipped_by_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOPOR/ENVIADOPOR + customs_broker_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.customs_brokers.id")) # AADUANAL / Agente aduanal + customs_broker_us_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.customs_brokers.id")) # AADUANALAME / Agente aduanal americano + + # Broker Invoice + broker_invoice_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMFACTURABROKER / Número factura broker + broker_invoice_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAFACBROKER / Fecha factura broker + + # Flags & Specific Regimes + is_mixed: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMIXTO / Es mixto + waste_type: Mapped[Optional[str]] = mapped_column(String(1)) # TIPODESPERDICIO / Tipo de desperdicio + scrap_type: Mapped[Optional[str]] = mapped_column(String(1)) # TIPOSCRAP / Tipo de scrap + appendix_17: Mapped[Optional[int]] = mapped_column(Integer) # APENDICE17 / Apéndice 17 + is_regime_change: Mapped[Optional[str]] = mapped_column(String(1)) # ESCAMBIOREGIMEN / Es cambio de régimen + which_exchange_rate: Mapped[Optional[str]] = mapped_column(String(5)) # CUALTIPOCAMBIO / Cuál tipo de cambio + value_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR / Método de valoración + act_value: Mapped[Optional[str]] = mapped_column(String(5)) # ACTVALOR / Actualizar valor + is_pedimento_pending: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # PED_PENDIENTE_ASIGNAR (Mapear 1 -> True, 0 -> False) + + # Ownership & Balances + is_owner_of_goods: Mapped[Optional[str]] = mapped_column(String(2)) # ESDUENOMCIA / Es dueño de mercancía + generate_balances: Mapped[Optional[str]] = mapped_column(String(2)) # GENERARSALDOS / Generar saldos + was_reviewed_by_company: Mapped[Optional[bool]] = mapped_column(Boolean) # FUEREVISADAMCIA / Fue revisada por la compañía + + # VUCEM / Digital + edocument: Mapped[Optional[str]] = mapped_column(String(50)) # EDOCUMENT / Documento electrónico + electronic_signature: Mapped[Optional[str]] = mapped_column(String(999)) # FIRMAELECTRONICA / Firma electrónica + certificate_number: Mapped[Optional[str]] = mapped_column(String(99)) # NUMEROCERTIFICADO / Número de certificado + niu_number: Mapped[Optional[str]] = mapped_column(String(19)) # NUMERONIU / Número NIU + bill_of_lading_count: Mapped[Optional[str]] = mapped_column(String(12)) # CANTGUIASEMBARQUE / Cantidad guías embarque + addendum_vu: Mapped[Optional[str]] = mapped_column(String(204)) # ADENDAVU / Adenda VUCEM + origin_destination_cove: Mapped[Optional[str]] = mapped_column(String(19)) # DESTINOORIGENCOVE / Destino/Origen COVE + vucem_operation_num: Mapped[Optional[str]] = mapped_column(String(19)) # NUMOPERACIONVU / Número operación VUCEM + customs_person_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAPERSONAAA / Línea persona agente aduanal + + # Additional Control + contingency_mode: Mapped[Optional[bool]] = mapped_column(Boolean) # MODOCONTINGENCIA / Modo contingencia + enclosure: Mapped[Optional[str]] = mapped_column(String(4)) # RECINTO / Recinto fiscal + guide_type_to_identify: Mapped[Optional[str]] = mapped_column(String(1)) # TIPODEGUIAAIDENTIFICAR / Tipo de guía a identificar + location: Mapped[Optional[str]] = mapped_column(String(200)) # LOCALIZACION / Localización + + # DOT & Official + dot_code: Mapped[Optional[str]] = mapped_column(String(20)) # CLAVEDOT / Clave DOT + subdivision: Mapped[Optional[str]] = mapped_column(String(20)) # SUBDIVISION / Subdivisión + acts_as: Mapped[Optional[str]] = mapped_column(String(20)) # FUNGECOMOCO / Funge como + movement_type: Mapped[Optional[str]] = mapped_column(String(31)) # TIPOMOV / Tipo de movimiento + office_document: Mapped[Optional[str]] = mapped_column(String(30)) # OFICIO / Oficio + reason_export: Mapped[Optional[str]] = mapped_column(String(1)) # RAZONEXPORTACION / Razón de exportación + signature_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFIRMA / Clave de firma + + # SM specific + sem_id: Mapped[Optional[int]] = mapped_column(Integer) # SEM / ID SEM (de SFacEntradaSM/SFacSalidaSM) + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="compliance_mx") + + +# --- 3. Financials (invoice_financials) --- +class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_financials" + __table_args__ = ({"schema": "a76"},) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) + + # Currency + currency: Mapped[Optional[str]] = mapped_column(String(3)) # CLAVEMONEDA / Clave de moneda + currency_type: Mapped[Optional[str]] = mapped_column(ForeignKey("public.currency_types.code")) # TIPOMONEDA / TIPOCLAVEMONEDA + exchange_rate: Mapped[Optional[float]] = mapped_column(Numeric(13, 6)) # TIPOCAMBIO / Tipo de cambio + exchange_rate_mm: Mapped[Optional[float]] = mapped_column(Numeric(13, 6)) # TIPOCAMBIOMM / Tipo de cambio moneda a moneda + + # Merchandise Values (MN = National Currency, ME = Foreign Currency, MC = Third Currency) + value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORIMPOMN/VALOREXPOMN/VALORENTMN/VALORSALMN + value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORIMPOME/VALOREXPOME/VALORENTME/VALORSALME + value_mc: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORIMPOMC/VALOREXPOMC + + # Customs Value + customs_value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORADUANASMN / Valor en aduanas MN + customs_value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORADUANASME / Valor en aduanas ME + + # Raw Materials + raw_material_value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORMPMN / Valor materia prima MN + raw_material_value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORMPME / Valor materia prima ME + + # Aggregate Value + aggregate_value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORAGREMN / Valor agregado MN + aggregate_value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORAGREME / Valor agregado ME + aggregate_value_mc: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORAGREMC / Valor agregado MC + + # Mexican Merchandise Value + mexican_value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORVMEXMN / Valor mercancía mexicana MN + mexican_value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORVMEXME / Valor mercancía mexicana ME + mexican_value_mc: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORVMEXMC / Valor mercancía mexicana MC + + # National Packaging + national_packaging_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALEMPAQUENACMN / Valor empaque nacional MN + national_packaging_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALEMPAQUENACME / Valor empaque nacional ME + national_packaging_mc: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALEMPAQUENACMC / Valor empaque nacional MC + + # Costs & Increments + freight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # FLETE / Flete + insurance: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # SEGUROS / Seguros + insurance_value: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # VALSEGUROS / Valor seguros + packaging: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # EMBALAJES / Embalajes + other_increments: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # OTROSINCREMENTA / Otros incrementables + total_increments_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # TOTALINCREMMN / Total incrementables MN + total_increments_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # TOTALINCREMME / Total incrementables ME + + # Taxes + iva_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # IVAEXPOMN/VALORIVAMN / IVA en MN + iva_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # IVAEXPOME/VALORIVAME / IVA en ME + iva_mc: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # IVAEXPOMC / IVA en MC + iva_factor: Mapped[Optional[str]] = mapped_column(String(10)) # FACTORIVA / Factor IVA (puede ser varchar en imports) + tax_value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORIMPUESTOME / Valor impuesto ME + seal_value_2500: Mapped[Optional[bool]] = mapped_column(Boolean) # SELLOVALOR2500 / Sello valor 2500 + + # Weights & Quantities + total_quantity: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # CANTEXPO/CANTIMPO / Cantidad total + gross_weight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # PESOBRUTO / Peso bruto + net_weight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # PESONETO / Peso neto + bundle_count: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS / Cantidad de bultos + weight_factor: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # FACTORPESO / Factor de peso + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="financials") + + +# --- 4. Logistics (invoice_logistics) --- +class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_logistics" + __table_args__ = ({"schema": "a76"},) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) + + # Carrier Info + carrier_id: Mapped[Optional[str]] = mapped_column(String(10)) # TRANSPORTISTA / Transportista + transport_id: Mapped[Optional[str]] = mapped_column(String(10)) # NUMTRAILER / Transportista + transport_us_id: Mapped[Optional[str]] = mapped_column(String(10)) # TRANSPORTISTAAME / Transportista americano + transport_type: Mapped[TransportType] = mapped_column(String(15), default="none") # TRANSPORTE / Tipo de transporte + transport_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMTRASPORTE / Número de transporte + transport_mode: Mapped[Optional[str]] = mapped_column(String(15)) # MODTRANS / Modo de transporte + driver_name: Mapped[Optional[str]] = mapped_column(String(80)) # CONDUCTOR / Nombre del conductor + is_rail: Mapped[Optional[str]] = mapped_column(String(2)) # ESFERROCARRIL / Es ferrocarril + rail_id: Mapped[Optional[str]] = mapped_column(String(31)) # IDFERRORCARRIL / ID ferrocarril + + # Vehicle & Tracking + vehicle_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMVEHICULO / Número de vehículo + license_plate: Mapped[Optional[str]] = mapped_column(String(20)) # NUMTRASPORTE / Número de transporte/placa + license_plate_complete: Mapped[Optional[str]] = mapped_column(String(40)) # NUMTRASPORTECOMPLE / Número transporte completo + trailer_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMTRAILER / Número de trailer + seal_number: Mapped[Optional[str]] = mapped_column(String(15)) # PRECINTO / Precinto + guide_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMEROGUIA / Número de guía + bill_number: Mapped[Optional[str]] = mapped_column(String(15)) # BILLNUMBER / Número de bill + reference_number: Mapped[Optional[str]] = mapped_column(String(14)) # NUMREFERENCIA / Número de referencia + shipment_number: Mapped[Optional[str]] = mapped_column(String(19)) # NUMEMBARQUE / Número de embarque + + # Incoterms + incoterm: Mapped[Optional[str]] = mapped_column(String(5)) # INCOTERM / Término de comercio internacional + + # Identifiers & Complements + identifier_1: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR / Identificador 1 + complement_1: Mapped[Optional[str]] = mapped_column(String(30)) # COMPLEMENTO1 / Complemento 1 + identifier_2: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR2 / Identificador 2 + complement_2: Mapped[Optional[str]] = mapped_column(String(30)) # COMPLEMENTO2 / Complemento 2 + + # Weight & Container Info + weight_type: Mapped[Optional[str]] = mapped_column(String(6)) # TIPOPESO / Tipo de peso + container_types: Mapped[Optional[str]] = mapped_column(String(500)) # CONTENEDORESTIPO / Tipos de contenedores + vehicle_data: Mapped[Optional[str]] = mapped_column(String(500)) # DATOSVEHICULO / Datos del vehículo + + # Locations & Routes + origin_location: Mapped[Optional[str]] = mapped_column(String(200)) # ORIGENUBICACION / Ubicación de origen + destination_location: Mapped[Optional[str]] = mapped_column(String(200)) # DESTINOUBICACION / Ubicación de destino + transport_itinerary: Mapped[Optional[str]] = mapped_column(String(1000)) # ITINERARIOTRANPORTE / Itinerario del transporte + destination_goods: Mapped[Optional[str]] = mapped_column(String(50)) # DESTINOMCIA / Destino de mercancía + + # Logistics Dates + entry_exit_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAENTRADA/FECHAENVIO/FECHARECIBO / Fecha entrada/salida + delivery_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAENTREGA / Fecha de entrega + + # Delivery Control + delivered_status: Mapped[Optional[str]] = mapped_column(String(2)) # ENTREGADO / Estado de entrega + received_by: Mapped[Optional[str]] = mapped_column(String(50)) # RECIBIDOPOR / Recibido por + + # Payment Info + payment_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAPAGO / Fecha de pago + payment_receipt_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMRECIBOPAGO / Número de recibo de pago + + # CTM Process + is_ctm_process: Mapped[Optional[str]] = mapped_column(String(2)) # SETRATAPROCESOCTM / Se trata de proceso CTM + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="logistics") + + +# --- 5. Sales Order Details (invoice_sales_details) --- +class InvoiceSalesDetails(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_sales_details" + __table_args__ = ({"schema": "a76"},) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) + + line_number: Mapped[int] = mapped_column(Integer) # LINEA / Número de línea + sales_order: Mapped[Optional[str]] = mapped_column(String(20)) # ORDENVENTA / Orden de venta + + # Specific Custom Fields + colors_description: Mapped[Optional[str]] = mapped_column(String(49)) # COLORES / Descripción de colores + square_color_code: Mapped[Optional[str]] = mapped_column(String(1)) # COLORCUADRITO / Código de color cuadrito + line_bundles: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS / Cantidad de bultos de la línea + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="details") + + +# --- 6. Collections (invoice_collections) --- +class InvoiceCollections(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "invoice_collections" + __table_args__ = ({"schema": "a76"},) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) + + line_number: Mapped[int] = mapped_column(Integer) # LINEA / Número de línea + invoice_number: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURA / Número de factura + concept: Mapped[Optional[str]] = mapped_column(String(100)) # CONCEPTO / Concepto + + # Relationship + header: Mapped["InvoiceHeader"] = relationship(back_populates="collections") + concept: Mapped[Optional[str]] = mapped_column(String(100)) # CONCEPTO / Conce \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py new file mode 100644 index 00000000..e81706a1 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -0,0 +1,284 @@ +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, Path +from sqlalchemy.orm import Session + +from . import schemas, services + +# Create main router +router = APIRouter() + +# Create CRUD routes for Invoice Header using TenantCRUDRoutes +invoice_crud = TenantCRUDRoutes( + service=services.InvoiceService, + create_schema=schemas.InvoiceHeaderCreate, + update_schema=schemas.InvoiceHeaderUpdate, + response_schema=schemas.InvoiceHeaderResponse, + prefix="/invoices", + tags=[], + resource_name="Invoice", + id_name="invoice_id", + id_type=int, + enable_list=True, # Enable list endpoint with pagination + enable_filters=True, # Enable filters for status, operation_type, etc. + default_page_size=50, + max_page_size=200, +) + +# Include the main CRUD routes +router.include_router(invoice_crud.router) + + +# Additional nested routes for child resources + +# --- Logistics Routes --- + +@router.get( + "/invoices/{invoice_id}/logistics", + response_model=list[schemas.InvoiceLogisticsResponse], + summary="Get all logistics for an invoice", +) +def get_invoice_logistics( + invoice_id: int = Path(..., description="Invoice ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all logistics entries for a specific invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + logistics = services.InvoiceLogisticsService.get_all_by_invoice( + db, invoice_id) + return logistics + + +@router.post( + "/invoices/{invoice_id}/logistics", + response_model=schemas.InvoiceLogisticsResponse, + status_code=201, + summary="Add logistics to an invoice", +) +def create_invoice_logistics( + invoice_id: int = Path(..., description="Invoice ID"), + logistics_data: schemas.InvoiceLogisticsCreate = ..., + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Add a new logistics entry to an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + logistics = services.InvoiceLogisticsService.create( + db, logistics_data, invoice_id, tenant_id, company_id) + return logistics + + +@router.delete( + "/invoices/{invoice_id}/logistics/{logistics_id}", + status_code=204, + summary="Delete logistics from an invoice", +) +def delete_invoice_logistics( + invoice_id: int = Path(..., description="Invoice ID"), + logistics_id: int = Path(..., description="Logistics ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a logistics entry from an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + success = services.InvoiceLogisticsService.delete( + db, logistics_id, invoice_id) + if not success: + raise HTTPException( + status_code=404, detail="Logistics entry not found") + + return None + + +# --- Sales Details Routes --- + +@router.get( + "/invoices/{invoice_id}/details", + response_model=list[schemas.InvoiceSalesDetailsResponse], + summary="Get all sales details for an invoice", +) +def get_invoice_details( + invoice_id: int = Path(..., description="Invoice ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all sales details for a specific invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + details = services.InvoiceSalesDetailsService.get_all_by_invoice( + db, invoice_id) + return details + + +@router.post( + "/invoices/{invoice_id}/details", + response_model=schemas.InvoiceSalesDetailsResponse, + status_code=201, + summary="Add sales detail to an invoice", +) + +def create_invoice_detail( + invoice_id: int = Path(..., description="Invoice ID"), + detail_data: schemas.InvoiceSalesDetailsCreate = ..., + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Add a new sales detail to an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + detail = services.InvoiceSalesDetailsService.create( + db, detail_data, invoice_id, tenant_id, company_id) + return detail + + +@router.delete( + "/invoices/{invoice_id}/details/{detail_id}", + status_code=204, + summary="Delete sales detail from an invoice", +) +def delete_invoice_detail( + invoice_id: int = Path(..., description="Invoice ID"), + detail_id: int = Path(..., description="Detail ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a sales detail from an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + success = services.InvoiceSalesDetailsService.delete( + db, detail_id, invoice_id) + if not success: + raise HTTPException(status_code=404, detail="Sales detail not found") + + return None + + +# --- Collections Routes --- + +@router.get( + "/invoices/{invoice_id}/collections", + response_model=list[schemas.InvoiceCollectionsResponse], + summary="Get all collections for an invoice", +) +def get_invoice_collections( + invoice_id: int = Path(..., description="Invoice ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all collections for a specific invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + collections = services.InvoiceCollectionsService.get_all_by_invoice( + db, invoice_id) + return collections + + +@router.post( + "/invoices/{invoice_id}/collections", + response_model=schemas.InvoiceCollectionsResponse, + status_code=201, + summary="Add collection to an invoice", +) +def create_invoice_collection( + invoice_id: int = Path(..., description="Invoice ID"), + collection_data: schemas.InvoiceCollectionsCreate = ..., + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Add a new collection to an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + collection = services.InvoiceCollectionsService.create( + db, collection_data, invoice_id, tenant_id, company_id) + return collection + + +@router.delete( + "/invoices/{invoice_id}/collections/{collection_id}", + status_code=204, + summary="Delete collection from an invoice", +) +def delete_invoice_collection( + invoice_id: int = Path(..., description="Invoice ID"), + collection_id: int = Path(..., description="Collection ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a collection from an invoice""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verify the invoice exists and belongs to the tenant/company + invoice = services.InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + success = services.InvoiceCollectionsService.delete( + db, collection_id, invoice_id) + if not success: + raise HTTPException(status_code=404, detail="Collection not found") + + return None diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py new file mode 100644 index 00000000..2afaec50 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -0,0 +1,489 @@ +from typing import Optional, List +from datetime import datetime, date +from decimal import Decimal +from pydantic import BaseModel, Field +from .models import OperationType + + +# --- Base Schemas --- +class InvoiceHeaderBase(BaseModel): + """Base fields for Invoice Header""" + system: Optional[str] = Field( + None, max_length=10, description="System of origin") + operation_type: Optional[OperationType] = Field( + None, max_length=10, description="Operation type: imp/exp/sm/ctm") + invoice_type: Optional[str] = Field( + None, max_length=5, description="Invoice type key") + invoice_number: Optional[str] = Field( + None, max_length=20, description="Invoice number") + project_number: Optional[str] = Field( + None, max_length=14, description="Project number") + purchase_order: Optional[str] = Field( + None, max_length=50, description="Purchase order") + related_doc_id: Optional[int] = Field( + None, description="Related document ID for rectifications") + alternate_invoice: Optional[str] = Field( + None, max_length=99, description="Alternate invoice") + invoice_ref: Optional[str] = Field( + None, max_length=19, description="Invoice reference") + proforma_number: Optional[str] = Field( + None, max_length=20, description="Proforma number") + invoice_date: Optional[date] = Field(None, description="Invoice date") + emission_date: Optional[date] = Field(None, description="Emission date") + is_updated: Optional[bool] = Field(None, description="Status") + updated_date: Optional[datetime] = Field(None, description="Update date") + who_updated: Optional[str] = Field( + None, max_length=20, description="Who updated") + capture_user: Optional[str] = Field( + None, max_length=20, description="Capture user") + traffic_light_status: Optional[str] = Field( + None, max_length=50, description="Traffic light status") + process_log: Optional[str] = Field( + None, max_length=300, description="Processing log") + status_rec: Optional[int] = Field(None, description="Reception status") + status_rep: Optional[str] = Field( + None, max_length=2, description="Report status") + observation_es: Optional[str] = Field( + None, description="Observations in Spanish") + observation_en: Optional[str] = Field( + None, description="Observations in English") + comments_status: Optional[str] = Field( + None, description="Comments status") + vu_observations: Optional[str] = Field( + None, max_length=500, description="VUCEM observations") + cfdi_uuid: Optional[str] = Field( + None, max_length=100, description="CFDI UUID") + path_pdf: Optional[str] = Field( + None, max_length=500, description="Path to PDF file") + path_xml: Optional[str] = Field( + None, max_length=500, description="Path to XML file") + subcompany: Optional[str] = Field( + None, max_length=5, description="Subcompany") + party_count: Optional[int] = Field(None, description="Quantity of parties") + generate_id: Optional[str] = Field( + None, max_length=1, description="Generate ID") + generate_desc_parties: Optional[str] = Field( + None, max_length=12, description="Generate description of parties") + apply_manual_discount: Optional[str] = Field( + None, max_length=1, description="Apply manual discount") + is_bulk: Optional[bool] = Field(None, description="Is bulk") + download_substance: Optional[bool] = Field( + None, description="Download substance") + download_class: Optional[bool] = Field( + None, description="Download class") + download_def: Optional[bool] = Field( + None, description="Definitive download") + payment_terms: Optional[str] = Field( + None, max_length=200, description="Payment terms") + handling_fees: Optional[Decimal] = Field(None, description="Handling fees") + option_iv18: Optional[str] = Field( + None, max_length=50, description="Option IV18") + enajenation_goods: Optional[bool] = Field( + None, description="Enajenation of goods") + + +class InvoiceComplianceMxBase(BaseModel): + """Base fields for Compliance MX""" + pedimento: Optional[str] = Field( + None, max_length=19, description="Pedimento number") + pedimento_code: Optional[str] = Field( + None, max_length=5, description="Pedimento code (R1)") + pedimento_k1: Optional[str] = Field( + None, max_length=15, description="Pedimento K1") + remesa: Optional[int] = Field(None, description="Remesa") + aduana: Optional[str] = Field( + None, max_length=5, description="Customs office") + port_of_entry: Optional[str] = Field( + None, max_length=6, description="Port of entry") + destination: Optional[str] = Field( + None, max_length=3, description="Destination code") + manifest_number: Optional[str] = Field( + None, max_length=15, description="Manifest number") + provider_header: Optional[str] = Field( + None, max_length=20, description="Provider header") + provider_id: Optional[str] = Field( + None, description="Provider ID") + sold_to_header: Optional[str] = Field( + None, max_length=20, description="Sold to header") + sold_to_id: Optional[str] = Field( + None, description="Sold to ID") + shipped_to_header: Optional[str] = Field( + None, max_length=20, description="Shipped to header") + shipped_to_id: Optional[str] = Field( + None, description="Shipped to ID") + shipped_by_header: Optional[str] = Field( + None, max_length=20, description="Shipped by header") + shipped_by_id: Optional[str] = Field( + None, description="Shipped by ID") + customs_broker_id: Optional[str] = Field( + None, description="Customs broker ID") + customs_broker_us_id: Optional[str] = Field( + None, description="US customs broker ID") + broker_invoice_num: Optional[str] = Field( + None, max_length=20, description="Broker invoice number") + broker_invoice_date: Optional[date] = Field( + None, description="Broker invoice date") + is_mixed: Optional[bool] = Field( + None, description="Is mixed operation") + waste_type: Optional[str] = Field( + None, max_length=1, description="Waste type") + scrap_type: Optional[str] = Field( + None, max_length=1, description="Scrap type") + appendix_17: Optional[int] = Field(None, description="Appendix 17") + is_regime_change: Optional[str] = Field( + None, max_length=1, description="Is regime change") + which_exchange_rate: Optional[str] = Field( + None, max_length=5, description="Which exchange rate") + value_method: Optional[str] = Field( + None, max_length=2, description="Value method") + act_value: Optional[str] = Field( + None, max_length=5, description="Act value") + is_pedimento_pending: Optional[bool] = Field( + None, description="Is pedimento pending") + is_owner_of_goods: Optional[str] = Field( + None, max_length=2, description="Is owner of goods") + generate_balances: Optional[str] = Field( + None, max_length=2, description="Generate balances") + was_reviewed_by_company: Optional[bool] = Field( + None, description="Was reviewed by company") + edocument: Optional[str] = Field( + None, max_length=50, description="E-document") + electronic_signature: Optional[str] = Field( + None, max_length=999, description="Electronic signature") + certificate_number: Optional[str] = Field( + None, max_length=99, description="Certificate number") + niu_number: Optional[str] = Field( + None, max_length=19, description="NIU number") + bill_of_lading_count: Optional[str] = Field( + None, max_length=12, description="Bill of lading count") + addendum_vu: Optional[str] = Field( + None, max_length=204, description="VUCEM addendum") + origin_destination_cove: Optional[str] = Field( + None, max_length=19, description="Origin/Destination COVE") + vucem_operation_num: Optional[str] = Field( + None, max_length=19, description="VUCEM operation number") + customs_person_line: Optional[int] = Field( + None, description="Customs person line") + contingency_mode: Optional[bool] = Field( + None, description="Contingency mode") + enclosure: Optional[str] = Field( + None, max_length=4, description="Enclosure") + guide_type_to_identify: Optional[str] = Field( + None, max_length=1, description="Guide type to identify") + location: Optional[str] = Field( + None, max_length=200, description="Location") + dot_code: Optional[str] = Field( + None, max_length=20, description="DOT code") + subdivision: Optional[str] = Field( + None, max_length=20, description="Subdivision") + acts_as: Optional[str] = Field( + None, max_length=20, description="Acts as") + movement_type: Optional[str] = Field( + None, max_length=31, description="Movement type") + office_document: Optional[str] = Field( + None, max_length=30, description="Office document") + reason_export: Optional[str] = Field( + None, max_length=1, description="Reason for export") + signature_key: Optional[str] = Field( + None, max_length=10, description="Signature key") + sem_id: Optional[int] = Field(None, description="SEM ID") + + +class InvoiceFinancialsBase(BaseModel): + """Base fields for Financials""" + currency: Optional[str] = Field( + None, max_length=3, description="Currency code") + currency_type: Optional[str] = Field( + None, description="Currency type") + exchange_rate: Optional[Decimal] = Field(None, description="Exchange rate") + exchange_rate_mm: Optional[Decimal] = Field( + None, description="Exchange rate currency to currency") + value_mn: Optional[Decimal] = Field(None, description="Value in MXN") + value_me: Optional[Decimal] = Field( + None, description="Value in foreign currency") + value_mc: Optional[Decimal] = Field( + None, description="Value in third currency") + customs_value_mn: Optional[Decimal] = Field( + None, description="Customs value in MXN") + customs_value_me: Optional[Decimal] = Field( + None, description="Customs value in foreign currency") + raw_material_value_mn: Optional[Decimal] = Field( + None, description="Raw material value in MXN") + raw_material_value_me: Optional[Decimal] = Field( + None, description="Raw material value in foreign currency") + aggregate_value_mn: Optional[Decimal] = Field( + None, description="Aggregate value in MXN") + aggregate_value_me: Optional[Decimal] = Field( + None, description="Aggregate value in foreign currency") + aggregate_value_mc: Optional[Decimal] = Field( + None, description="Aggregate value in third currency") + mexican_value_mn: Optional[Decimal] = Field( + None, description="Mexican merchandise value in MXN") + mexican_value_me: Optional[Decimal] = Field( + None, description="Mexican merchandise value in foreign currency") + mexican_value_mc: Optional[Decimal] = Field( + None, description="Mexican merchandise value in third currency") + national_packaging_mn: Optional[Decimal] = Field( + None, description="National packaging in MXN") + national_packaging_me: Optional[Decimal] = Field( + None, description="National packaging in foreign currency") + national_packaging_mc: Optional[Decimal] = Field( + None, description="National packaging in third currency") + freight: Optional[Decimal] = Field(None, description="Freight cost") + insurance: Optional[Decimal] = Field(None, description="Insurance cost") + insurance_value: Optional[Decimal] = Field( + None, description="Insurance value") + packaging: Optional[Decimal] = Field(None, description="Packaging") + other_increments: Optional[Decimal] = Field( + None, description="Other increments") + total_increments_mn: Optional[Decimal] = Field( + None, description="Total increments in MXN") + total_increments_me: Optional[Decimal] = Field( + None, description="Total increments in foreign currency") + iva_mn: Optional[Decimal] = Field(None, description="IVA in MXN") + iva_me: Optional[Decimal] = Field( + None, description="IVA in foreign currency") + iva_mc: Optional[Decimal] = Field( + None, description="IVA in third currency") + iva_factor: Optional[str] = Field( + None, max_length=10, description="IVA factor") + tax_value_me: Optional[Decimal] = Field( + None, description="Tax value in foreign currency") + seal_value_2500: Optional[bool] = Field( + None, description="Seal value 2500") + total_quantity: Optional[Decimal] = Field( + None, description="Total quantity") + gross_weight: Optional[Decimal] = Field(None, description="Gross weight") + net_weight: Optional[Decimal] = Field(None, description="Net weight") + bundle_count: Optional[int] = Field(None, description="Bundle count") + weight_factor: Optional[Decimal] = Field(None, description="Weight factor") + + +class InvoiceLogisticsBase(BaseModel): + """Base fields for Logistics""" + carrier_id: Optional[str] = Field( + None, max_length=10, description="Carrier ID") + transport_id: Optional[str] = Field( + None, max_length=10, description="Transport ID") + transport_us_id: Optional[str] = Field( + None, max_length=10, description="US transport ID") + transport_type: Optional[str] = Field( + None, max_length=15, description="Transport type") + transport_num: Optional[str] = Field( + None, max_length=20, description="Transport number") + transport_mode: Optional[str] = Field( + None, max_length=15, description="Transport mode") + driver_name: Optional[str] = Field( + None, max_length=80, description="Driver name") + is_rail: Optional[str] = Field( + None, max_length=2, description="Is rail transport") + rail_id: Optional[str] = Field( + None, max_length=31, description="Rail ID") + vehicle_num: Optional[str] = Field( + None, max_length=20, description="Vehicle number") + license_plate: Optional[str] = Field( + None, max_length=20, description="License plate") + license_plate_complete: Optional[str] = Field( + None, max_length=40, description="Complete license plate") + trailer_num: Optional[str] = Field( + None, max_length=20, description="Trailer number") + seal_number: Optional[str] = Field( + None, max_length=15, description="Seal number") + guide_number: Optional[str] = Field( + None, max_length=20, description="Guide number") + bill_number: Optional[str] = Field( + None, max_length=15, description="Bill number") + reference_number: Optional[str] = Field( + None, max_length=14, description="Reference number") + shipment_number: Optional[str] = Field( + None, max_length=19, description="Shipment number") + incoterm: Optional[str] = Field( + None, max_length=5, description="Incoterm") + identifier_1: Optional[str] = Field( + None, max_length=2, description="Identifier 1") + complement_1: Optional[str] = Field( + None, max_length=30, description="Complement 1") + identifier_2: Optional[str] = Field( + None, max_length=2, description="Identifier 2") + complement_2: Optional[str] = Field( + None, max_length=30, description="Complement 2") + weight_type: Optional[str] = Field( + None, max_length=6, description="Weight type") + container_types: Optional[str] = Field( + None, max_length=500, description="Container types") + vehicle_data: Optional[str] = Field( + None, max_length=500, description="Vehicle data") + origin_location: Optional[str] = Field( + None, max_length=200, description="Origin location") + destination_location: Optional[str] = Field( + None, max_length=200, description="Destination location") + transport_itinerary: Optional[str] = Field( + None, max_length=1000, description="Transport itinerary") + destination_goods: Optional[str] = Field( + None, max_length=50, description="Destination of goods") + entry_exit_date: Optional[date] = Field( + None, description="Entry/Exit date") + delivery_date: Optional[date] = Field( + None, description="Delivery date") + delivered_status: Optional[str] = Field( + None, max_length=2, description="Delivered status") + received_by: Optional[str] = Field( + None, max_length=50, description="Received by") + payment_date: Optional[date] = Field( + None, description="Payment date") + payment_receipt_num: Optional[str] = Field( + None, max_length=20, description="Payment receipt number") + is_ctm_process: Optional[str] = Field( + None, max_length=2, description="Is CTM process") + + +class InvoiceSalesDetailsBase(BaseModel): + """Base fields for Sales Details""" + line_number: int = Field(..., description="Line number") + sales_order: Optional[str] = Field( + None, max_length=20, description="Sales order") + colors_description: Optional[str] = Field( + None, max_length=49, description="Colors description") + square_color_code: Optional[str] = Field( + None, max_length=1, description="Square color code") + line_bundles: Optional[int] = Field(None, description="Line bundles count") + + +class InvoiceCollectionsBase(BaseModel): + """Base fields for Collections""" + line_number: int = Field(..., description="Line number") + invoice_number: Optional[str] = Field( + None, max_length=15, description="Invoice number") + concept: Optional[str] = Field(None, max_length=100, description="Concept") + + +# --- Create Schemas --- + +class InvoiceComplianceMxCreate(InvoiceComplianceMxBase): + """Schema for creating Compliance MX""" + pass + + +class InvoiceFinancialsCreate(InvoiceFinancialsBase): + """Schema for creating Financials""" + pass + + +class InvoiceLogisticsCreate(InvoiceLogisticsBase): + """Schema for creating Logistics""" + pass + + +class InvoiceSalesDetailsCreate(InvoiceSalesDetailsBase): + """Schema for creating Sales Details""" + pass + + +class InvoiceCollectionsCreate(InvoiceCollectionsBase): + """Schema for creating Collections""" + pass + + +class InvoiceHeaderCreate(InvoiceHeaderBase): + """Schema for creating Invoice Header with nested relations""" + compliance_mx: Optional[InvoiceComplianceMxCreate] = None + financials: Optional[InvoiceFinancialsCreate] = None + logistics: Optional[List[InvoiceLogisticsCreate]] = None + details: Optional[List[InvoiceSalesDetailsCreate]] = None + collections: Optional[List[InvoiceCollectionsCreate]] = None + + +# --- Update Schemas --- + +class InvoiceComplianceMxUpdate(InvoiceComplianceMxBase): + """Schema for updating Compliance MX""" + pass + + +class InvoiceFinancialsUpdate(InvoiceFinancialsBase): + """Schema for updating Financials""" + pass + + +class InvoiceLogisticsUpdate(InvoiceLogisticsBase): + """Schema for updating Logistics""" + pass + + +class InvoiceSalesDetailsUpdate(InvoiceSalesDetailsBase): + """Schema for updating Sales Details""" + line_number: Optional[int] = None + + +class InvoiceCollectionsUpdate(InvoiceCollectionsBase): + """Schema for updating Collections""" + line_number: Optional[int] = None + + +class InvoiceHeaderUpdate(InvoiceHeaderBase): + """Schema for updating Invoice Header with nested relations""" + compliance_mx: Optional[InvoiceComplianceMxUpdate] = None + financials: Optional[InvoiceFinancialsUpdate] = None + logistics: Optional[List[InvoiceLogisticsUpdate]] = None + details: Optional[List[InvoiceSalesDetailsUpdate]] = None + collections: Optional[List[InvoiceCollectionsUpdate]] = None + + +# --- Response Schemas --- + +class InvoiceComplianceMxResponse(InvoiceComplianceMxBase): + """Schema for Compliance MX response""" + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceFinancialsResponse(InvoiceFinancialsBase): + """Schema for Financials response""" + id: int + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceLogisticsResponse(InvoiceLogisticsBase): + """Schema for Logistics response""" + id: int + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceSalesDetailsResponse(InvoiceSalesDetailsBase): + """Schema for Sales Details response""" + id: int + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceCollectionsResponse(InvoiceCollectionsBase): + """Schema for Collections response""" + id: int + invoice_id: int + + class Config: + from_attributes = True + + +class InvoiceHeaderResponse(InvoiceHeaderBase): + """Schema for Invoice Header response with nested relations""" + id: int + capture_date: datetime + compliance_mx: Optional[InvoiceComplianceMxResponse] = None + financials: Optional[InvoiceFinancialsResponse] = None + logistics: List[InvoiceLogisticsResponse] = [] + details: List[InvoiceSalesDetailsResponse] = [] + collections: List[InvoiceCollectionsResponse] = [] + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py new file mode 100644 index 00000000..478bdae8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -0,0 +1,256 @@ +import traceback +from typing import Optional, List, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from . import models, schemas + +class InvoiceService: + """Service for Invoice Header operations""" + + @staticmethod + def get_by_id(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Optional[models.InvoiceHeader]: + """Get an invoice by ID with tenant/company validation""" + return ( + db.query(models.InvoiceHeader) + .filter( + models.InvoiceHeader.id == invoice_id, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ) + .first() + ) + + @staticmethod + def get_all( + db: Session, + tenant_id: int, + company_id: int, + skip: int = 0, + limit: int = 100, + filters: Optional[dict] = None, + ) -> Tuple[List[models.InvoiceHeader], int]: + """Get all invoices for a tenant/company with pagination and optional filters""" + query = db.query(models.InvoiceHeader).filter( + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("status"): + query = query.filter( + models.InvoiceHeader.status == filters["status"]) + if filters.get("operation_type"): + query = query.filter( + models.InvoiceHeader.operation_type == filters["operation_type"]) + if filters.get("invoice_type"): + query = query.filter( + models.InvoiceHeader.invoice_type == filters["invoice_type"]) + if filters.get("invoice_number"): + query = query.filter(models.InvoiceHeader.invoice_number.ilike( + f"%{filters['invoice_number']}%")) + if filters.get("pedimento"): + query = query.join(models.InvoiceComplianceMx).filter( + models.InvoiceComplianceMx.pedimento.ilike( + f"%{filters['pedimento']}%") + ) + if not filters.get("invoice_type") and filters.get("operation_type") == "exp": + query = query.filter( + models.InvoiceHeader.operation_type != "REPAR") + + total = query.count() + items = query.offset(skip).limit(limit).all() + return items, total + + @staticmethod + def create( + db: Session, + invoice_data: schemas.InvoiceHeaderCreate, + tenant_id: int, + company_id: int + ) -> models.InvoiceHeader: + """Create a new invoice with all related data""" + + + def clean_dict(data_dict: dict) -> dict: + cleaned = {} + for key, value in data_dict.items(): + + if key == 'customs_agent': + key = 'customs_broker_id' + elif key == 'provider': + key = 'provider_id' + + + if isinstance(value, str) and not value.strip(): + cleaned[key] = None + + elif value == 0 and (key.endswith('_id') or key == 'remesa'): + cleaned[key] = None + else: + cleaned[key] = value + return cleaned + + + try: + # Extract nested data + compliance_data = invoice_data.compliance_mx + financials_data = invoice_data.financials + logistics_data = invoice_data.logistics or [] + details_data = invoice_data.details or [] + collections_data = invoice_data.collections or [] + + # Create main invoice header + raw_invoice_dict = invoice_data.model_dump( + exclude={"compliance_mx", "financials", + "logistics", "details", "collections"} + ) + invoice_dict = clean_dict(raw_invoice_dict) + invoice_dict["tenant_id"] = tenant_id + invoice_dict["company_id"] = company_id + + new_invoice = models.InvoiceHeader(**invoice_dict) + db.add(new_invoice) + db.flush() # Flush to get the invoice ID + + # Create compliance_mx if provided + if compliance_data: + raw_comp_dict = compliance_data.model_dump() + # Pasamos los datos por la lavadora para arreglar pedimento, aduana, etc. + compliance_dict = clean_dict(raw_comp_dict) + + compliance_dict["invoice_id"] = new_invoice.id + compliance_dict["tenant_id"] = tenant_id + compliance_dict["company_id"] = company_id + + new_compliance = models.InvoiceComplianceMx(**compliance_dict) + db.add(new_compliance) + + # Create financials if provided + if financials_data: + raw_fin_dict = financials_data.model_dump() + financials_dict = clean_dict(raw_fin_dict) + + financials_dict["invoice_id"] = new_invoice.id + financials_dict["tenant_id"] = tenant_id + financials_dict["company_id"] = company_id + + new_financials = models.InvoiceFinancials(**financials_dict) + db.add(new_financials) + + # Create logistics entries + for logistics_item in logistics_data: + raw_log_dict = logistics_item.model_dump() + logistics_dict = clean_dict(raw_log_dict) + + logistics_dict["invoice_id"] = new_invoice.id + logistics_dict["tenant_id"] = tenant_id + logistics_dict["company_id"] = company_id + new_logistics = models.InvoiceLogistics(**logistics_dict) + db.add(new_logistics) + + # Create sales details + for detail_item in details_data: + raw_det_dict = detail_item.model_dump() + detail_dict = clean_dict(raw_det_dict) + + detail_dict["invoice_id"] = new_invoice.id + detail_dict["tenant_id"] = tenant_id + detail_dict["company_id"] = company_id + new_detail = models.InvoiceSalesDetails(**detail_dict) + db.add(new_detail) + + # Create collections + for collection_item in collections_data: + raw_col_dict = collection_item.model_dump() + collection_dict = clean_dict(raw_col_dict) + + collection_dict["invoice_id"] = new_invoice.id + collection_dict["tenant_id"] = tenant_id + collection_dict["company_id"] = company_id + new_collection = models.InvoiceCollections(**collection_dict) + db.add(new_collection) + + db.commit() + db.refresh(new_invoice) + return new_invoice + + except Exception as e: + db.rollback() + print("\n\n🔥 ERROR AL GUARDAR FACTURA 🔥") + print(f"Error: {str(e)}") + traceback.print_exc() # Esto imprime el error real en la consola + print("--------------------------------\n") + raise e + + @staticmethod + def update( + db: Session, + invoice_id: int, + tenant_id: int, + invoice_data: schemas.InvoiceHeaderUpdate, + company_id: int + ) -> Optional[models.InvoiceHeader]: + # ... (El resto de tu código update se queda igual) ... + # (Te recomiendo implementar clean_dict aquí también si tienes problemas al editar) + invoice = InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if not invoice: + return None + + # Update main invoice header fields + update_dict = invoice_data.model_dump( + exclude={"compliance_mx", "financials", + "logistics", "details", "collections"}, + exclude_unset=True + ) + for key, value in update_dict.items(): + setattr(invoice, key, value) + + # Update compliance_mx if provided + if invoice_data.compliance_mx is not None: + if invoice.compliance_mx: + for key, value in invoice_data.compliance_mx.model_dump(exclude_unset=True).items(): + # Parche rápido para update + if value == "": value = None + setattr(invoice.compliance_mx, key, value) + else: + compliance_dict = invoice_data.compliance_mx.model_dump() + # Aplicar limpieza manual si es necesario + if 'customs_agent' in compliance_dict: compliance_dict['customs_broker_id'] = compliance_dict.pop('customs_agent') + + compliance_dict["invoice_id"] = invoice.id + compliance_dict["tenant_id"] = tenant_id + compliance_dict["company_id"] = company_id + new_compliance = models.InvoiceComplianceMx(**compliance_dict) + db.add(new_compliance) + + # Update financials if provided + if invoice_data.financials is not None: + if invoice.financials: + for key, value in invoice_data.financials.model_dump(exclude_unset=True).items(): + if value == "": value = None + setattr(invoice.financials, key, value) + else: + financials_dict = invoice_data.financials.model_dump() + financials_dict["invoice_id"] = invoice.id + financials_dict["tenant_id"] = tenant_id + financials_dict["company_id"] = company_id + new_financials = models.InvoiceFinancials(**financials_dict) + db.add(new_financials) + + db.commit() + db.refresh(invoice) + return invoice + + @staticmethod + def delete(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> bool: + """Delete an invoice and all related data (cascade delete)""" + invoice = InvoiceService.get_by_id( + db, invoice_id, tenant_id, company_id) + if invoice: + db.delete(invoice) + db.commit() + return True + return False \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py new file mode 100644 index 00000000..1439f6a0 --- /dev/null +++ b/backend/api/v1/modules/a76/items/models.py @@ -0,0 +1,620 @@ +""" +Normalized Database Schema for SCAF (Fixed Assets) and SCAII (Parts Inventory) +SQLAlchemy v2 - Annex 24 Compliance +""" + +from datetime import datetime +from decimal import Decimal +from typing import Optional, TYPE_CHECKING +from enum import Enum +from sqlalchemy import Boolean, String, Integer, Numeric, Text, SmallInteger, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from core.database import Base + +if TYPE_CHECKING: + from .series.models import Serie + +# ============================================================================ +# CORE ENTITIES +# ============================================================================ + +class Item(Base): + """ + Unified item header table for all import/export operations + Consolidates headers from both SCAF and SCAII systems + """ + __tablename__ = "items" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) # CONSECUTIVO + item_type: Mapped[str] = mapped_column(String(20)) # Type: IMPORT_TEMP, IMPORT_DEF, EXPORT, REPAIR, etc. + system_origin: Mapped[str] = mapped_column(String(10)) # SCAF or SCAII + + # Item references + invoice_number: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAIMPO/FACTURAEXPO + reference_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMREFERENCIA + order: Mapped[Optional[str]] = mapped_column(String(50)) # ORDENCOMPRA / ORDENVENTA + guide_number: Mapped[Optional[str]] = mapped_column(String(50)) # NUMEROGUIA/NUMERODEGUIA + + # Dates + invoice_date: Mapped[Optional[int]] = mapped_column(Integer) # FECHAFACTURA + depreciation_date: Mapped[Optional[int]] = mapped_column(Integer) # FECHADEPRECIACION + + # Administrative fields + rectification: Mapped[Optional[int]] = mapped_column(SmallInteger) # RECTIFICACION + warehouse: Mapped[Optional[str]] = mapped_column(String(30)) # BODEGA + location: Mapped[Optional[str]] = mapped_column(String(200)) # LOCALIZACION + + # Relationships + lines: Mapped[list["LineItem"]] = relationship(back_populates="item", cascade="all, delete-orphan") + + +class LineItem(Base): + """ + Unified line items for all items + Consolidates all line-level data from Q and S tables + """ + __tablename__ = "item_lines" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_id: Mapped[int] = mapped_column(ForeignKey("a76.items.id")) + line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA + + # Part identification + part_number: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.parts.id")) # NUMPARTE + component_part_number: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.parts.id")) # NUMPARTECOM + class_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.classes.id")) # CLASE + + # Unit of measure + unit_of_measure: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.units_of_measure_general.id")) # UNIDADMEDIDA/UNIMED + alternate_unit: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.units_of_measure_general.id")) # UNIMEDALTERNA + uma_key: Mapped[Optional[str]] = mapped_column(String(2)) # CLAVEUMA + auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR + + # Permits and certificates + permit_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMPERMISO + page_line: Mapped[Optional[str]] = mapped_column(String(10)) # PAGRENGLON + has_certificate: Mapped[Optional[bool]] = mapped_column(Boolean) # TIENECO/CERTORIGEN + certificate_number: Mapped[Optional[str]] = mapped_column(String(10)) # NOCERTIFICADO + octave_permit: Mapped[Optional[str]] = mapped_column(String(20)) # PERMISOROCTAVA + permits_ped: Mapped[Optional[str]] = mapped_column(String(500)) # PERMISOSPED + + # FDA + has_fda_code: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVACODFDA + fda_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFDA + + # Subitem flags + is_subitem: Mapped[Optional[bool]] = mapped_column(Boolean) # ESSUBPARTIDA + contains_subitems: Mapped[Optional[bool]] = mapped_column(Boolean) # CONTIENESUBP + includes_subitems: Mapped[Optional[bool]] = mapped_column(Boolean) # INCUYESUBPARTIDAS + subitem_number: Mapped[Optional[bool]] = mapped_column(Boolean) # SUBPARTIDA + + # Special flags + is_military_mcia: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMCIAMILITAR + + # IV32 (Tax identification) + iv32_type_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVETIPOIV32 + iv32_number: Mapped[Optional[str]] = mapped_column(String(35)) # NUMEROIV32 + + + + # IN CASE OF EXPO + scrap_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASCRAP + consecutive_destination: Mapped[Optional[int]] = mapped_column(Integer) # CONSECUTIVODES + ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM + + # Tax payment + tax_payment: Mapped[Optional[bool]] = mapped_column(Boolean) # PAGOIMPUESTO + payment_method: Mapped[Optional[str]] = mapped_column(String(9)) # FORMAPAGO/FORMAPAGOTIGI + igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI + igi_payment_method: Mapped[Optional[str]] = mapped_column(String(9)) # FORMAPAGOTIGI + + # FCC + fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) # CLAVEFCC + + # Valuation method + valuation_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR + valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(29, 8)) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO + valuation_reason: Mapped[Optional[str]] = mapped_column(String(500)) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO + + # Container rules + container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA + container_parts_ii: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORPARTESII + + # APHIS + consecutive_aphis: Mapped[Optional[int]] = mapped_column(Integer) # CONSECUTIVOAPHIS + + # BOM/Commercial + bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM + bill_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBILL + + # TLCAN value + tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN + + # Identifier + identifier: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR + + # Validation fields + validation_zero: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONZERO + validation_one: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONUNO + + # Material type + material_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPOMAT/TIPODENUMPARTE + + # Order concept + order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN + line_concept: Mapped[Optional[str]] = mapped_column(String(50)) # CONCEPTODELAPARTIDA + + # Review dispatch + review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP + + # Take component from PT + take_component_pt: Mapped[Optional[int]] = mapped_column(Integer) # TOMARCOMOPT + + # Pallet + pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2 + + # Wildcard field + wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN + + # Relationships + item: Mapped["Item"] = relationship(back_populates="lines") + +class LineFinancial(Base): + """ + Financial details for line items + Consolidates all line-level data from Q and S tables + """ + __tablename__ = "line_financials" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_line_id: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) + + # Costs - Capture + unit_cost_capture: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUNITARIOCAPTURA + + # Costs - USD + unit_cost_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUNITARIODLLS/COSTOUNITARIOME + unit_cost_commercial_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUCOMDLLS + unit_cost_current_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOACTUALDLLS + unit_cost_depreciated_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTODEPRECME + unit_cost_subitem_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUNITARIOSUBPDLLS + unit_cost_auxiliary_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUAUXILIARME + sales_cost_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOVENTAME + commercial_unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUNICOMERCIAL + + # Costs - MXN + unit_cost_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUNITARIOPESOS/COSTOUNITARIOMN + unit_cost_commercial_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUCOMPESOS + unit_cost_current_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOACTUALMN + unit_cost_depreciated_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTODEPRECMN + unit_cost_subitem_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOUNITARIOSUBPPESOS + sales_cost_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # COSTOVENTAMN + + # Costs - MC (Custom Currency) + unit_cost_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(29, 8)) # COSTOUNITARIOMC + + # Values - MXN + value_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORMN/VALORIMPOMN/VALOREXPOMN + value_commercial_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALOREXPOCOMMN + value_updated_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORACTUALIZADOMN + value_subitem_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORSUBPMN + sub_import_value_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # SUBVALORIMPOMN + value_returned_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORRETORNADOMN + value_depreciated_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORDEPRECIADOMN + customs_value_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORADUANASMN + value_total_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTOTALMN + value_temp_material_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORMPTEMPMN + value_def_material_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORMPDEFMN + value_added_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORAGREMN + value_national_packing_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALEMPAQUENACMN + vat_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # IVAIMPOMN/IVAEXPOMN/VALORIVAMN + vat_used_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORIVAMNUSADO + advalorem_line_mxn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # ADVALORMNLINEAPED + + # Values - USD + value_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(29, 8)) # VALORME/VALORIMPOME/VALOREXPOME + value_commercial_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALOREXPOCOMME + value_updated_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORACTUALIZADOME + value_subitem_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORSUBPME + sub_import_value_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # SUBVALORIMPOME + value_returned_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORRETORNADOME + value_depreciated_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORDEPRECIADOME + customs_value_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORADUANASME + value_auxiliary_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORIMPOAUXILIARME / VALOREXPORAUXILIARME + value_total_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTOTALME + value_temp_material_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORMPTEMPME + value_def_material_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORMPDEFME + value_added_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORAGREME + value_national_packing_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALEMPAQUENACME + value_us_packing_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALEMPAQUEUSME + vat_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # IVAIMPOME/IVAEXPOME/VALORIVAME + vat_used_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORIVAMEUSADO + value_non_originating_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORNOORIGINARIOME + value_originating_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORORIGINARIOME + igi_amount_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGIME + exempt_amount_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOEXCENTOME + total_commercial_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTOTALCOMERCIAL + advalorem_line_usd: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # ADVALORMELINEAPED + + # Values - MC + value_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(29, 8)) # VALORIMPOMC/VALOREXPOMC + sub_import_value_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # SUBVALORIMPOMC + vat_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # IVAIMPOMC/IVAEXPOMC + value_added_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORAGREMC + value_national_packing_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALEMPAQUENACMC + value_total_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTOTALMC + value_temp_material_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORMPTEMPMC + value_def_material_mc: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORMPDEFMC + + # Relationship + item_line: Mapped["LineItem"] = relationship() + +class LineQuantity(Base): + """ + Quantity details for line items + Consolidates all line-level data from Q and S tables + """ + __tablename__ = "line_quantities" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_line_id: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) + + # Quantities + quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTIMPO / CANTEXPO / CANTIMPODEF + alternate_quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTALTERNA + quantity_uma: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTIMPOUMA / CANTEXPOUMA + auxiliary_quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTIMPOAUXILIAR / CANTEXPOAUXILIAR + + # Quantities - Special (SCAF specific) + quantity_temp_export: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTEXPOTEMP + quantity_existence: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTEXISTENCIA + quantity_returned: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTRETORNADA + quantity_returned_temp: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # CANTRETORNADATEMP + serial_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_SERIES/CANT_SERIESDEF + + # Weight + weight_unit: Mapped[Optional[str]] = mapped_column(String(3)) # 'KG' o 'LB' + net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESONETO + gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO + + # Packaging + package_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVEBULTOS + package_quantity: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS + package_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCBULTOS + container_quantity: Mapped[Optional[int]] = mapped_column(SmallInteger) # CANTBULCONT + container_description: Mapped[Optional[str]] = mapped_column(String(40)) # DESCCONTENEDOR + box_count: Mapped[Optional[str]] = mapped_column(String(30)) # NOCAJAS + + # Relationship + item_line: Mapped["LineItem"] = relationship() + +class LineCustoms(Base): + """ + Customs details for line items + Consolidates all line-level data from Q and S tables + """ + __tablename__ = "line_customs" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_line_id: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) + + # Tariff/Customs Classifications + fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION / FRACCIONIMPO / FRACCIONEXPO + fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION / TIPOFRACCIONIMPO / TIPOFRACCIONEXPO + american_fraction: Mapped[Optional[str]] = mapped_column(String(16)) # FRACCIONAMERICANA + alternate_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONALTERNA + reference_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONREFERENCIA + octave_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONROCTAVA + tlcan_fraction: Mapped[Optional[str]] = mapped_column(String(13)) # FRACCIONTLCAN + extra_american_fraction: Mapped[Optional[str]] = mapped_column(String(16)) # FRACAMESELEXTRA + garment_fraction: Mapped[Optional[str]] = mapped_column(String(19)) # FRACCIONDELAPRENDA/FRACCIONDELAPARTIDA + + # Ad Valorem + advalorem: Mapped[Optional[str]] = mapped_column(String(10)) # ADVIMPO / ADVEXPO + advalorem_numeric: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2)) # ADVIMPONUM / ADVEXPONUM + advalorem_american: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # ADVAME + advalorem_tlcan: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # ADVTLCAN + + # Rates + rate: Mapped[Optional[str]] = mapped_column(String(10)) # TASAIM / TASAEX + depreciation_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # TASADEPRECIA + + # Origin/Destination + origin_country: Mapped[Optional[str]] = mapped_column(String(3)) # PAISORIGEN + destination_country: Mapped[Optional[str]] = mapped_column(String(3)) # PAISDESTINO + optional_country: Mapped[Optional[str]] = mapped_column(String(3)) # PAISOPCIONAL + origin_procedure: Mapped[Optional[str]] = mapped_column(String(3)) # PROCEDENCIA + scrap_procedure: Mapped[Optional[str]] = mapped_column(String(3)) # PROCSCRAP + + # Sector + sector: Mapped[Optional[str]] = mapped_column(String(8)) # SECTOR + + # Relationship + item_line: Mapped["LineItem"] = relationship() + +class LineDescription(Base): + """ + Description details for line items + Consolidates all line-level data from Q and S tables + """ + __tablename__ = "line_descriptions" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_line_id: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) + + # Descriptions + description_spanish: Mapped[Optional[str]] = mapped_column(String(4999)) # DESCRIPCIONE + description_english: Mapped[Optional[str]] = mapped_column(String(4999)) # DESCRIPCIONI + extra_description: Mapped[Optional[str]] = mapped_column(Text) # DESCRIPCIONEEXTRA + part_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONPARTE + class_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONCLASE + + # Product attributes + brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA + model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELO + has_serial: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVASERIE + + # Additional information + additional_info_spanish: Mapped[Optional[str]] = mapped_column(String(1000)) # INFOADICIONESP + additional_info_english: Mapped[Optional[str]] = mapped_column(String(1000)) # INFOADICIONING + + # Lot and entry tracking + lot: Mapped[Optional[str]] = mapped_column(String(254)) # LOTE + entry_number: Mapped[Optional[str]] = mapped_column(String(50)) # NUMENTRADA/NUMERODEENTRADA + + # Relationship + item_line: Mapped["LineItem"] = relationship() + +class LineReference(Base): + """ + Reference details for line items + Consolidates all line-level data from Q and S tables + """ + __tablename__ = "line_references" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_line_id: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) + + serie_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.item_line_series.id")) # SERIEPARTIDA + + # Customer/Vendor + customer_invoice: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # CLIENTEFACTURAR + assigned_client: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # CLIENTEASIGNADO + supplier: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # PROVEEDOR + requisitioner: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # REQUISITOR + sent_to: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # ENVIADOA + + # PED line reference + ped_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAPED + ro_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEARO + + # Relationship + item_line: Mapped["LineItem"] = relationship() + +# ============================================================================ +# SUPPORTING TABLES +# ============================================================================ + +class PackingList(Base): + """ + Packing list items + From: SPartidasPackingList + """ + __tablename__ = "packing_lists" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_line_id: Mapped[int] = mapped_column(Integer) # LINEA + packing_list_number: Mapped[Optional[str]] = mapped_column(String(100)) # NUMPACKINGLIST + + +class RepairPart(Base): + """ + Repair parts (orphan table without primary key in original) + From: SPartidasRep + """ + __tablename__ = "repair_parts" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + import_line: Mapped[int] = mapped_column(Integer) # LINEAIMPO + + +class CTMShipment(Base): + """ + CTM Shipment lines (temporary manufacturing) + From: SPartidasEnviaCTM + """ + __tablename__ = "ctm_shipments" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + shipment_line: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) # LINEAENVIO + brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA + model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELO + serial_numbers: Mapped[Optional[str]] = mapped_column(String(50)) # SERIES + + +class CTMReceipt(Base): + """ + CTM Receipt lines (temporary manufacturing) + From: SPartidasReciboCTM + """ + __tablename__ = "ctm_receipts" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + receipt_line: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) # LINEARECIBO + + option: Mapped[Optional[str]] = mapped_column(String(3)) # OPCION + exit_invoice: Mapped[Optional[str]] = mapped_column(String(19)) # FACTURASALIDA + + +class SubassemblyEntry(Base): + """ + Subassembly/Submanufacturing Entry lines + From: SPartidasEntradaSM + """ + __tablename__ = "subassembly_entries" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + remission_line: Mapped[int] = mapped_column(Integer) # LINEAREMISION + exit_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASALIDA + exit_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEASALIDA + + +class SubassemblyExit(Base): + """ + Subassembly/Submanufacturing Exit lines + From: SPartidasSalidaSM + """ + __tablename__ = "subassembly_exits" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + exit_line: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) # LINEASALIDA + + +class ImpositionPart(Base): + """ + Imposition parts (orphan table without primary key constraint) + From: SPartidasImposion + """ + __tablename__ = "imposition_parts" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + import_line: Mapped[int] = mapped_column(Integer) # LINEAIMPO + + + +# ============================================================================ +# INDEXES AND CONSTRAINTS +# ============================================================================ + +""" +Recommended indexes for optimal query performance: + +CREATE INDEX idx_items_consecutive ON items(consecutive); +CREATE INDEX idx_items_invoice ON items(invoice_number); +CREATE INDEX idx_items_type_system ON items(item_type, system_origin); +CREATE INDEX idx_items_dates ON items(invoice_date, depreciation_date); + +CREATE INDEX idx_lines_item ON item_lines(item_id); +CREATE INDEX idx_lines_part ON item_lines(part_number); +CREATE INDEX idx_lines_class ON item_lines(class_code); +CREATE INDEX idx_lines_invoice_refs ON item_lines(import_invoice, export_invoice); +CREATE INDEX idx_lines_fractions ON item_lines(import_fraction, export_fraction); + +CREATE INDEX idx_packing_consecutive ON packing_lists(consecutive); +CREATE INDEX idx_packing_part ON packing_lists(part_number); + +CREATE INDEX idx_repair_invoice ON repair_parts(import_invoice, import_line); +CREATE INDEX idx_ctm_ship_consec ON ctm_shipments(consecutive); +CREATE INDEX idx_ctm_rcpt_consec ON ctm_receipts(consecutive); +CREATE INDEX idx_sub_entry_consec ON subassembly_entries(consecutive); +CREATE INDEX idx_sub_exit_consec ON subassembly_exits(consecutive); +CREATE INDEX idx_imposition_consec ON imposition_parts(consecutive); +""" + + +# ============================================================================ +# MIGRATION NOTES +# ============================================================================ + +""" +MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA: + +1. DOCUMENT MAPPING: + - QEqeMaq (Equipment Import Temp) → items (type='EQUIPMENT_IMPORT_TEMP', system='SCAF') + - QEqeMaqRep (Equipment Repair Export) → items (type='EQUIPMENT_REPAIR_EXPORT', system='SCAF') + - QEqiDef (Equipment Import Definitive) → items (type='EQUIPMENT_IMPORT_DEF', system='SCAF') + - QEqiMaq (Equipment Machinery) → items (type='EQUIPMENT_MACHINERY', system='SCAF') + - QEqiMaqRep (Equipment Machinery Repair) → items (type='EQUIPMENT_REPAIR_IMPORT', system='SCAF') + - SPartidasCM (Common Commerce) → items (type='COMMON_COMMERCE', system='SCAII') + - SPartidasExpo (Export) → items (type='EXPORT', system='SCAII') + - SPartidasImpo (Import) → items (type='IMPORT', system='SCAII') + +2. LINE MAPPING: + All line items from Q* and SPartidas* tables map to item_lines with appropriate + field mapping based on the original column names (preserved as comments). + +3. FIELD CONSOLIDATION RULES: + - Costs: Unified under unit_cost_* with currency suffix (usd/mxn/mc) + - Values: Unified under value_* with currency suffix + - Quantities: Unified under quantity_* with specific purpose suffixes + - Descriptions: Consolidated into description_spanish/english/extra + - Fractions: All fraction fields preserved with clear naming + +4. DATA INTEGRITY: + - Original CONSECUTIVO + LINE number preserved for traceability + - Foreign key relationships established via item_id + - All original fields retained to prevent data loss + +5. SPECIAL TABLES: + - PackingList, RepairPart, CTM*, Subassembly*, ImpositionPart remain separate + as they serve specific purposes and don't fit the main item/line pattern + +6. BENEFITS: + - Eliminates redundancy across 13 original tables + - Unified query interface for all operations + - Maintains full audit trail with original field names + - Enables cross-system reporting (SCAF + SCAII) + - Simplifies maintenance with single schema + +7. QUERYING EXAMPLES: + ```python + # Get all imports (both systems) + session.query(Item).filter( + Item.item_type.in_(['IMPORT', 'EQUIPMENT_IMPORT_TEMP', 'EQUIPMENT_IMPORT_DEF']) + ) + + # Get all lines for a specific part across all items + session.query(LineItem).filter( + LineItem.part_number == 'ABC123' + ) + + # Get SCAF equipment with depreciation + session.query(Item).join(LineItem).filter( + Item.system_origin == 'SCAF', + LineItem.value_depreciated_usd.isnot(None) + ) + ``` +""" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/series/models.py b/backend/api/v1/modules/a76/items/series/models.py new file mode 100644 index 00000000..a944b080 --- /dev/null +++ b/backend/api/v1/modules/a76/items/series/models.py @@ -0,0 +1,23 @@ +from typing import Optional +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column +from core.database import Base + +class Serie(Base): + __tablename__ = "item_line_series" + __table_args__ = { + "schema": "a76", + } + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + line_item_id: Mapped[int] = mapped_column(ForeignKey("a76.item_lines.id")) # LINEAIMPO / LINEAEXPO + row: Mapped[int] = mapped_column(Integer) # RENGLON + serial_numbers: Mapped[Optional[str]] = mapped_column(String(50)) # SERIEEXPO + model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELOEXPO + sub_model: Mapped[Optional[str]] = mapped_column(String(50)) # SUBMODELOEXPO + brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA + expo_brad: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO + number_id: Mapped[Optional[str]] = mapped_column(String(25)) # NUMIDEXPO + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/parts/__init__.py b/backend/api/v1/modules/a76/parts/__init__.py new file mode 100644 index 00000000..33bfb6c2 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/__init__.py @@ -0,0 +1,7 @@ +""" +Módulo de GParts +""" + +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py new file mode 100644 index 00000000..e21e7772 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -0,0 +1,232 @@ +""" +DTOs (Data Transfer Objects) para módulo de partes/componentes +Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS +""" + +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, Field + + +class PartCreateDTO(BaseModel): + """DTO para crear una parte""" + + client_id: int = Field(..., description="Client key") + part_number: str = Field(..., max_length=49, description="Part number") + fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction") + description_spanish: Optional[str] = Field( + None, max_length=500, description="Description in Spanish" + ) + description_english: Optional[str] = Field( + None, max_length=500, description="Description in English" + ) + part_class: Optional[str] = Field(None, max_length=8, description="Part class") + unit_of_measure: Optional[str] = Field( + None, max_length=5, description="Unit of measure" + ) + commercial_part_number: Optional[str] = Field( + None, max_length=70, description="Commercial part number" + ) + country_of_origin: Optional[str] = Field( + None, max_length=3, description="Country of origin code" + ) + + # Pricing and currency + unit_cost: Optional[Decimal] = Field(None, description="Unit cost") + currency_type: Optional[str] = Field( + None, max_length=2, description="Currency type" + ) + currency_key: Optional[str] = Field(None, max_length=3, description="Currency key") + + # Weight information + unit_weight: Optional[Decimal] = Field(None, description="Unit weight") + weight_type: Optional[str] = Field(None, max_length=6, description="Weight type") + + # Classification and regulatory + us_fraction: Optional[str] = Field( + None, max_length=16, description="US tariff fraction" + ) + fda_key: Optional[str] = Field(None, max_length=20, description="FDA key") + fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") + license_code: Optional[str] = Field(None, max_length=3, description="License code") + eccn: Optional[str] = Field( + None, max_length=20, description="Export Control Classification Number" + ) + export_code: Optional[str] = Field(None, max_length=2, description="Export code") + exclusion_symbol: Optional[str] = Field( + None, max_length=19, description="Exclusion symbol" + ) + + # Additional information + supplier: Optional[str] = Field(None, max_length=14, description="Supplier") + alternate_unit_measure: Optional[str] = Field( + None, max_length=14, description="Alternate unit of measure" + ) + added_value: Optional[Decimal] = Field(None, description="Added value") + + # Status and media + is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") + creation_date: Optional[int] = Field(None, description="Creation date") + part_photo: Optional[str] = Field( + None, max_length=255, description="Part photo URL" + ) + + class Config: + from_attributes = True + + +class PartUpdateDTO(BaseModel): + """DTO para actualizar una parte""" + + fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction") + description_spanish: Optional[str] = Field( + None, max_length=500, description="Description in Spanish" + ) + description_english: Optional[str] = Field( + None, max_length=500, description="Description in English" + ) + part_class: Optional[str] = Field(None, max_length=8, description="Part class") + unit_of_measure: Optional[str] = Field( + None, max_length=5, description="Unit of measure" + ) + commercial_part_number: Optional[str] = Field( + None, max_length=70, description="Commercial part number" + ) + country_of_origin: Optional[str] = Field( + None, max_length=3, description="Country of origin code" + ) + + # Pricing and currency + unit_cost: Optional[Decimal] = Field(None, description="Unit cost") + currency_type: Optional[str] = Field( + None, max_length=2, description="Currency type" + ) + currency_key: Optional[str] = Field(None, max_length=3, description="Currency key") + + # Weight information + unit_weight: Optional[Decimal] = Field(None, description="Unit weight") + weight_type: Optional[str] = Field(None, max_length=6, description="Weight type") + + # Classification and regulatory + us_fraction: Optional[str] = Field( + None, max_length=16, description="US tariff fraction" + ) + fda_key: Optional[str] = Field(None, max_length=20, description="FDA key") + fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") + license_code: Optional[str] = Field(None, max_length=3, description="License code") + eccn: Optional[str] = Field( + None, max_length=20, description="Export Control Classification Number" + ) + export_code: Optional[str] = Field(None, max_length=2, description="Export code") + exclusion_symbol: Optional[str] = Field( + None, max_length=19, description="Exclusion symbol" + ) + + # Additional information + supplier: Optional[str] = Field(None, max_length=14, description="Supplier") + alternate_unit_measure: Optional[str] = Field( + None, max_length=14, description="Alternate unit of measure" + ) + added_value: Optional[Decimal] = Field(None, description="Added value") + + # Status and media + is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") + part_photo: Optional[str] = Field( + None, max_length=255, description="Part photo URL" + ) + + class Config: + from_attributes = True + + +class PartResponseDTO(BaseModel): + """DTO para respuesta de parte""" + + client_id: int + part_number: str + fraction: Optional[str] = None + description_spanish: Optional[str] = None + description_english: Optional[str] = None + part_class: Optional[str] = None + unit_of_measure: Optional[str] = None + commercial_part_number: Optional[str] = None + country_of_origin: Optional[str] = None + + # Pricing and currency + unit_cost: Optional[Decimal] = None + currency_type: Optional[str] = None + currency_key: Optional[str] = None + + # Weight information + unit_weight: Optional[Decimal] = None + weight_type: Optional[str] = None + + # Classification and regulatory + us_fraction: Optional[str] = None + fda_key: Optional[str] = None + fcc_key: Optional[str] = None + license_code: Optional[str] = None + eccn: Optional[str] = None + export_code: Optional[str] = None + exclusion_symbol: Optional[str] = None + + # Additional information + supplier: Optional[str] = None + alternate_unit_measure: Optional[str] = None + added_value: Optional[Decimal] = None + + # Status and dates + is_active: Optional[bool] = None + creation_date: Optional[int] = None + modification_date: Optional[int] = None + modification_date_iso: Optional[datetime] = None + + # Media + part_photo: Optional[str] = None + + class Config: + from_attributes = True + + +class PartBasicDTO(BaseModel): + """DTO para información básica de parte""" + + client_id: int + part_number: str + description_spanish: Optional[str] = None + description_english: Optional[str] = None + part_class: Optional[str] = None + unit_cost: Optional[Decimal] = None + currency_key: Optional[str] = None + is_active: Optional[bool] = None + + class Config: + from_attributes = True + + +class PartListDTO(BaseModel): + """DTO para lista de partes""" + + parts: list[PartBasicDTO] + total: int + page: int + size: int + + class Config: + from_attributes = True + + +class PartSearchDTO(BaseModel): + """DTO para búsqueda de partes""" + + client_id: Optional[int] = Field(None, description="Filter by client key") + part_number: Optional[str] = Field(None, description="Search by part number") + description: Optional[str] = Field(None, description="Search in descriptions") + fraction: Optional[str] = Field(None, description="Filter by tariff fraction") + supplier: Optional[str] = Field(None, description="Filter by supplier") + enabled_only: bool = Field(False, description="Show only enabled parts") + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py new file mode 100644 index 00000000..12750dca --- /dev/null +++ b/backend/api/v1/modules/a76/parts/models.py @@ -0,0 +1,131 @@ +""" +Modelos ORM para gestión de partes/componentes +""" + +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Optional + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + Numeric, + PrimaryKeyConstraint, + String, + UniqueConstraint, + Boolean, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.classes.models import Class + from api.v1.modules.public.reference_data.countries.models import Country + from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + + +class Part(Base, TenantScopedMixin, TimestampMixin): + """ + Modelo para la tabla GPartes - Información de partes en los sistemas SCAII (N), SCAF (S) Y WINSAAI (W) + """ + + __tablename__ = "parts" + __table_args__ = ( + PrimaryKeyConstraint("id", name="parts_pkey"), + ForeignKeyConstraint( + ["country_of_origin"], ["public.countries.m3_key"], name="fk_parts_country" + ), + ForeignKeyConstraint( + ["currency_key"], ["public.currency_types.code"], name="fk_parts_currency" + ), + 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", "part_number", name="client_part_ukey" + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + # Unique constraint compuesta + client_id: Mapped[int] = mapped_column(Integer) + part_number: Mapped[str] = mapped_column(String(50)) + + # Basic information + fraction: Mapped[Optional[str]] = mapped_column(String(10)) + description_spanish: Mapped[Optional[str]] = mapped_column(String(500)) + description_english: Mapped[Optional[str]] = mapped_column(String(500)) + part_class: Mapped[Optional[str]] = mapped_column(String(8)) + unit_of_measure: Mapped[Optional[str]] = mapped_column( + String(5) + ) + commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70)) + country_of_origin: Mapped[Optional[str]] = mapped_column(String(3)) + + # Pricing and currency + unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) + currency_type: Mapped[Optional[str]] = mapped_column(String(2)) + currency_key: Mapped[Optional[str]] = mapped_column(String(3)) + + # Weight information + unit_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) + weight_type: Mapped[Optional[str]] = mapped_column(String(6)) + + # Classification and regulatory + us_fraction: Mapped[Optional[str]] = mapped_column( + String(16)) # FRACCIONAME + fda_key: Mapped[Optional[str]] = mapped_column(String(20)) + fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) + license_code: Mapped[Optional[str]] = mapped_column(String(3)) + eccn: Mapped[Optional[str]] = mapped_column( + String(20) + ) # Export Control Classification Number + export_code: Mapped[Optional[str]] = mapped_column(String(2)) + exclusion_symbol: Mapped[Optional[str]] = mapped_column( + String(19)) # SIMBOLOEXCLIC + + # Additional information + supplier: Mapped[Optional[str]] = mapped_column(String(14)) + alternate_unit_measure: Mapped[Optional[str]] = mapped_column(String(14)) + added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) + + # Status and dates + is_active: Mapped[Optional[bool]] = mapped_column(Boolean) + creation_date: Mapped[Optional[int] + ] = mapped_column() # FECHACREACIONPARTE + modification_date: Mapped[Optional[int]] = mapped_column() # FECHAMODIFICA + modification_date_iso: Mapped[Optional[datetime]] = ( + mapped_column() + ) # FECHAMODIFICA_ISO + + # Media + part_photo: Mapped[Optional[str]] = mapped_column(String(255)) + + # Relationships + country: Mapped[Optional["Country"]] = relationship( + foreign_keys=[country_of_origin] + ) + currency: Mapped[Optional["CurrencyType"]] = relationship( + foreign_keys=[currency_key] + ) + unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship( + foreign_keys=[unit_of_measure] + ) + + # Relationship with Class through composite foreign key + # Note: This requires both client_id and part_class to match client_id and class_code in Class + part_class_info: Mapped[Optional["Class"]] = relationship( + primaryjoin="and_(Part.client_id == Class.client_id, Part.part_class == Class.class_code)", + foreign_keys="[Part.client_id, Part.part_class]", + viewonly=True, + back_populates="parts", + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/api/v1/modules/a76/parts/routes.py b/backend/api/v1/modules/a76/parts/routes.py new file mode 100644 index 00000000..0f5996b6 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -0,0 +1,393 @@ +""" +Endpoints API para gestión de partes/componentes +""" + +from typing import List, Optional + +from core.database import get_core_db +from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from .dto import ( + PartBasicDTO, + PartCreateDTO, + PartListDTO, + PartResponseDTO, + PartSearchDTO, + PartUpdateDTO, +) +from .service import PartService + +router = APIRouter(prefix="/parts") + + +@router.post("/", response_model=PartResponseDTO, status_code=status.HTTP_201_CREATED) +async def create_part( + part_data: PartCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Create a new part in the system + """ + # 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" + ) + + service = PartService(db) + return service.create_part(part_data) + + +@router.get("/", response_model=PartListDTO) +async def list_parts( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query( + 100, ge=1, le=1000, description="Maximum number of records to return" + ), + client_id: Optional[int] = Query(None, description="Filter by client key"), + part_number: Optional[str] = Query(None, description="Search by part number"), + description: Optional[str] = Query(None, description="Search in descriptions"), + fraction: Optional[str] = Query(None, description="Filter by tariff fraction"), + supplier: Optional[str] = Query(None, description="Filter by supplier"), + enabled_only: bool = Query(False, description="Show only enabled parts"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + List parts with optional filters and pagination + """ + # 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" + ) + + service = PartService(db) + search_params = PartSearchDTO( + client_id=client_id, + part_number=part_number, + description=description, + fraction=fraction, + supplier=supplier, + enabled_only=enabled_only, + ) + return service.list_parts(skip, limit, search_params) + + +@router.get("/client/{client_id}", response_model=List[PartBasicDTO]) +async def get_parts_by_client( + client_id: int, + 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 all parts for a specific client + """ + # 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" + ) + + service = PartService(db) + return service.search_by_client(client_id, skip, limit) + + +@router.get("/search/fraction/{fraction}", response_model=List[PartBasicDTO]) +async def search_by_fraction( + fraction: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Search parts by tariff fraction + """ + # 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" + ) + + service = PartService(db) + return service.search_by_fraction(fraction) + + +@router.get("/search/supplier/{supplier}", response_model=List[PartBasicDTO]) +async def search_by_supplier( + supplier: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Search parts by supplier + """ + # 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" + ) + + service = PartService(db) + return service.search_by_supplier(supplier) + + +@router.get("/search/country/{country_code}", response_model=List[PartBasicDTO]) +async def get_parts_by_country( + country_code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Get parts by country of origin + """ + # 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" + ) + + service = PartService(db) + return service.get_parts_by_country(country_code) + + +@router.get("/statistics", response_model=dict) +async def get_parts_statistics( + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user) +): + """ + Get basic parts statistics + """ + # 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" + ) + + service = PartService(db) + return service.get_parts_statistics() + + +@router.get("/{client_id}/{part_number}", response_model=PartResponseDTO) +async def get_part( + client_id: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Get part by composite key (client_id + part_number) + """ + # 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" + ) + + service = PartService(db) + part = service.get_part(client_id, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", + ) + return part + + +@router.put("/{client_id}/{part_number}", response_model=PartResponseDTO) +async def update_part( + client_id: int, + part_number: str, + part_data: PartUpdateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Update part information + """ + # 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" + ) + + service = PartService(db) + part = service.update_part(client_id, part_number, part_data) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", + ) + return part + + +@router.delete("/{client_id}/{part_number}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_part( + client_id: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Delete part from the system + + Note: This will completely remove the part from the system. + """ + # 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" + ) + + service = PartService(db) + if not service.delete_part(client_id, part_number): + raise HTTPException( + status_code=404, + detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", + ) + + +@router.patch( + "/{client_id}/{part_number}/toggle-status", response_model=PartResponseDTO +) +async def toggle_part_status( + client_id: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Toggle part enabled/disabled status + """ + # 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" + ) + + service = PartService(db) + part = service.toggle_status(client_id, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", + ) + return part + + +# Endpoints específicos para información detallada +@router.get("/{client_id}/{part_number}/basic", response_model=PartBasicDTO) +async def get_part_basic_info( + client_id: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Get basic information for a part + """ + # 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" + ) + + service = PartService(db) + part = service.get_part(client_id, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", + ) + + return PartBasicDTO( + client_id=part.client_id, + part_number=part.part_number, + description_spanish=part.description_spanish, + description_english=part.description_english, + part_class=part.part_class, + unit_cost=part.unit_cost, + currency_key=part.currency_key, + is_active=part.is_active, + ) + + +@router.get("/{client_id}/{part_number}/regulatory", response_model=dict) +async def get_part_regulatory_info( + client_id: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Get regulatory information for a part (FDA, FCC, ECCN, etc.) + """ + # 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" + ) + + service = PartService(db) + part = service.get_part(client_id, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", + ) + + return { + "client_id": part.client_id, + "part_number": part.part_number, + "fraction": part.fraction, + "us_fraction": part.us_fraction, + "fda_key": part.fda_key, + "fcc_key": part.fcc_key, + "license_code": part.license_code, + "eccn": part.eccn, + "export_code": part.export_code, + "exclusion_symbol": part.exclusion_symbol, + } diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py new file mode 100644 index 00000000..48c2eab9 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/service.py @@ -0,0 +1,309 @@ +""" +Capa de servicio para lógica de negocio de partes/componentes +""" + +import logging +from typing import List, Optional + +from fastapi import HTTPException +from sqlalchemy import and_, func, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .dto import PartCreateDTO, PartUpdateDTO +from .models import Part + +logger = logging.getLogger(__name__) + + +class PartService: + """ + Servicio para gestión de partes/componentes + """ + + @staticmethod + def create_part(db: Session, part_data: PartCreateDTO) -> Part: + """ + Crear una nueva parte + """ + try: + db_part = Part(**part_data.model_dump()) + db.add(db_part) + db.commit() + db.refresh(db_part) + return db_part + except IntegrityError as e: + db.rollback() + logger.error(f"Error creating part: {e}") + raise HTTPException( + status_code=400, + detail="Part with this client_id and part_number already exists", + ) + except Exception as e: + db.rollback() + logger.error(f"Unexpected error creating part: {e}") + raise HTTPException(status_code=500, detail="Error creating part") + + @staticmethod + def get_part(db: Session, client_id: int, part_number: str) -> Optional[Part]: + """ + Obtener una parte por clave de cliente y número de parte + """ + try: + return ( + db.query(Part) + .filter( + and_(Part.client_id == client_id, Part.part_number == part_number) + ) + .first() + ) + except Exception as e: + logger.error(f"Error getting part: {e}") + raise HTTPException(status_code=500, detail="Error retrieving part") + + @staticmethod + def get_parts_paginated( + db: Session, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + client_id: Optional[int] = None, + fraction: Optional[str] = None, + country_of_origin: Optional[str] = None, + ) -> tuple[List[Part], int]: + """ + Obtener partes con paginación y filtros + """ + try: + query = db.query(Part) + + # Aplicar filtros + if search: + query = query.filter( + or_( + Part.description_spanish.ilike(f"%{search}%"), + Part.description_english.ilike(f"%{search}%"), + Part.part_number.ilike(f"%{search}%"), + ) + ) + + if client_id is not None: + query = query.filter(Part.client_id == client_id) + + if fraction: + query = query.filter(Part.fraction == fraction) + + if country_of_origin: + query = query.filter(Part.country_of_origin == country_of_origin) + + # Contar total + total = query.count() + + # Aplicar paginación + parts = query.offset(skip).limit(limit).all() + + return parts, total + except Exception as e: + logger.error(f"Error getting paginated parts: {e}") + raise HTTPException(status_code=500, detail="Error retrieving parts") + + @staticmethod + def get_parts_by_client(db: Session, client_id: int) -> List[Part]: + """ + Obtener todas las partes de un cliente específico + """ + try: + return db.query(Part).filter(Part.client_id == client_id).all() + except Exception as e: + logger.error(f"Error getting parts by client: {e}") + raise HTTPException(status_code=500, detail="Error retrieving client parts") + + @staticmethod + def search_parts_by_fraction(db: Session, fraction: str) -> List[Part]: + """ + Buscar partes por fracción arancelaria + """ + try: + return ( + db.query(Part) + .filter( + or_( + Part.fraction.ilike(f"%{fraction}%"), + Part.us_fraction.ilike(f"%{fraction}%"), + ) + ) + .all() + ) + except Exception as e: + logger.error(f"Error searching parts by fraction: {e}") + raise HTTPException( + status_code=500, detail="Error searching parts by fraction" + ) + + @staticmethod + def search_parts_by_supplier(db: Session, supplier: str) -> List[Part]: + """ + Buscar partes por proveedor + """ + try: + return db.query(Part).filter(Part.supplier.ilike(f"%{supplier}%")).all() + except Exception as e: + logger.error(f"Error searching parts by supplier: {e}") + raise HTTPException( + status_code=500, detail="Error searching parts by supplier" + ) + + @staticmethod + def search_parts_by_country(db: Session, country_code: str) -> List[Part]: + """ + Buscar partes por país de origen + """ + try: + return db.query(Part).filter(Part.country_of_origin == country_code).all() + except Exception as e: + logger.error(f"Error searching parts by country: {e}") + raise HTTPException( + status_code=500, detail="Error searching parts by country" + ) + + @staticmethod + def update_part( + db: Session, client_id: int, part_number: str, part_data: PartUpdateDTO + ) -> Optional[Part]: + """ + Actualizar una parte existente + """ + try: + db_part = PartService.get_part(db, client_id, part_number) + if not db_part: + return None + + # Actualizar campos + for field, value in part_data.model_dump(exclude_unset=True).items(): + setattr(db_part, field, value) + + db.commit() + db.refresh(db_part) + return db_part + except Exception as e: + db.rollback() + logger.error(f"Error updating part: {e}") + raise HTTPException(status_code=500, detail="Error updating part") + + @staticmethod + def delete_part(db: Session, client_id: int, part_number: str) -> bool: + """ + Eliminar una parte + """ + try: + db_part = PartService.get_part(db, client_id, part_number) + if not db_part: + return False + + db.delete(db_part) + db.commit() + return True + except Exception as e: + db.rollback() + logger.error(f"Error deleting part: {e}") + raise HTTPException(status_code=500, detail="Error deleting part") + + @staticmethod + def toggle_part_status( + db: Session, client_id: int, part_number: str + ) -> Optional[Part]: + """ + Cambiar el estado habilitado/deshabilitado de una parte + """ + try: + db_part = PartService.get_part(db, client_id, part_number) + if not db_part: + return None + + # Toggle status (assuming 1 = enabled, 0 = disabled) + db_part.is_active = 1 if db_part.is_active == 0 else 0 + + db.commit() + db.refresh(db_part) + return db_part + except Exception as e: + db.rollback() + logger.error(f"Error toggling part status: {e}") + raise HTTPException(status_code=500, detail="Error toggling part status") + + @staticmethod + def get_parts_statistics(db: Session) -> dict: + """ + Obtener estadísticas de partes + """ + try: + total_parts = db.query(Part).count() + + # Partes por cliente + parts_by_client = ( + db.query(Part.client_id, func.count(Part.part_number).label("count")) + .group_by(Part.client_id) + .all() + ) + + # Partes por país de origen + parts_by_country = ( + db.query( + Part.country_of_origin, func.count(Part.part_number).label("count") + ) + .filter(Part.country_of_origin.isnot(None)) + .group_by(Part.country_of_origin) + .all() + ) + + # Partes habilitadas vs deshabilitadas + enabled_parts = db.query(Part).filter(Part.is_active == 1).count() + disabled_parts = db.query(Part).filter(Part.is_active == 0).count() + + return { + "total_parts": total_parts, + "enabled_parts": enabled_parts, + "disabled_parts": disabled_parts, + "parts_by_client": [ + {"client_id": item[0], "count": item[1]} for item in parts_by_client + ], + "parts_by_country": [ + {"country": item[0], "count": item[1]} for item in parts_by_country + ], + } + except Exception as e: + logger.error(f"Error getting parts statistics: {e}") + raise HTTPException( + status_code=500, detail="Error retrieving parts statistics" + ) + + @staticmethod + def get_part_regulatory_info( + db: Session, client_id: int, part_number: str + ) -> Optional[dict]: + """ + Obtener información regulatoria específica de una parte + """ + try: + db_part = PartService.get_part(db, client_id, part_number) + if not db_part: + return None + + return { + "client_id": db_part.client_id, + "part_number": db_part.part_number, + "fraction": db_part.fraction, + "us_fraction": db_part.us_fraction, + "fda_key": db_part.fda_key, + "fcc_key": db_part.fcc_key, + "license_code": db_part.license_code, + "eccn": db_part.eccn, + "export_code": db_part.export_code, + "exclusion_symbol": db_part.exclusion_symbol, + "country_of_origin": db_part.country_of_origin, + } + except Exception as e: + logger.error(f"Error getting part regulatory info: {e}") + raise HTTPException( + status_code=500, detail="Error retrieving part regulatory information" + ) diff --git a/backend/api/v1/modules/a76/parts/test_parts.py b/backend/api/v1/modules/a76/parts/test_parts.py new file mode 100644 index 00000000..bb54e2b0 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/test_parts.py @@ -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_parts(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/parts/", 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_part_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/parts/invalid_id", headers=headers) + assert response.status_code == 404 + + +def test_create_part_forbidden(): + response = client.post("/parts/", json={"name": "Test Part"}) + assert response.status_code in (403, 405, 404) + + +def test_update_part_forbidden(): + response = client.put("/parts/1", json={"name": "Updated Part"}) + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_additional.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_additional.py new file mode 100644 index 00000000..028a1dd4 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_additional.py @@ -0,0 +1,51 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoConfigAdditionalBase(BaseModel): + """Base schema for Pedimento Config Additional""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + add_po_identifier: Optional[int] = Field(None, description="Add PO identifier") + do_not_exempt_norms_complement_x: Optional[int] = Field( + None, description="Do not exempt norms complement X" + ) + manual_pedimento_year: Optional[str] = Field( + None, max_length=2, description="Manual pedimento year" + ) + enable_import_invoice_recipient: Optional[int] = Field( + None, description="Enable import invoice recipient" + ) + send_502_validation_file_for_consolidated: Optional[int] = Field( + None, description="Send 502 validation file for consolidated" + ) + add_remove_norms: Optional[int] = Field(None, description="Add/remove norms") + + +class PedimentoConfigAdditionalCreate(PedimentoConfigAdditionalBase): + """Schema for creating a new Pedimento Config Additional""" + + pass + + +class PedimentoConfigAdditionalUpdate(BaseModel): + """Schema for updating a Pedimento Config Additional""" + + add_po_identifier: Optional[int] = None + do_not_exempt_norms_complement_x: Optional[int] = None + manual_pedimento_year: Optional[str] = Field(None, max_length=2) + enable_import_invoice_recipient: Optional[int] = None + send_502_validation_file_for_consolidated: Optional[int] = None + add_remove_norms: Optional[int] = None + + +class PedimentoConfigAdditionalResponse(PedimentoConfigAdditionalBase): + """Schema for Pedimento Config Additional response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_calculations.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_calculations.py new file mode 100644 index 00000000..692b9e9c --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_calculations.py @@ -0,0 +1,61 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoConfigCalculationsBase(BaseModel): + """Base schema for Pedimento Config Calculations""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + dta_type: Optional[str] = Field(None, max_length=1, description="DTA type") + dta_operation: Optional[int] = Field(None, description="DTA operation") + dta_vehicle_count: Optional[int] = Field(None, description="DTA vehicle count") + dta_mixed_rate_8permil: Optional[int] = Field( + None, description="DTA mixed rate 8 per mil" + ) + pays_vat: Optional[int] = Field(None, description="Pays VAT") + pays_prevalidation: Optional[int] = Field(None, description="Pays prevalidation") + include_sagar_certificate_fee: Optional[int] = Field( + None, description="Include SAGAR certificate fee" + ) + fixed_vehicle_dta_fee: Optional[int] = Field( + None, description="Fixed vehicle DTA fee" + ) + additional_fixed_fee: Optional[int] = Field( + None, description="Additional fixed fee" + ) + additional_fixed_fee_payment_method: Optional[int] = Field( + None, description="Additional fixed fee payment method" + ) + + +class PedimentoConfigCalculationsCreate(PedimentoConfigCalculationsBase): + """Schema for creating a new Pedimento Config Calculations""" + + pass + + +class PedimentoConfigCalculationsUpdate(BaseModel): + """Schema for updating a Pedimento Config Calculations""" + + dta_type: Optional[str] = Field(None, max_length=1) + dta_operation: Optional[int] = None + dta_vehicle_count: Optional[int] = None + dta_mixed_rate_8permil: Optional[int] = None + pays_vat: Optional[int] = None + pays_prevalidation: Optional[int] = None + include_sagar_certificate_fee: Optional[int] = None + fixed_vehicle_dta_fee: Optional[int] = None + additional_fixed_fee: Optional[int] = None + additional_fixed_fee_payment_method: Optional[int] = None + + +class PedimentoConfigCalculationsResponse(PedimentoConfigCalculationsBase): + """Schema for Pedimento Config Calculations response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_parameters.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_parameters.py new file mode 100644 index 00000000..3f5f04ed --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_parameters.py @@ -0,0 +1,66 @@ +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoConfigParametersBase(BaseModel): + """Base schema for Pedimento Config Parameters""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + is_embassy: Optional[int] = Field(None, description="Is embassy") + embassy_dta: Optional[Decimal] = Field(None, description="Embassy DTA") + rule_3121_section_ii: Optional[int] = Field( + None, description="Rule 3.1.21 Section II" + ) + use_previous_tariff: Optional[int] = Field(None, description="Use previous tariff") + use_payment_date_fi: Optional[int] = Field(None, description="Use payment date FI") + add_state_supplier_record_505: Optional[int] = Field( + None, description="Add state supplier record 505" + ) + customs_value_calculation: Optional[int] = Field( + None, description="Customs value calculation" + ) + two_decimals_unit_value: Optional[int] = Field( + None, description="Two decimals unit value" + ) + customs_value_per_item: Optional[int] = Field( + None, description="Customs value per item" + ) + is_national_supplier: Optional[int] = Field( + None, description="Is national supplier" + ) + is_consolidated: Optional[int] = Field(None, description="Is consolidated") + + +class PedimentoConfigParametersCreate(PedimentoConfigParametersBase): + """Schema for creating a new Pedimento Config Parameters""" + + pass + + +class PedimentoConfigParametersUpdate(BaseModel): + """Schema for updating a Pedimento Config Parameters""" + + is_embassy: Optional[int] = None + embassy_dta: Optional[Decimal] = None + rule_3121_section_ii: Optional[int] = None + use_previous_tariff: Optional[int] = None + use_payment_date_fi: Optional[int] = None + add_state_supplier_record_505: Optional[int] = None + customs_value_calculation: Optional[int] = None + two_decimals_unit_value: Optional[int] = None + customs_value_per_item: Optional[int] = None + is_national_supplier: Optional[int] = None + is_consolidated: Optional[int] = None + + +class PedimentoConfigParametersResponse(PedimentoConfigParametersBase): + """Schema for Pedimento Config Parameters response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_surcharges.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_surcharges.py new file mode 100644 index 00000000..385ebcf9 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_surcharges.py @@ -0,0 +1,43 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoConfigSurchargesBase(BaseModel): + """Base schema for Pedimento Config Surcharges""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + surcharge_igi: Optional[int] = Field(None, description="Surcharge IGI") + surcharge_dta: Optional[int] = Field(None, description="Surcharge DTA") + surcharge_vat: Optional[int] = Field(None, description="Surcharge VAT") + surcharge_isan: Optional[int] = Field(None, description="Surcharge ISAN") + surcharge_ieps: Optional[int] = Field(None, description="Surcharge IEPS") + surcharge_cc: Optional[int] = Field(None, description="Surcharge CC") + + +class PedimentoConfigSurchargesCreate(PedimentoConfigSurchargesBase): + """Schema for creating a new Pedimento Config Surcharges""" + + pass + + +class PedimentoConfigSurchargesUpdate(BaseModel): + """Schema for updating a Pedimento Config Surcharges""" + + surcharge_igi: Optional[int] = None + surcharge_dta: Optional[int] = None + surcharge_vat: Optional[int] = None + surcharge_isan: Optional[int] = None + surcharge_ieps: Optional[int] = None + surcharge_cc: Optional[int] = None + + +class PedimentoConfigSurchargesResponse(PedimentoConfigSurchargesBase): + """Schema for Pedimento Config Surcharges response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_update_rectification.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_update_rectification.py new file mode 100644 index 00000000..62bbf8f7 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_update_rectification.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from api.v1.common.dto_mixins import UpdateFlagsMixin + + +class PedimentoConfigUpdateRectificationBase(BaseModel, UpdateFlagsMixin): + """Base schema for Pedimento Config Update Rectification""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + calculate_surcharge: Optional[int] = Field(None, description="Calculate surcharge") + + +class PedimentoConfigUpdateRectificationCreate(PedimentoConfigUpdateRectificationBase): + """Schema for creating a new Pedimento Config Update Rectification""" + + pass + + +class PedimentoConfigUpdateRectificationUpdate(BaseModel, UpdateFlagsMixin): + """Schema for updating a Pedimento Config Update Rectification""" + + calculate_surcharge: Optional[int] = None + + +class PedimentoConfigUpdateRectificationResponse( + PedimentoConfigUpdateRectificationBase +): + """Schema for Pedimento Config Update Rectification response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_updates.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_updates.py new file mode 100644 index 00000000..b3098972 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_config_updates.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from api.v1.common.dto_mixins import UpdateFlagsMixin + + +class PedimentoConfigUpdatesBase(BaseModel, UpdateFlagsMixin): + """Base schema for Pedimento Config Updates""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + +class PedimentoConfigUpdatesCreate(PedimentoConfigUpdatesBase): + """Schema for creating a new Pedimento Config Updates""" + + pass + + +class PedimentoConfigUpdatesUpdate(BaseModel, UpdateFlagsMixin): + """Schema for updating a Pedimento Config Updates""" + pass + + +class PedimentoConfigUpdatesResponse(PedimentoConfigUpdatesBase): + """Schema for Pedimento Config Updates response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_customs_offices.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_customs_offices.py new file mode 100644 index 00000000..31444672 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_customs_offices.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoCustomsOfficesBase(BaseModel): + """Base schema for Pedimento Customs Offices""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + dispatch_customs: Optional[str] = Field( + None, max_length=3, description="Dispatch customs" + ) + entry_exit_customs: Optional[str] = Field( + None, max_length=3, description="Entry/exit customs" + ) + + +class PedimentoCustomsOfficesCreate(PedimentoCustomsOfficesBase): + """Schema for creating a new Pedimento Customs Offices""" + + pass + + +class PedimentoCustomsOfficesUpdate(BaseModel): + """Schema for updating a Pedimento Customs Offices""" + + dispatch_customs: Optional[str] = Field(None, max_length=3) + entry_exit_customs: Optional[str] = Field(None, max_length=3) + + +class PedimentoCustomsOfficesResponse(PedimentoCustomsOfficesBase): + """Schema for Pedimento Customs Offices response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_dates.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_dates.py new file mode 100644 index 00000000..dd7f048e --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_dates.py @@ -0,0 +1,59 @@ +from datetime import datetime, time +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoDatesBase(BaseModel): + """Base schema for Pedimento Dates""" + + entry_date: Optional[datetime] = Field(None, description="Entry date") + payment_date: datetime = Field(..., description="Payment date") + rectification_payment_date: Optional[datetime] = Field( + None, description="Rectification payment date" + ) + extraction_date: Optional[datetime] = Field(None, description="Extraction date") + submission_date: Optional[datetime] = Field(None, description="Submission date") + eucan_date: Optional[datetime] = Field(None, description="EUCAN date") + original_date: Optional[datetime] = Field(None, description="Original date") + start_date: Optional[datetime] = Field(None, description="Start date") + end_date: Optional[datetime] = Field(None, description="End date") + + +class PedimentoDatesCreate(BaseModel): + """Schema for creating a new Pedimento Dates - pedimento_id and tenant_id are set by backend""" + + entry_date: Optional[datetime] = Field(None, description="Entry date") + payment_date: datetime = Field(..., description="Payment date") + rectification_payment_date: Optional[datetime] = Field(None, description="Rectification payment date") + extraction_date: Optional[datetime] = Field(None, description="Extraction date") + submission_date: Optional[datetime] = Field(None, description="Submission date") + eucan_date: Optional[datetime] = Field(None, description="EUCAN date") + original_date: Optional[datetime] = Field(None, description="Original date") + start_date: Optional[datetime] = Field(None, description="Start date") + end_date: Optional[datetime] = Field(None, description="End date") + + +class PedimentoDatesUpdate(BaseModel): + """Schema for updating a Pedimento Dates""" + + entry_date: Optional[datetime] = None + payment_date: Optional[datetime] = None + rectification_payment_date: Optional[datetime] = None + extraction_date: Optional[datetime] = None + submission_date: Optional[datetime] = None + eucan_date: Optional[datetime] = None + original_date: Optional[datetime] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + + +class PedimentoDatesResponse(PedimentoDatesBase): + """Schema for Pedimento Dates response""" + + id: int + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py new file mode 100644 index 00000000..aabce8cf --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py @@ -0,0 +1,54 @@ +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoDecrementablesBase(BaseModel): + """Base schema for Pedimento Decrementables""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + freight: Optional[Decimal] = Field(None, description="Freight") + insurance: Optional[Decimal] = Field(None, description="Insurance") + loading: Optional[Decimal] = Field(None, description="Loading") + unloading: Optional[Decimal] = Field(None, description="Unloading") + others: Optional[Decimal] = Field(None, description="Others") + currency: Optional[str] = Field(None, max_length=3, description="Currency") + currency_factor: Optional[Decimal] = Field(None, description="Currency factor") + 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 PedimentoDecrementablesCreate(PedimentoDecrementablesBase): + """Schema for creating a new Pedimento Decrementables""" + + pass + + +class PedimentoDecrementablesUpdate(BaseModel): + """Schema for updating a Pedimento Decrementables""" + + freight: Optional[Decimal] = None + insurance: Optional[Decimal] = None + loading: Optional[Decimal] = None + unloading: Optional[Decimal] = None + others: Optional[Decimal] = None + currency: Optional[str] = Field(None, max_length=3) + currency_factor: Optional[Decimal] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None + + +class PedimentoDecrementablesResponse(PedimentoDecrementablesBase): + """Schema for Pedimento Decrementables response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py new file mode 100644 index 00000000..59beceb2 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py @@ -0,0 +1,56 @@ +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoIncrementablesBase(BaseModel): + """Base schema for Pedimento Incrementables""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + insured_value: Optional[Decimal] = Field(None, description="Insured value") + freight: Optional[Decimal] = Field(None, description="Freight") + insurance: Optional[Decimal] = Field(None, description="Insurance") + packaging: Optional[Decimal] = Field(None, description="Packaging") + others: Optional[Decimal] = Field(None, description="Others") + deductibles: Optional[Decimal] = Field(None, description="Deductibles") + currency: Optional[str] = Field(None, max_length=3, description="Currency") + currency_factor: Optional[Decimal] = Field(None, description="Currency factor") + 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 PedimentoIncrementablesCreate(PedimentoIncrementablesBase): + """Schema for creating a new Pedimento Incrementables""" + + pass + + +class PedimentoIncrementablesUpdate(BaseModel): + """Schema for updating a Pedimento Incrementables""" + + insured_value: Optional[Decimal] = None + freight: Optional[Decimal] = None + insurance: Optional[Decimal] = None + packaging: Optional[Decimal] = None + others: Optional[Decimal] = None + deductibles: Optional[Decimal] = None + currency: Optional[str] = Field(None, max_length=3) + currency_factor: Optional[Decimal] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None + + +class PedimentoIncrementablesResponse(PedimentoIncrementablesBase): + """Schema for Pedimento Incrementables response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_indexes.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_indexes.py new file mode 100644 index 00000000..b304abee --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_indexes.py @@ -0,0 +1,40 @@ +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoIndexesBase(BaseModel): + """Base schema for Pedimento Indexes""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + update_factor_type: Optional[int] = Field(None, description="Update factor type") + update_factor: Optional[Decimal] = Field(None, description="Update factor") + manual_update_factor: Optional[int] = Field( + None, description="Manual update factor" + ) + + +class PedimentoIndexesCreate(PedimentoIndexesBase): + """Schema for creating a new Pedimento Indexes""" + + pass + + +class PedimentoIndexesUpdate(BaseModel): + """Schema for updating a Pedimento Indexes""" + + update_factor_type: Optional[int] = None + update_factor: Optional[Decimal] = None + manual_update_factor: Optional[int] = None + + +class PedimentoIndexesResponse(PedimentoIndexesBase): + """Schema for Pedimento Indexes response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_payments.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_payments.py new file mode 100644 index 00000000..bdd98381 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_payments.py @@ -0,0 +1,69 @@ +from datetime import date as Date +from datetime import datetime +from datetime import time as Time +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoPaymentsBase(BaseModel): + """Base schema for Pedimento Payments""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + acknowledgment: Optional[str] = Field( + None, max_length=20, description="Acknowledgment" + ) + operation_number: Optional[str] = Field( + None, max_length=14, description="Operation number" + ) + bank_code: Optional[int] = Field(None, description="Bank code") + cashier: Optional[str] = Field(None, max_length=2, description="Cashier") + date: Optional[Date] = Field(None, description="Date") + time: Optional[Time] = Field(None, description="Time") + shift: Optional[str] = Field(None, max_length=1, description="Shift") + total_cash_paid: Optional[int] = Field(None, description="Total cash paid") + total_contributions: Optional[int] = Field(None, description="Total contributions") + counter_payment: Optional[int] = Field(None, description="Counter payment") + pece_code: Optional[str] = Field(None, max_length=5, description="PECE code") + + +class PedimentoPaymentsCreate(BaseModel): + """Schema for creating a new Pedimento Payments - pedimento_id and tenant_id are set by backend""" + + acknowledgment: Optional[str] = Field(None, max_length=20, description="Acknowledgment") + operation_number: Optional[str] = Field(None, max_length=14, description="Operation number") + bank_code: Optional[int] = Field(None, description="Bank code") + cashier: Optional[str] = Field(None, max_length=2, description="Cashier") + date: Optional[Date] = Field(None, description="Date") + time: Optional[Time] = Field(None, description="Time") + shift: Optional[str] = Field(None, max_length=1, description="Shift") + total_cash_paid: Optional[int] = Field(None, description="Total cash paid") + total_contributions: Optional[int] = Field(None, description="Total contributions") + counter_payment: Optional[int] = Field(None, description="Counter payment") + pece_code: Optional[str] = Field(None, max_length=5, description="PECE code") + + +class PedimentoPaymentsUpdate(BaseModel): + """Schema for updating a Pedimento Payments""" + + acknowledgment: Optional[str] = Field(None, max_length=20) + operation_number: Optional[str] = Field(None, max_length=14) + bank_code: Optional[int] = None + cashier: Optional[str] = Field(None, max_length=2) + date: Optional[Date] = None + time: Optional[Time] = None + shift: Optional[str] = Field(None, max_length=1) + total_cash_paid: Optional[int] = None + total_contributions: Optional[int] = None + counter_payment: Optional[int] = None + pece_code: Optional[str] = Field(None, max_length=5) + + +class PedimentoPaymentsResponse(PedimentoPaymentsBase): + """Schema for Pedimento Payments response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_rectification_destination.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_rectification_destination.py new file mode 100644 index 00000000..b021cc11 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_rectification_destination.py @@ -0,0 +1,45 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoRectificationDestinationBase(BaseModel): + """Base schema for Pedimento Rectification Destination""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + destination_pedimento_year: Optional[str] = Field( + None, max_length=2, description="Destination pedimento year" + ) + destination_customs_office: Optional[str] = Field( + None, max_length=3, description="Destination customs office" + ) + destination_license: Optional[str] = Field( + None, max_length=4, description="Destination license" + ) + destination_pedimento_number: Optional[str] = Field( + None, max_length=7, description="Destination pedimento number" + ) + + +class PedimentoRectificationDestinationCreate(PedimentoRectificationDestinationBase): + """Schema for creating a new Pedimento Rectification Destination""" + + pass + + +class PedimentoRectificationDestinationUpdate(BaseModel): + """Schema for updating a Pedimento Rectification Destination""" + + destination_pedimento_year: Optional[str] = Field(None, max_length=2) + destination_customs_office: Optional[str] = Field(None, max_length=3) + destination_license: Optional[str] = Field(None, max_length=4) + destination_pedimento_number: Optional[str] = Field(None, max_length=7) + + +class PedimentoRectificationDestinationResponse(PedimentoRectificationDestinationBase): + """Schema for Pedimento Rectification Destination response""" + + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_rectification_origin.py new file mode 100644 index 00000000..93caf94f --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_rectification_origin.py @@ -0,0 +1,72 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoRectificationOriginBase(BaseModel): + """Base schema for Pedimento Rectification Origin""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + original_pedimento_year: Optional[str] = Field( + None, max_length=2, description="Original pedimento year" + ) + original_customs_office: Optional[str] = Field( + None, max_length=3, description="Original customs office" + ) + original_license: Optional[str] = Field( + None, max_length=4, description="Original license" + ) + original_pedimento_number: Optional[str] = Field( + None, max_length=7, description="Original pedimento number" + ) + original_pedimento_code: Optional[str] = Field( + None, max_length=2, description="Original pedimento key" + ) + original_payment_date: Optional[datetime] = Field( + None, description="Original payment date" + ) + total_cash: Optional[int] = Field(None, description="Total cash") + total_others: Optional[int] = Field(None, description="Total others") + reason: Optional[str] = Field(None, max_length=255, description="Reason") + charge_to_client: Optional[int] = Field(None, description="Charge to client") + use_original_payment_date_for_interest_calc: Optional[int] = Field( + None, description="Use original payment date for interest calculation" + ) + manual_calculation: Optional[int] = Field(None, description="Manual calculation") + original_pedimento_norms: Optional[int] = Field( + None, description="Original pedimento norms" + ) + + +class PedimentoRectificationOriginCreate(PedimentoRectificationOriginBase): + """Schema for creating a new Pedimento Rectification Origin""" + + pass + + +class PedimentoRectificationOriginUpdate(BaseModel): + """Schema for updating a Pedimento Rectification Origin""" + + original_pedimento_year: Optional[str] = Field(None, max_length=2) + original_customs_office: Optional[str] = Field(None, max_length=3) + original_license: Optional[str] = Field(None, max_length=4) + original_pedimento_number: Optional[str] = Field(None, max_length=7) + original_pedimento_code: Optional[str] = Field(None, max_length=2) + original_payment_date: Optional[datetime] = None + total_cash: Optional[int] = None + total_others: Optional[int] = None + reason: Optional[str] = Field(None, max_length=255) + charge_to_client: Optional[int] = None + use_original_payment_date_for_interest_calc: Optional[int] = None + manual_calculation: Optional[int] = None + original_pedimento_norms: Optional[int] = None + + +class PedimentoRectificationOriginResponse(PedimentoRectificationOriginBase): + """Schema for Pedimento Rectification Origin response""" + + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_transport_means.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_transport_means.py new file mode 100644 index 00000000..da6fb6aa --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_transport_means.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoTransportMeansBase(BaseModel): + """Base schema for Pedimento Transport Means""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + destination: Optional[int] = Field(None, description="Destination") + entry_exit: Optional[str] = Field(None, max_length=2, description="Entry/exit") + arrival: str = Field(..., max_length=2, description="Arrival") + departure: str = Field(..., max_length=2, description="Departure") + + +class PedimentoTransportMeansCreate(BaseModel): + """Schema for creating a new Pedimento Transport Means - pedimento_id and tenant_id are set by backend""" + + destination: Optional[int] = Field(None, description="Destination") + entry_exit: Optional[str] = Field(None, max_length=2, description="Entry/exit") + arrival: str = Field(..., max_length=2, description="Arrival") + departure: str = Field(..., max_length=2, description="Departure") + + +class PedimentoTransportMeansUpdate(BaseModel): + """Schema for updating a Pedimento Transport Means""" + + destination: Optional[int] = None + entry_exit: Optional[str] = Field(None, max_length=2) + arrival: Optional[str] = Field(None, max_length=2) + departure: Optional[str] = Field(None, max_length=2) + + +class PedimentoTransportMeansResponse(PedimentoTransportMeansBase): + """Schema for Pedimento Transport Means response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_validation.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_validation.py new file mode 100644 index 00000000..649ce3cb --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_validation.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class PedimentoValidationBase(BaseModel): + """Base schema for Pedimento Validation""" + + pedimento_id: int = Field(..., description="Pedimento ID") + tenant_id: int = Field(..., description="Tenant ID") + validator: Optional[str] = Field(None, max_length=3, description="Validator") + validation_ack: Optional[str] = Field( + None, max_length=8, description="Validation acknowledgment" + ) + pre_ack: Optional[str] = Field(None, max_length=8, description="Pre-acknowledgment") + line_signature: Optional[str] = Field( + None, max_length=50, description="Line signature" + ) + electronic_signature: Optional[str] = Field( + None, max_length=999, description="Electronic signature" + ) + certificate_number: Optional[str] = Field( + None, max_length=99, description="Certificate number" + ) + validator_id: Optional[int] = Field(None, description="Validator ID") + responsible_id: Optional[int] = Field(None, description="Responsible ID") + + +class PedimentoValidationCreate(BaseModel): + """Schema for creating a new Pedimento Validation - pedimento_id and tenant_id are set by backend""" + + validator: Optional[str] = Field(None, max_length=3, description="Validator") + validation_ack: Optional[str] = Field(None, max_length=8, description="Validation acknowledgment") + pre_ack: Optional[str] = Field(None, max_length=8, description="Previous acknowledgment") + line_signature: Optional[str] = Field(None, max_length=50, description="Line signature") + electronic_signature: Optional[str] = Field(None, max_length=999, description="Electronic signature") + certificate_number: Optional[str] = Field(None, max_length=99, description="Certificate number") + validator_id: Optional[int] = Field(None, description="Validator ID") + responsible_id: Optional[int] = Field(None, description="Responsible ID") + + +class PedimentoValidationUpdate(BaseModel): + """Schema for updating a Pedimento Validation""" + + validator: Optional[str] = Field(None, max_length=3) + validation_ack: Optional[str] = Field(None, max_length=8) + pre_ack: Optional[str] = Field(None, max_length=8) + line_signature: Optional[str] = Field(None, max_length=50) + electronic_signature: Optional[str] = Field(None, max_length=999) + certificate_number: Optional[str] = Field(None, max_length=99) + validator_id: Optional[int] = None + responsible_id: Optional[int] = None + + +class PedimentoValidationResponse(PedimentoValidationBase): + """Schema for Pedimento Validation response""" + + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimentos.py new file mode 100644 index 00000000..32598d75 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimentos.py @@ -0,0 +1,150 @@ +from datetime import datetime +from decimal import Decimal +from enum import IntEnum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from .pedimento_config_additional import PedimentoConfigAdditionalCreate, PedimentoConfigAdditionalResponse +from .pedimento_config_calculations import PedimentoConfigCalculationsCreate, PedimentoConfigCalculationsResponse +from .pedimento_config_parameters import PedimentoConfigParametersCreate, PedimentoConfigParametersResponse +from .pedimento_config_surcharges import PedimentoConfigSurchargesCreate, PedimentoConfigSurchargesResponse +from .pedimento_config_update_rectification import PedimentoConfigUpdateRectificationCreate, PedimentoConfigUpdateRectificationResponse +from .pedimento_config_updates import PedimentoConfigUpdatesCreate, PedimentoConfigUpdatesResponse +from .pedimento_customs_offices import PedimentoCustomsOfficesCreate, PedimentoCustomsOfficesResponse +from .pedimento_dates import PedimentoDatesCreate, PedimentoDatesResponse +from .pedimento_decrementables import PedimentoDecrementablesCreate, PedimentoDecrementablesResponse +from .pedimento_incrementables import PedimentoIncrementablesCreate, PedimentoIncrementablesResponse +from .pedimento_indexes import PedimentoIndexesCreate, PedimentoIndexesResponse +from .pedimento_payments import PedimentoPaymentsCreate, PedimentoPaymentsResponse +from .pedimento_rectification_destination import PedimentoRectificationDestinationCreate, PedimentoRectificationDestinationResponse +from .pedimento_rectification_origin import PedimentoRectificationOriginCreate, PedimentoRectificationOriginResponse +from .pedimento_transport_means import PedimentoTransportMeansCreate, PedimentoTransportMeansResponse +from .pedimento_validation import PedimentoValidationCreate, PedimentoValidationResponse + + +class OperationType(IntEnum): + EXPORTACION = 1 + IMPORTACION = 2 + + +class PedimentosBase(BaseModel): + """Base schema for Pedimentos""" + + year: Optional[str] = Field(None, max_length=2, description="Year") + customs_office: Optional[str] = Field( + None, max_length=3, description="Customs office" + ) + license: Optional[str] = Field(None, max_length=4, description="License") + pedimento_number: Optional[str] = Field( + None, max_length=7, description="Pedimento number" + ) + client_id: Optional[int] = Field(None, description="Client ID") + operation_type: Optional[int] = Field(None, description="Operation type") + pedimento_type: Optional[str] = Field(None, max_length=20, description="Pedimento type") + pedimento_code: str = Field( + ..., max_length=2, description="Pedimento key" + ) + regime: str = Field(..., max_length=3, description="Regime") + status: Optional[str] = Field(None, max_length=30, description="Status") + usd_value: Optional[Decimal] = Field(None, description="USD value") + paid_price: Optional[Decimal] = Field(None, description="Paid price") + gross_weight: Optional[Decimal] = Field(None, description="Gross weight") + exchange_rate: Optional[Decimal] = Field(None, description="Exchange rate") + +class PedimentosCreate(PedimentosBase): + """Schema for creating a new Pedimento""" + + # Override to make required fields non-optional + year: str = Field(..., max_length=2, description="Year") + customs_office: str = Field(..., max_length=3, description="Customs office") + license: str = Field(..., max_length=4, description="License") + pedimento_number: str = Field(..., max_length=7, description="Pedimento number") + client_id: int = Field(..., description="Client ID") + operation_type: int = Field(..., description="Operation type") + pedimento_type: str = Field(..., max_length=20, description="Pedimento type") + pedimento_code: str = Field( + ..., max_length=2, description="Pedimento key" + ) + regime: str = Field(..., max_length=3, description="Regime") + status: str = Field(..., max_length=30, description="Status") + + pedimento_dates: Optional[PedimentoDatesCreate] = None + pedimento_decrementables: Optional[PedimentoDecrementablesCreate] = None + pedimento_incrementables: Optional[PedimentoIncrementablesCreate] = None + pedimento_indexes: Optional[PedimentoIndexesCreate] = None + pedimento_validation: Optional[PedimentoValidationCreate] = None + pedimento_customs_offices: Optional[PedimentoCustomsOfficesCreate] = None + pedimento_payments: Optional[PedimentoPaymentsCreate] = None + pedimento_rectification_destination: Optional[PedimentoRectificationDestinationCreate] = None + pedimento_rectification_origin: Optional[PedimentoRectificationOriginCreate] = None + pedimento_transport_means: Optional[PedimentoTransportMeansCreate] = None + pedimento_config_additional: Optional[PedimentoConfigAdditionalCreate] = None + pedimento_config_calculations: Optional[PedimentoConfigCalculationsCreate] = None + pedimento_config_parameters: Optional[PedimentoConfigParametersCreate] = None + pedimento_config_surcharges: Optional[PedimentoConfigSurchargesCreate] = None + pedimento_config_update_rectification: Optional[PedimentoConfigUpdateRectificationCreate] = None + pedimento_config_updates: Optional[PedimentoConfigUpdatesCreate] = None + +class PedimentosUpdate(BaseModel): + """Schema for updating a Pedimento""" + + year: Optional[str] = Field(None, max_length=2) + customs_office: Optional[str] = Field(None, max_length=3) + license: Optional[str] = Field(None, max_length=4) + pedimento_number: Optional[str] = Field(None, max_length=7) + client_id: Optional[int] = None + operation_type: Optional[int] = None + pedimento_type: Optional[str] = Field(None, max_length=20) + pedimento_code: Optional[str] = Field(None, max_length=2) + regime: Optional[str] = Field(None, max_length=3) + status: Optional[str] = Field(None, max_length=30) + usd_value: Optional[Decimal] = None + paid_price: Optional[Decimal] = None + gross_weight: Optional[Decimal] = None + exchange_rate: Optional[Decimal] = None + + # Sub-resources + pedimento_dates: Optional[PedimentoDatesCreate] = None + pedimento_decrementables: Optional[PedimentoDecrementablesCreate] = None + pedimento_incrementables: Optional[PedimentoIncrementablesCreate] = None + pedimento_indexes: Optional[PedimentoIndexesCreate] = None + pedimento_validation: Optional[PedimentoValidationCreate] = None + pedimento_customs_offices: Optional[PedimentoCustomsOfficesCreate] = None + pedimento_payments: Optional[PedimentoPaymentsCreate] = None + pedimento_rectification_destination: Optional[PedimentoRectificationDestinationCreate] = None + pedimento_rectification_origin: Optional[PedimentoRectificationOriginCreate] = None + pedimento_transport_means: Optional[PedimentoTransportMeansCreate] = None + pedimento_config_additional: Optional[PedimentoConfigAdditionalCreate] = None + pedimento_config_calculations: Optional[PedimentoConfigCalculationsCreate] = None + pedimento_config_parameters: Optional[PedimentoConfigParametersCreate] = None + pedimento_config_surcharges: Optional[PedimentoConfigSurchargesCreate] = None + pedimento_config_update_rectification: Optional[PedimentoConfigUpdateRectificationCreate] = None + pedimento_config_updates: Optional[PedimentoConfigUpdatesCreate] = None + + +class PedimentosResponse(PedimentosBase): + """Schema for Pedimento response""" + + id: int + tenant_id: int + created_at: datetime + + pedimento_dates: Optional[PedimentoDatesResponse] = None + pedimento_decrementables: Optional[PedimentoDecrementablesResponse] = None + pedimento_incrementables: Optional[PedimentoIncrementablesResponse] = None + pedimento_indexes: Optional[PedimentoIndexesResponse] = None + pedimento_validation: Optional[PedimentoValidationResponse] = None + pedimento_customs_offices: Optional[PedimentoCustomsOfficesResponse] = None + pedimento_payments: Optional[PedimentoPaymentsResponse] = None + pedimento_rectification_destination: Optional[PedimentoRectificationDestinationResponse] = None + pedimento_rectification_origin: Optional[PedimentoRectificationOriginResponse] = None + pedimento_transport_means: Optional[PedimentoTransportMeansResponse] = None + pedimento_config_additional: Optional[PedimentoConfigAdditionalResponse] = None + pedimento_config_calculations: Optional[PedimentoConfigCalculationsResponse] = None + pedimento_config_parameters: Optional[PedimentoConfigParametersResponse] = None + pedimento_config_surcharges: Optional[PedimentoConfigSurchargesResponse] = None + pedimento_config_update_rectification: Optional[PedimentoConfigUpdateRectificationResponse] = None + pedimento_config_updates: Optional[PedimentoConfigUpdatesResponse] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_additional.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_additional.py new file mode 100644 index 00000000..22735ad6 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_additional.py @@ -0,0 +1,50 @@ +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoConfigAdditional(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_config_additional" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_config_additional_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_config_additional", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_config_additional_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + add_po_identifier: Mapped[int] = mapped_column(SmallInteger) + do_not_exempt_norms_complement_x: Mapped[int] = mapped_column(SmallInteger) + manual_pedimento_year: Mapped[str] = mapped_column(String(2)) + enable_import_invoice_recipient: Mapped[int] = mapped_column(SmallInteger) + send_502_validation_file_for_consolidated: Mapped[int] = mapped_column(SmallInteger) + add_remove_norms: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_config_additional" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_calculations.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_calculations.py new file mode 100644 index 00000000..013768fe --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_calculations.py @@ -0,0 +1,54 @@ +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoConfigCalculations(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_config_calculations" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_config_calculations_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_config_calculations", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_config_calculations_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + dta_type: Mapped[str] = mapped_column(String(1)) + dta_operation: Mapped[int] = mapped_column(SmallInteger) + dta_vehicle_count: Mapped[int] = mapped_column(SmallInteger) + dta_mixed_rate_8permil: Mapped[int] = mapped_column(SmallInteger) + pays_vat: Mapped[int] = mapped_column(SmallInteger) + pays_prevalidation: Mapped[int] = mapped_column(SmallInteger) + include_sagar_certificate_fee: Mapped[int] = mapped_column(SmallInteger) + fixed_vehicle_dta_fee: Mapped[int] = mapped_column(SmallInteger) + additional_fixed_fee: Mapped[int] = mapped_column(SmallInteger) + additional_fixed_fee_payment_method: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_config_calculations" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_parameters.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_parameters.py new file mode 100644 index 00000000..875d46d8 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_parameters.py @@ -0,0 +1,56 @@ +from decimal import Decimal +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + Numeric, + PrimaryKeyConstraint, + SmallInteger, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoConfigParameters(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_config_parameters" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_config_parameters_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_config_parameters", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_config_parameters_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + is_embassy: Mapped[int] = mapped_column(SmallInteger) + embassy_dta: Mapped[Decimal] = mapped_column(Numeric(11, 2)) + rule_3121_section_ii: Mapped[int] = mapped_column(SmallInteger) + use_previous_tariff: Mapped[int] = mapped_column(SmallInteger) + use_payment_date_fi: Mapped[int] = mapped_column(SmallInteger) + add_state_supplier_record_505: Mapped[int] = mapped_column(SmallInteger) + customs_value_calculation: Mapped[int] = mapped_column(SmallInteger) + two_decimals_unit_value: Mapped[int] = mapped_column(SmallInteger) + customs_value_per_item: Mapped[int] = mapped_column(SmallInteger) + is_national_supplier: Mapped[int] = mapped_column(SmallInteger) + is_consolidated: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_config_parameters" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_surcharges.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_surcharges.py new file mode 100644 index 00000000..2d2cdd78 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_surcharges.py @@ -0,0 +1,49 @@ +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoConfigSurcharges(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_config_surcharges" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_config_surcharges_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_config_surcharges", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_config_surcharges_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + surcharge_igi: Mapped[int] = mapped_column(SmallInteger) + surcharge_dta: Mapped[int] = mapped_column(SmallInteger) + surcharge_vat: Mapped[int] = mapped_column(SmallInteger) + surcharge_isan: Mapped[int] = mapped_column(SmallInteger) + surcharge_ieps: Mapped[int] = mapped_column(SmallInteger) + surcharge_cc: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_config_surcharges" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_update_rectification.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_update_rectification.py new file mode 100644 index 00000000..a8ebe936 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_update_rectification.py @@ -0,0 +1,48 @@ +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoConfigUpdateRectification(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_config_update_rectification" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_config_update_rectification_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_config_update_rectification", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_config_update_rectification_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + update_vat: Mapped[int] = mapped_column(SmallInteger) + update_advalorem: Mapped[int] = mapped_column(SmallInteger) + update_cc: Mapped[int] = mapped_column(SmallInteger) + update_ieps: Mapped[int] = mapped_column(SmallInteger) + calculate_surcharge: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_config_update_rectification" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_updates.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_updates.py new file mode 100644 index 00000000..d652b699 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_config_updates.py @@ -0,0 +1,47 @@ +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoConfigUpdates(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_config_updates" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_config_updates_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_config_updates", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_config_updates_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + update_vat: Mapped[int] = mapped_column(SmallInteger) + update_advalorem: Mapped[int] = mapped_column(SmallInteger) + update_cc: Mapped[int] = mapped_column(SmallInteger) + update_ieps: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_config_updates" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_customs_offices.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_customs_offices.py new file mode 100644 index 00000000..14decb00 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_customs_offices.py @@ -0,0 +1,45 @@ +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoCustomsOffices(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_customs_offices" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_customs_offices_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_customs_offices", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_customs_offices_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + dispatch_customs: Mapped[str] = mapped_column(String(3)) + entry_exit_customs: Mapped[str] = mapped_column(String(3)) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_customs_offices" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_dates.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_dates.py new file mode 100644 index 00000000..fe8a5d0a --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_dates.py @@ -0,0 +1,60 @@ +from datetime import datetime +from datetime import time as datetime_time +from typing import TYPE_CHECKING, Optional + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + DateTime, + ForeignKeyConstraint, + Index, + Integer, + PrimaryKeyConstraint, + Time, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoDates(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_dates" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_dates_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_dates", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_dates_pedimento_id_key", + ), + Index("idx_pedimento_dates_pedimento_id", "pedimento_id"), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + entry_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + pedimento_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + payment_date: Mapped[datetime] = mapped_column(DateTime) + rectification_payment_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + extraction_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + submission_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + eucan_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + original_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + start_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + end_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + + capture_time: Mapped[datetime_time] = mapped_column(Time) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_dates" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_decrementables.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_decrementables.py new file mode 100644 index 00000000..732800f2 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_decrementables.py @@ -0,0 +1,55 @@ +from decimal import Decimal +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + Numeric, + PrimaryKeyConstraint, + SmallInteger, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoDecrementables(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_decrementables" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_decrementables_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_decrementables", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_decrementables_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + freight: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + insurance: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + loading: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + unloading: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + others: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + currency: Mapped[str] = mapped_column(String(3)) + currency_factor: Mapped[Decimal] = mapped_column(Numeric(15, 8)) + not_affect_usd_value: Mapped[int] = mapped_column(SmallInteger) + not_affect_customs_value: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_decrementables" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_incrementables.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_incrementables.py new file mode 100644 index 00000000..d13e660b --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_incrementables.py @@ -0,0 +1,56 @@ +from decimal import Decimal +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + Numeric, + PrimaryKeyConstraint, + SmallInteger, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoIncrementables(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_incrementables" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_incrementables_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_incrementables", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_incrementables_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + insured_value: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + freight: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + insurance: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + packaging: Mapped[Decimal] = mapped_column(Numeric(13, 2)) + others: Mapped[Decimal] = mapped_column(Numeric(13, 3)) + deductibles: Mapped[Decimal] = mapped_column(Numeric(13, 3)) + currency: Mapped[str] = mapped_column(String(3)) + currency_factor: Mapped[Decimal] = mapped_column(Numeric(15, 8)) + not_affect_usd_value: Mapped[int] = mapped_column(SmallInteger) + not_affect_customs_value: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_incrementables" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_indexes.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_indexes.py new file mode 100644 index 00000000..9f0cbd68 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_indexes.py @@ -0,0 +1,48 @@ +from decimal import Decimal +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + Numeric, + PrimaryKeyConstraint, + SmallInteger, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoIndexes(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_indexes" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_indexes_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_indexes", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_indexes_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + update_factor_type: Mapped[int] = mapped_column(SmallInteger) + update_factor: Mapped[Decimal] = mapped_column(Numeric(7, 4)) + manual_update_factor: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_indexes" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_payments.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_payments.py new file mode 100644 index 00000000..faad1622 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_payments.py @@ -0,0 +1,61 @@ +from datetime import date as Date2 +from datetime import time as Time2 +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + Date, + ForeignKeyConstraint, + Index, + Integer, + PrimaryKeyConstraint, + SmallInteger, + String, + Time, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoPayments(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_payments" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_payments_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_payments", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_payments_pedimento_id_key", + ), + Index("idx_pedimento_payments_pedimento_id", "pedimento_id"), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + acknowledgment: Mapped[str] = mapped_column(String(20)) + operation_number: Mapped[str] = mapped_column(String(14)) + bank_code: Mapped[int] = mapped_column(Integer) + cashier: Mapped[str] = mapped_column(String(2)) + date: Mapped[Date2] = mapped_column(Date) + time: Mapped[Time2] = mapped_column(Time) + shift: Mapped[str] = mapped_column(String(1)) + total_cash_paid: Mapped[int] = mapped_column(Integer) + total_contributions: Mapped[int] = mapped_column(Integer) + counter_payment: Mapped[int] = mapped_column(SmallInteger) + pece_code: Mapped[str] = mapped_column(String(5)) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_payments" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_destination.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_destination.py new file mode 100644 index 00000000..9b9928c2 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_destination.py @@ -0,0 +1,47 @@ +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoRectificationDestination(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_rectification_destination" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_rectification_destination_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_rectification_destination", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_rectification_destination_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + destination_pedimento_year: Mapped[str] = mapped_column(String(2)) + destination_customs_office: Mapped[str] = mapped_column(String(3)) + destination_license: Mapped[str] = mapped_column(String(4)) + destination_pedimento_number: Mapped[str] = mapped_column(String(7)) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_rectification_destination" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py new file mode 100644 index 00000000..9a5fda2f --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py @@ -0,0 +1,61 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + DateTime, + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoRectificationOrigin(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_rectification_origin" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_rectification_origin_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_rectification_origin", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_rectification_origin_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + original_pedimento_year: Mapped[str] = mapped_column(String(2)) + original_customs_office: Mapped[str] = mapped_column(String(3)) + original_license: Mapped[str] = mapped_column(String(4)) + original_pedimento_number: Mapped[str] = mapped_column(String(7)) + original_pedimento_code: Mapped[str] = mapped_column(String(2)) + original_payment_date: Mapped[datetime] = mapped_column(DateTime) + total_cash: Mapped[int] = mapped_column(Integer) + total_others: Mapped[int] = mapped_column(Integer) + reason: Mapped[str] = mapped_column(String(255)) + charge_to_client: Mapped[int] = mapped_column(SmallInteger) + use_original_payment_date_for_interest_calc: Mapped[int] = mapped_column( + SmallInteger + ) + manual_calculation: Mapped[int] = mapped_column(SmallInteger) + original_pedimento_norms: Mapped[int] = mapped_column(SmallInteger) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_rectification_origin" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_transport_means.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_transport_means.py new file mode 100644 index 00000000..271f69d4 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_transport_means.py @@ -0,0 +1,48 @@ +from typing import TYPE_CHECKING + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Integer, + PrimaryKeyConstraint, + SmallInteger, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoTransportMeans(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_transport_means" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_transport_means_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_transport_means", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_transport_means_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + destination: Mapped[int] = mapped_column(SmallInteger) + entry_exit: Mapped[str] = mapped_column(String(2)) + arrival: Mapped[str] = mapped_column(String(2)) + departure: Mapped[str] = mapped_column(String(2)) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_transport_means" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_validation.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_validation.py new file mode 100644 index 00000000..4a5a98f5 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_validation.py @@ -0,0 +1,51 @@ +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + +class PedimentoValidation(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimento_validation" # PedimentoValidacion + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimento_validation_pkey"), + ForeignKeyConstraint( + ["pedimento_id"], + ["a76.pedimentos.id"], + ondelete="CASCADE", + name="fk_pedimento_validation", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "pedimento_id", + name="pedimento_validation_pedimento_id_key", + ), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) + + validator: Mapped[str] = mapped_column(String(3)) # validador + validation_ack: Mapped[str] = mapped_column(String(8)) # acuse_validacion + pre_ack: Mapped[str] = mapped_column(String(8)) # acuse_previo + line_signature: Mapped[str] = mapped_column(String(50)) # firma_linea_captura + electronic_signature: Mapped[str] = mapped_column(String(999)) # firma_electronica + certificate_number: Mapped[str] = mapped_column(String(99)) # numero_certificado + validator_id: Mapped[int] = mapped_column(Integer) + responsible_id: Mapped[int] = mapped_column(Integer) + + pedimento: Mapped["Pedimentos"] = relationship( + "Pedimentos", back_populates="pedimento_validation" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/models/pedimentos.py new file mode 100644 index 00000000..3f414f1c --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimentos.py @@ -0,0 +1,166 @@ +from decimal import Decimal +from typing import TYPE_CHECKING, Optional + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + ForeignKeyConstraint, + Index, + Integer, + Numeric, + PrimaryKeyConstraint, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.pedmientos.models.pedimento_config_additional import ( + PedimentoConfigAdditional, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_config_calculations import ( + PedimentoConfigCalculations, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_config_parameters import ( + PedimentoConfigParameters, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_config_surcharges import ( + PedimentoConfigSurcharges, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_config_update_rectification import ( + PedimentoConfigUpdateRectification, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_config_updates import ( + PedimentoConfigUpdates, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_customs_offices import ( + PedimentoCustomsOffices, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + from api.v1.modules.a76.pedmientos.models.pedimento_decrementables import ( + PedimentoDecrementables, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_incrementables import ( + PedimentoIncrementables, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_indexes import PedimentoIndexes + from api.v1.modules.a76.pedmientos.models.pedimento_payments import ( + PedimentoPayments, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_rectification_destination import ( + PedimentoRectificationDestination, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_rectification_origin import ( + PedimentoRectificationOrigin, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_transport_means import ( + PedimentoTransportMeans, + ) + from api.v1.modules.a76.pedmientos.models.pedimento_validation import ( + PedimentoValidation, + ) + + +class Pedimentos(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "pedimentos" + __table_args__ = ( + PrimaryKeyConstraint("id", name="pedimentos_pkey"), + ForeignKeyConstraint( + ["client_id"], ["a76.clients_and_providers.id"], name="fk_pedimentos_client" + ), + ForeignKeyConstraint( + ["regime"], ["public.pedimento_regimens.code"], name="fk_pedimentos_regime" + ), + ForeignKeyConstraint( + ["pedimento_code"], + ["public.pedimento_codes.code"], + name="fk_pedimentos_code", + ), + UniqueConstraint( + "tenant_id", + "company_id", + "year", + "customs_office", + "license", + "pedimento_number", + name="pedimentos_unique_key", + ), + Index("idx_pedimentos_client_id", "client_id"), + Index("idx_pedimentos_created_at", "created_at"), + Index("idx_pedimentos_status", "status"), + {"schema": "a76"}, + ) + + id: Mapped[int] = mapped_column(Integer) + + year: Mapped[str] = mapped_column(String(2)) + customs_office: Mapped[str] = mapped_column(String(3)) + license: Mapped[str] = mapped_column(String(4)) + pedimento_number: Mapped[str] = mapped_column(String(7)) + client_id: Mapped[int] = mapped_column(Integer) + operation_type: Mapped[int] = mapped_column(Integer) + pedimento_type: Mapped[str] = mapped_column(String(20)) + pedimento_code: Mapped[str] = mapped_column(String(2)) + regime: Mapped[str] = mapped_column(String(3)) + status: Mapped[str] = mapped_column(String(30)) + usd_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(17, 6)) + paid_price: Mapped[Optional[Decimal]] = mapped_column(Numeric(17, 6)) + gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 3)) + exchange_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(9, 5)) + + pedimento_config_additional: Mapped["PedimentoConfigAdditional"] = relationship( + "PedimentoConfigAdditional", uselist=False, back_populates="pedimento" + ) + pedimento_config_calculations: Mapped["PedimentoConfigCalculations"] = relationship( + "PedimentoConfigCalculations", uselist=False, back_populates="pedimento" + ) + pedimento_config_parameters: Mapped["PedimentoConfigParameters"] = relationship( + "PedimentoConfigParameters", uselist=False, back_populates="pedimento" + ) + pedimento_config_surcharges: Mapped["PedimentoConfigSurcharges"] = relationship( + "PedimentoConfigSurcharges", uselist=False, back_populates="pedimento" + ) + pedimento_config_update_rectification: Mapped[ + "PedimentoConfigUpdateRectification" + ] = relationship( + "PedimentoConfigUpdateRectification", uselist=False, back_populates="pedimento" + ) + pedimento_config_updates: Mapped["PedimentoConfigUpdates"] = relationship( + "PedimentoConfigUpdates", uselist=False, back_populates="pedimento" + ) + pedimento_customs_offices: Mapped["PedimentoCustomsOffices"] = relationship( + "PedimentoCustomsOffices", uselist=False, back_populates="pedimento" + ) + pedimento_dates: Mapped["PedimentoDates"] = relationship( + "PedimentoDates", uselist=False, back_populates="pedimento" + ) + pedimento_decrementables: Mapped["PedimentoDecrementables"] = relationship( + "PedimentoDecrementables", uselist=False, back_populates="pedimento" + ) + pedimento_incrementables: Mapped["PedimentoIncrementables"] = relationship( + "PedimentoIncrementables", uselist=False, back_populates="pedimento" + ) + pedimento_indexes: Mapped["PedimentoIndexes"] = relationship( + "PedimentoIndexes", uselist=False, back_populates="pedimento" + ) + pedimento_payments: Mapped["PedimentoPayments"] = relationship( + "PedimentoPayments", uselist=False, back_populates="pedimento" + ) + pedimento_rectification_destination: Mapped["PedimentoRectificationDestination"] = ( + relationship( + "PedimentoRectificationDestination", + uselist=False, + back_populates="pedimento", + ) + ) + pedimento_rectification_origin: Mapped["PedimentoRectificationOrigin"] = ( + relationship( + "PedimentoRectificationOrigin", uselist=False, back_populates="pedimento" + ) + ) + pedimento_transport_means: Mapped["PedimentoTransportMeans"] = relationship( + "PedimentoTransportMeans", uselist=False, back_populates="pedimento" + ) + pedimento_validation: Mapped["PedimentoValidation"] = relationship( + "PedimentoValidation", uselist=False, back_populates="pedimento" + ) diff --git a/backend/api/v1/modules/a76/pedmientos/router.py b/backend/api/v1/modules/a76/pedmientos/router.py new file mode 100644 index 00000000..f294cba4 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/router.py @@ -0,0 +1,119 @@ +from fastapi import APIRouter + +from .routes.pedimento_config_additional import ( + router as pedimento_config_additional_router, +) +from .routes.pedimento_config_calculations import ( + router as pedimento_config_calculations_router, +) +from .routes.pedimento_config_parameters import ( + router as pedimento_config_parameters_router, +) +from .routes.pedimento_config_surcharges import ( + router as pedimento_config_surcharges_router, +) +from .routes.pedimento_config_update_rectification import ( + router as pedimento_config_update_rectification_router, +) +from .routes.pedimento_config_updates import router as pedimento_config_updates_router +from .routes.pedimento_customs_offices import router as pedimento_customs_offices_router +from .routes.pedimento_dates import router as pedimento_dates_router +from .routes.pedimento_decrementables import router as pedimento_decrementables_router +from .routes.pedimento_incrementables import router as pedimento_incrementables_router +from .routes.pedimento_indexes import router as pedimento_indexes_router +from .routes.pedimento_payments import router as pedimento_payments_router +from .routes.pedimento_rectification_destination import ( + router as pedimento_rectification_destination_router, +) +from .routes.pedimento_rectification_origin import ( + router as pedimento_rectification_origin_router, +) +from .routes.pedimento_transport_means import router as pedimento_transport_means_router +from .routes.pedimento_validation import router as pedimento_validation_router +from .routes.pedimentos import router as pedimentos_router + +router = APIRouter() + +router.include_router( + pedimento_config_additional_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_config_additional"], +) +router.include_router( + pedimento_config_calculations_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_config_calculations"], +) +router.include_router( + pedimento_config_parameters_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_config_parameters"], +) +router.include_router( + pedimento_config_surcharges_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_config_surcharges"], +) +router.include_router( + pedimento_config_update_rectification_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_config_update_rectification"], +) +router.include_router( + pedimento_config_updates_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_config_updates"], +) +router.include_router( + pedimento_customs_offices_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_customs_offices"], +) +router.include_router( + pedimento_dates_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_dates"], +) +router.include_router( + pedimento_decrementables_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_decrementables"], +) +router.include_router( + pedimento_incrementables_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_incrementables"], +) +router.include_router( + pedimento_indexes_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_indexes"], +) +router.include_router( + pedimento_payments_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_payments"], +) +router.include_router( + pedimento_rectification_destination_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_rectification_destination"], +) +router.include_router( + pedimento_rectification_origin_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_rectification_origin"], +) +router.include_router( + pedimento_transport_means_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_transport_means"], +) +router.include_router( + pedimento_validation_router, + prefix="/pedimentos", + tags=["a76 / pedimentos / pedimento_validation"], +) +router.include_router( + pedimentos_router, prefix="/pedimentos", tags=["a76 / pedimentos"] +) diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_additional.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_additional.py new file mode 100644 index 00000000..650cb92d --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_additional.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoConfigAdditional CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_config_additional import ( + PedimentoConfigAdditionalCreate, + PedimentoConfigAdditionalResponse, + PedimentoConfigAdditionalUpdate, +) +from ..services.pedimento_config_additional import PedimentoConfigAdditionalService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoConfigAdditionalService, + create_schema=PedimentoConfigAdditionalCreate, + update_schema=PedimentoConfigAdditionalUpdate, + response_schema=PedimentoConfigAdditionalResponse, + prefix="/{pedimento_id}/config-additional", + tags=[], + resource_name="Config additional", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_calculations.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_calculations.py new file mode 100644 index 00000000..ceb572c4 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_calculations.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoConfigCalculations CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_config_calculations import ( + PedimentoConfigCalculationsCreate, + PedimentoConfigCalculationsResponse, + PedimentoConfigCalculationsUpdate, +) +from ..services.pedimento_config_calculations import PedimentoConfigCalculationsService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoConfigCalculationsService, + create_schema=PedimentoConfigCalculationsCreate, + update_schema=PedimentoConfigCalculationsUpdate, + response_schema=PedimentoConfigCalculationsResponse, + prefix="/{pedimento_id}/config-calculations", + tags=[], + resource_name="Config calculations", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_parameters.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_parameters.py new file mode 100644 index 00000000..c182a1fc --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_parameters.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoConfigParameters CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_config_parameters import ( + PedimentoConfigParametersCreate, + PedimentoConfigParametersResponse, + PedimentoConfigParametersUpdate, +) +from ..services.pedimento_config_parameters import PedimentoConfigParametersService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoConfigParametersService, + create_schema=PedimentoConfigParametersCreate, + update_schema=PedimentoConfigParametersUpdate, + response_schema=PedimentoConfigParametersResponse, + prefix="/{pedimento_id}/config-parameters", + tags=[], + resource_name="Config parameters", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_surcharges.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_surcharges.py new file mode 100644 index 00000000..d2575d18 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_surcharges.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoConfigSurcharges CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_config_surcharges import ( + PedimentoConfigSurchargesCreate, + PedimentoConfigSurchargesResponse, + PedimentoConfigSurchargesUpdate, +) +from ..services.pedimento_config_surcharges import PedimentoConfigSurchargesService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoConfigSurchargesService, + create_schema=PedimentoConfigSurchargesCreate, + update_schema=PedimentoConfigSurchargesUpdate, + response_schema=PedimentoConfigSurchargesResponse, + prefix="/{pedimento_id}/config-surcharges", + tags=[], + resource_name="Config surcharges", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_update_rectification.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_update_rectification.py new file mode 100644 index 00000000..c16ce5a8 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_update_rectification.py @@ -0,0 +1,28 @@ +""" +Routes for PedimentoConfigUpdateRectification CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_config_update_rectification import ( + PedimentoConfigUpdateRectificationCreate, + PedimentoConfigUpdateRectificationResponse, + PedimentoConfigUpdateRectificationUpdate, +) +from ..services.pedimento_config_update_rectification import ( + PedimentoConfigUpdateRectificationService, +) + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoConfigUpdateRectificationService, + create_schema=PedimentoConfigUpdateRectificationCreate, + update_schema=PedimentoConfigUpdateRectificationUpdate, + response_schema=PedimentoConfigUpdateRectificationResponse, + prefix="/{pedimento_id}/config-update-rectification", + tags=[], + resource_name="Config update rectification", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_updates.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_updates.py new file mode 100644 index 00000000..d026b2bf --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_config_updates.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoConfigUpdates CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_config_updates import ( + PedimentoConfigUpdatesCreate, + PedimentoConfigUpdatesResponse, + PedimentoConfigUpdatesUpdate, +) +from ..services.pedimento_config_updates import PedimentoConfigUpdatesService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoConfigUpdatesService, + create_schema=PedimentoConfigUpdatesCreate, + update_schema=PedimentoConfigUpdatesUpdate, + response_schema=PedimentoConfigUpdatesResponse, + prefix="/{pedimento_id}/config-updates", + tags=[], + resource_name="Config updates", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_customs_offices.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_customs_offices.py new file mode 100644 index 00000000..255ffef1 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_customs_offices.py @@ -0,0 +1,115 @@ +""" +Routes for PedimentoCustomsOffices CRUD operations +""" + +from typing import Any, Dict, List + +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 ..dtos.pedimento_customs_offices import ( + PedimentoCustomsOfficesCreate, + PedimentoCustomsOfficesResponse, + PedimentoCustomsOfficesUpdate, +) +from ..services.pedimento_customs_offices import PedimentoCustomsOfficesService + +router = APIRouter(prefix="/{pedimento_id}/customs-offices") + + +@router.get("/", response_model=List[PedimentoCustomsOfficesResponse]) +async def list_customs_offices( + pedimento_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all customs offices for a pedimento""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + offices = PedimentoCustomsOfficesService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + return offices + + +@router.get("/{office_id}", response_model=PedimentoCustomsOfficesResponse) +async def get_customs_office( + pedimento_id: int, + office_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get a specific customs office by ID""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + office = PedimentoCustomsOfficesService.get_by_id( + db, office_id, pedimento_id, tenant_id, company_id + ) + if not office: + raise HTTPException(status_code=404, detail="Customs office not found") + + return office + + +@router.post("/", response_model=PedimentoCustomsOfficesResponse, status_code=201) +async def create_customs_office( + pedimento_id: int, + data: PedimentoCustomsOfficesCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Create a new customs office""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Ensure pedimento_id matches + if data.pedimento_id != pedimento_id: + raise HTTPException(status_code=400, detail="Pedimento ID mismatch") + + office = PedimentoCustomsOfficesService.create(db, data, tenant_id, company_id) + return office + + +@router.put("/{office_id}", response_model=PedimentoCustomsOfficesResponse) +async def update_customs_office( + pedimento_id: int, + office_id: int, + data: PedimentoCustomsOfficesUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Update a customs office""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + office = PedimentoCustomsOfficesService.update( + db, office_id, pedimento_id, tenant_id, company_id, data + ) + if not office: + raise HTTPException(status_code=404, detail="Customs office not found") + + return office + + +@router.delete("/{office_id}", status_code=204) +async def delete_customs_office( + pedimento_id: int, + office_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a customs office""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + success = PedimentoCustomsOfficesService.delete( + db, office_id, pedimento_id, tenant_id, company_id + ) + if not success: + raise HTTPException(status_code=404, detail="Customs office not found") + + return None diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_dates.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_dates.py new file mode 100644 index 00000000..4b3788cd --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_dates.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoDates CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_dates import ( + PedimentoDatesCreate, + PedimentoDatesResponse, + PedimentoDatesUpdate, +) +from ..services.pedimento_dates import PedimentoDatesService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoDatesService, + create_schema=PedimentoDatesCreate, + update_schema=PedimentoDatesUpdate, + response_schema=PedimentoDatesResponse, + prefix="/{pedimento_id}/dates", + tags=[], + resource_name="Pedimento dates", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_decrementables.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_decrementables.py new file mode 100644 index 00000000..3dcb2c4a --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_decrementables.py @@ -0,0 +1,117 @@ +""" +Routes for PedimentoDecrementables CRUD operations +""" + +from typing import Any, Dict, List + +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 ..dtos.pedimento_decrementables import ( + PedimentoDecrementablesCreate, + PedimentoDecrementablesResponse, + PedimentoDecrementablesUpdate, +) +from ..services.pedimento_decrementables import PedimentoDecrementablesService + +router = APIRouter(prefix="/{pedimento_id}/decrementables") + + +@router.get("/", response_model=List[PedimentoDecrementablesResponse]) +async def list_decrementables( + pedimento_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all decrementables for a pedimento""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + decrementables = PedimentoDecrementablesService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + return decrementables + + +@router.get("/{decrementable_id}", response_model=PedimentoDecrementablesResponse) +async def get_decrementable( + pedimento_id: int, + decrementable_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get a specific decrementable by ID""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + decrementable = PedimentoDecrementablesService.get_by_id( + db, decrementable_id, pedimento_id, tenant_id, company_id + ) + if not decrementable: + raise HTTPException(status_code=404, detail="Decrementable not found") + + return decrementable + + +@router.post("/", response_model=PedimentoDecrementablesResponse, status_code=201) +async def create_decrementable( + pedimento_id: int, + data: PedimentoDecrementablesCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Create a new decrementable""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Ensure pedimento_id matches + if data.pedimento_id != pedimento_id: + raise HTTPException(status_code=400, detail="Pedimento ID mismatch") + + decrementable = PedimentoDecrementablesService.create( + db, data, tenant_id, company_id + ) + return decrementable + + +@router.put("/{decrementable_id}", response_model=PedimentoDecrementablesResponse) +async def update_decrementable( + pedimento_id: int, + decrementable_id: int, + data: PedimentoDecrementablesUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Update a decrementable""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + decrementable = PedimentoDecrementablesService.update( + db, decrementable_id, pedimento_id, tenant_id, company_id, data + ) + if not decrementable: + raise HTTPException(status_code=404, detail="Decrementable not found") + + return decrementable + + +@router.delete("/{decrementable_id}", status_code=204) +async def delete_decrementable( + pedimento_id: int, + decrementable_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a decrementable""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + success = PedimentoDecrementablesService.delete( + db, decrementable_id, pedimento_id, tenant_id, company_id + ) + if not success: + raise HTTPException(status_code=404, detail="Decrementable not found") + + return None diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_incrementables.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_incrementables.py new file mode 100644 index 00000000..fcfc2778 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_incrementables.py @@ -0,0 +1,117 @@ +""" +Routes for PedimentoIncrementables CRUD operations +""" + +from typing import Any, Dict, List + +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 ..dtos.pedimento_incrementables import ( + PedimentoIncrementablesCreate, + PedimentoIncrementablesResponse, + PedimentoIncrementablesUpdate, +) +from ..services.pedimento_incrementables import PedimentoIncrementablesService + +router = APIRouter(prefix="/{pedimento_id}/incrementables") + + +@router.get("/", response_model=List[PedimentoIncrementablesResponse]) +async def list_incrementables( + pedimento_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all incrementables for a pedimento""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + incrementables = PedimentoIncrementablesService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + return incrementables + + +@router.get("/{incrementable_id}", response_model=PedimentoIncrementablesResponse) +async def get_incrementable( + pedimento_id: int, + incrementable_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get a specific incrementable by ID""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + incrementable = PedimentoIncrementablesService.get_by_id( + db, incrementable_id, pedimento_id, tenant_id, company_id + ) + if not incrementable: + raise HTTPException(status_code=404, detail="Incrementable not found") + + return incrementable + + +@router.post("/", response_model=PedimentoIncrementablesResponse, status_code=201) +async def create_incrementable( + pedimento_id: int, + data: PedimentoIncrementablesCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Create a new incrementable""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Ensure pedimento_id matches + if data.pedimento_id != pedimento_id: + raise HTTPException(status_code=400, detail="Pedimento ID mismatch") + + incrementable = PedimentoIncrementablesService.create( + db, data, tenant_id, company_id + ) + return incrementable + + +@router.put("/{incrementable_id}", response_model=PedimentoIncrementablesResponse) +async def update_incrementable( + pedimento_id: int, + incrementable_id: int, + data: PedimentoIncrementablesUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Update an incrementable""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + incrementable = PedimentoIncrementablesService.update( + db, incrementable_id, pedimento_id, tenant_id, company_id, data + ) + if not incrementable: + raise HTTPException(status_code=404, detail="Incrementable not found") + + return incrementable + + +@router.delete("/{incrementable_id}", status_code=204) +async def delete_incrementable( + pedimento_id: int, + incrementable_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete an incrementable""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + success = PedimentoIncrementablesService.delete( + db, incrementable_id, pedimento_id, tenant_id, company_id + ) + if not success: + raise HTTPException(status_code=404, detail="Incrementable not found") + + return None diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_indexes.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_indexes.py new file mode 100644 index 00000000..c808b559 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_indexes.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoIndexes CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_indexes import ( + PedimentoIndexesCreate, + PedimentoIndexesResponse, + PedimentoIndexesUpdate, +) +from ..services.pedimento_indexes import PedimentoIndexesService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoIndexesService, + create_schema=PedimentoIndexesCreate, + update_schema=PedimentoIndexesUpdate, + response_schema=PedimentoIndexesResponse, + prefix="/{pedimento_id}/indexes", + tags=[], + resource_name="Pedimento indexes", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_payments.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_payments.py new file mode 100644 index 00000000..7078e26f --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_payments.py @@ -0,0 +1,115 @@ +""" +Routes for PedimentoPayments CRUD operations +""" + +from typing import Any, Dict, List + +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 ..dtos.pedimento_payments import ( + PedimentoPaymentsCreate, + PedimentoPaymentsResponse, + PedimentoPaymentsUpdate, +) +from ..services.pedimento_payments import PedimentoPaymentsService + +router = APIRouter(prefix="/{pedimento_id}/payments") + + +@router.get("/", response_model=List[PedimentoPaymentsResponse]) +async def list_payments( + pedimento_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all payments for a pedimento""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + payments = PedimentoPaymentsService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + return payments + + +@router.get("/{id}", response_model=PedimentoPaymentsResponse) +async def get_payment( + pedimento_id: int, + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get a specific payment by ID""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + payment = PedimentoPaymentsService.get_by_id( + db, id, pedimento_id, tenant_id, company_id + ) + if not payment: + raise HTTPException(status_code=404, detail="Payment not found") + + return payment + + +@router.post("/", response_model=PedimentoPaymentsResponse, status_code=201) +async def create_payment( + pedimento_id: int, + data: PedimentoPaymentsCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Create a new payment""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Ensure pedimento_id matches + if data.pedimento_id != pedimento_id: + raise HTTPException(status_code=400, detail="Pedimento ID mismatch") + + payment = PedimentoPaymentsService.create(db, data, tenant_id, company_id) + return payment + + +@router.put("/{id}", response_model=PedimentoPaymentsResponse) +async def update_payment( + pedimento_id: int, + id: int, + data: PedimentoPaymentsUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Update a payment""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + payment = PedimentoPaymentsService.update( + db, id, pedimento_id, tenant_id, company_id, data + ) + if not payment: + raise HTTPException(status_code=404, detail="Payment not found") + + return payment + + +@router.delete("/{id}", status_code=204) +async def delete_payment( + pedimento_id: int, + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a payment""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + success = PedimentoPaymentsService.delete( + db, id, pedimento_id, tenant_id, company_id + ) + if not success: + raise HTTPException(status_code=404, detail="Payment not found") + + return None diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_rectification_destination.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_rectification_destination.py new file mode 100644 index 00000000..59dec341 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_rectification_destination.py @@ -0,0 +1,28 @@ +""" +Routes for PedimentoRectificationDestination CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_rectification_destination import ( + PedimentoRectificationDestinationCreate, + PedimentoRectificationDestinationResponse, + PedimentoRectificationDestinationUpdate, +) +from ..services.pedimento_rectification_destination import ( + PedimentoRectificationDestinationService, +) + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoRectificationDestinationService, + create_schema=PedimentoRectificationDestinationCreate, + update_schema=PedimentoRectificationDestinationUpdate, + response_schema=PedimentoRectificationDestinationResponse, + prefix="/{pedimento_id}/rectification-destination", + tags=[], + resource_name="Rectification destination", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_rectification_origin.py new file mode 100644 index 00000000..0b20fce4 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_rectification_origin.py @@ -0,0 +1,28 @@ +""" +Routes for PedimentoRectificationOrigin CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_rectification_origin import ( + PedimentoRectificationOriginCreate, + PedimentoRectificationOriginResponse, + PedimentoRectificationOriginUpdate, +) +from ..services.pedimento_rectification_origin import ( + PedimentoRectificationOriginService, +) + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoRectificationOriginService, + create_schema=PedimentoRectificationOriginCreate, + update_schema=PedimentoRectificationOriginUpdate, + response_schema=PedimentoRectificationOriginResponse, + prefix="/{pedimento_id}/rectification-origin", + tags=[], + resource_name="Rectification origin", + parent_id_name="pedimento_id", + enable_list=False, + validate_parent_match=True, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_transport_means.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_transport_means.py new file mode 100644 index 00000000..f3015a49 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_transport_means.py @@ -0,0 +1,117 @@ +""" +Routes for PedimentoTransportMeans CRUD operations +""" + +from typing import Any, Dict, List + +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 ..dtos.pedimento_transport_means import ( + PedimentoTransportMeansCreate, + PedimentoTransportMeansResponse, + PedimentoTransportMeansUpdate, +) +from ..services.pedimento_transport_means import PedimentoTransportMeansService + +router = APIRouter(prefix="/{pedimento_id}/transport-means") + + +@router.get("/", response_model=List[PedimentoTransportMeansResponse]) +async def list_transport_means( + pedimento_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get all transport means for a pedimento""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + transport_means = PedimentoTransportMeansService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + return transport_means + + +@router.get("/{transport_mean_id}", response_model=PedimentoTransportMeansResponse) +async def get_transport_mean( + pedimento_id: int, + transport_mean_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Get a specific transport mean by ID""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + transport_mean = PedimentoTransportMeansService.get_by_id( + db, transport_mean_id, pedimento_id, tenant_id, company_id + ) + if not transport_mean: + raise HTTPException(status_code=404, detail="Transport mean not found") + + return transport_mean + + +@router.post("/", response_model=PedimentoTransportMeansResponse, status_code=201) +async def create_transport_mean( + pedimento_id: int, + data: PedimentoTransportMeansCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Create a new transport mean""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Ensure pedimento_id matches + if data.pedimento_id != pedimento_id: + raise HTTPException(status_code=400, detail="Pedimento ID mismatch") + + transport_mean = PedimentoTransportMeansService.create( + db, data, tenant_id, company_id + ) + return transport_mean + + +@router.put("/{transport_mean_id}", response_model=PedimentoTransportMeansResponse) +async def update_transport_mean( + pedimento_id: int, + transport_mean_id: int, + data: PedimentoTransportMeansUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Update a transport mean""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + transport_mean = PedimentoTransportMeansService.update( + db, transport_mean_id, pedimento_id, tenant_id, company_id, data + ) + if not transport_mean: + raise HTTPException(status_code=404, detail="Transport mean not found") + + return transport_mean + + +@router.delete("/{transport_mean_id}", status_code=204) +async def delete_transport_mean( + pedimento_id: int, + transport_mean_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """Delete a transport mean""" + tenant_id = validate_access_to_resource(db, company_id, current_user) + + success = PedimentoTransportMeansService.delete( + db, transport_mean_id, pedimento_id, tenant_id, company_id + ) + if not success: + raise HTTPException(status_code=404, detail="Transport mean not found") + + return None diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimento_validation.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_validation.py new file mode 100644 index 00000000..47af93a5 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimento_validation.py @@ -0,0 +1,26 @@ +""" +Routes for PedimentoValidation CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimento_validation import ( + PedimentoValidationCreate, + PedimentoValidationResponse, + PedimentoValidationUpdate, +) +from ..services.pedimento_validation import PedimentoValidationService + +# Create router with generic CRUD routes for child resource +router = TenantCRUDRoutes( + service=PedimentoValidationService, + create_schema=PedimentoValidationCreate, + update_schema=PedimentoValidationUpdate, + response_schema=PedimentoValidationResponse, + prefix="/{pedimento_id}/validation", + tags=[], + resource_name="Pedimento validation", + parent_id_name="pedimento_id", + enable_list=False, # Child resource - no list endpoint + validate_parent_match=True, # Validate pedimento_id matches in create +).router diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py new file mode 100644 index 00000000..d4edfaee --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py @@ -0,0 +1,24 @@ +""" +Routes for Pedimentos CRUD operations +""" + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from ..dtos.pedimentos import PedimentosCreate, PedimentosResponse, PedimentosUpdate +from ..services.pedimentos import PedimentosService + +# Create router with generic CRUD routes +router = TenantCRUDRoutes( + service=PedimentosService, + create_schema=PedimentosCreate, + update_schema=PedimentosUpdate, + response_schema=PedimentosResponse, + prefix="", # No prefix here, will be added in main router + tags=["a76 / pedimentos"], # Tag for Swagger documentation + resource_name="Pedimento", + id_name="pedimento_id", + enable_list=True, # Enable GET / with pagination + enable_filters=True, # Enable status, client_id, year filters + default_page_size=50, + max_page_size=100, +).router diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_additional.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_additional.py new file mode 100644 index 00000000..5f3a8141 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_additional.py @@ -0,0 +1,85 @@ +""" +Service layer for PedimentoConfigAdditional CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_config_additional import ( + PedimentoConfigAdditionalCreate, + PedimentoConfigAdditionalUpdate, +) +from ..models.pedimento_config_additional import PedimentoConfigAdditional + + +class PedimentoConfigAdditionalService: + """Service class for PedimentoConfigAdditional business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int, company_id: int + ) -> Optional[PedimentoConfigAdditional]: + """Get config by pedimento ID""" + return ( + db.query(PedimentoConfigAdditional) + .filter( + PedimentoConfigAdditional.pedimento_id == pedimento_id, + PedimentoConfigAdditional.tenant_id == tenant_id, + PedimentoConfigAdditional.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + config_data: PedimentoConfigAdditionalCreate, + tenant_id: int, + company_id: int, + ) -> PedimentoConfigAdditional: + """Create a new config""" + config = PedimentoConfigAdditional(**config_data.model_dump()) + config.tenant_id = tenant_id + config.company_id = company_id + + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + company_id: int, + config_data: PedimentoConfigAdditionalUpdate, + ) -> Optional[PedimentoConfigAdditional]: + """Update config""" + config = PedimentoConfigAdditionalService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + if not config: + return None + + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + return config + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int, company_id: int) -> bool: + """Delete config""" + config = PedimentoConfigAdditionalService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + if not config: + return False + + db.delete(config) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_calculations.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_calculations.py new file mode 100644 index 00000000..50dbaeb0 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_calculations.py @@ -0,0 +1,85 @@ +""" +Service layer for PedimentoConfigCalculations CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_config_calculations import ( + PedimentoConfigCalculationsCreate, + PedimentoConfigCalculationsUpdate, +) +from ..models.pedimento_config_calculations import PedimentoConfigCalculations + + +class PedimentoConfigCalculationsService: + """Service class for PedimentoConfigCalculations business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int, company_id: int + ) -> Optional[PedimentoConfigCalculations]: + """Get config by pedimento ID""" + return ( + db.query(PedimentoConfigCalculations) + .filter( + PedimentoConfigCalculations.pedimento_id == pedimento_id, + PedimentoConfigCalculations.tenant_id == tenant_id, + PedimentoConfigCalculations.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + config_data: PedimentoConfigCalculationsCreate, + tenant_id: int, + company_id: int, + ) -> PedimentoConfigCalculations: + """Create a new config""" + config = PedimentoConfigCalculations(**config_data.model_dump()) + config.tenant_id = tenant_id + config.company_id = company_id + + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + company_id: int, + config_data: PedimentoConfigCalculationsUpdate, + ) -> Optional[PedimentoConfigCalculations]: + """Update config""" + config = PedimentoConfigCalculationsService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + if not config: + return None + + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + return config + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int, company_id: int) -> bool: + """Delete config""" + config = PedimentoConfigCalculationsService.get_by_pedimento_id( + db, pedimento_id, tenant_id, company_id + ) + if not config: + return False + + db.delete(config) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_parameters.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_parameters.py new file mode 100644 index 00000000..b3a7fab5 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_parameters.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoConfigParameters CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_config_parameters import ( + PedimentoConfigParametersCreate, + PedimentoConfigParametersUpdate, +) +from ..models.pedimento_config_parameters import PedimentoConfigParameters + + +class PedimentoConfigParametersService: + """Service class for PedimentoConfigParameters business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoConfigParameters]: + """Get config by pedimento ID""" + return ( + db.query(PedimentoConfigParameters) + .filter( + PedimentoConfigParameters.pedimento_id == pedimento_id, + PedimentoConfigParameters.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, config_data: PedimentoConfigParametersCreate + ) -> PedimentoConfigParameters: + """Create a new config""" + config = PedimentoConfigParameters(**config_data.model_dump()) + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + config_data: PedimentoConfigParametersUpdate, + ) -> Optional[PedimentoConfigParameters]: + """Update config""" + config = PedimentoConfigParametersService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return None + + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + return config + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete config""" + config = PedimentoConfigParametersService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return False + + db.delete(config) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_surcharges.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_surcharges.py new file mode 100644 index 00000000..95f15752 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_surcharges.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoConfigSurcharges CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_config_surcharges import ( + PedimentoConfigSurchargesCreate, + PedimentoConfigSurchargesUpdate, +) +from ..models.pedimento_config_surcharges import PedimentoConfigSurcharges + + +class PedimentoConfigSurchargesService: + """Service class for PedimentoConfigSurcharges business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoConfigSurcharges]: + """Get config by pedimento ID""" + return ( + db.query(PedimentoConfigSurcharges) + .filter( + PedimentoConfigSurcharges.pedimento_id == pedimento_id, + PedimentoConfigSurcharges.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, config_data: PedimentoConfigSurchargesCreate + ) -> PedimentoConfigSurcharges: + """Create a new config""" + config = PedimentoConfigSurcharges(**config_data.model_dump()) + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + config_data: PedimentoConfigSurchargesUpdate, + ) -> Optional[PedimentoConfigSurcharges]: + """Update config""" + config = PedimentoConfigSurchargesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return None + + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + return config + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete config""" + config = PedimentoConfigSurchargesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return False + + db.delete(config) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_update_rectification.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_update_rectification.py new file mode 100644 index 00000000..27fb724a --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_update_rectification.py @@ -0,0 +1,79 @@ +""" +Service layer for PedimentoConfigUpdateRectification CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_config_update_rectification import ( + PedimentoConfigUpdateRectificationCreate, + PedimentoConfigUpdateRectificationUpdate, +) +from ..models.pedimento_config_update_rectification import ( + PedimentoConfigUpdateRectification, +) + + +class PedimentoConfigUpdateRectificationService: + """Service class for PedimentoConfigUpdateRectification business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoConfigUpdateRectification]: + """Get config by pedimento ID""" + return ( + db.query(PedimentoConfigUpdateRectification) + .filter( + PedimentoConfigUpdateRectification.pedimento_id == pedimento_id, + PedimentoConfigUpdateRectification.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, config_data: PedimentoConfigUpdateRectificationCreate + ) -> PedimentoConfigUpdateRectification: + """Create a new config""" + config = PedimentoConfigUpdateRectification(**config_data.model_dump()) + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + config_data: PedimentoConfigUpdateRectificationUpdate, + ) -> Optional[PedimentoConfigUpdateRectification]: + """Update config""" + config = PedimentoConfigUpdateRectificationService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return None + + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + return config + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete config""" + config = PedimentoConfigUpdateRectificationService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return False + + db.delete(config) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_updates.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_updates.py new file mode 100644 index 00000000..b602f8f6 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_config_updates.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoConfigUpdates CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_config_updates import ( + PedimentoConfigUpdatesCreate, + PedimentoConfigUpdatesUpdate, +) +from ..models.pedimento_config_updates import PedimentoConfigUpdates + + +class PedimentoConfigUpdatesService: + """Service class for PedimentoConfigUpdates business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoConfigUpdates]: + """Get config by pedimento ID""" + return ( + db.query(PedimentoConfigUpdates) + .filter( + PedimentoConfigUpdates.pedimento_id == pedimento_id, + PedimentoConfigUpdates.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, config_data: PedimentoConfigUpdatesCreate + ) -> PedimentoConfigUpdates: + """Create a new config""" + config = PedimentoConfigUpdates(**config_data.model_dump()) + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + config_data: PedimentoConfigUpdatesUpdate, + ) -> Optional[PedimentoConfigUpdates]: + """Update config""" + config = PedimentoConfigUpdatesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return None + + update_data = config_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(config, field, value) + + db.commit() + db.refresh(config) + return config + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete config""" + config = PedimentoConfigUpdatesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not config: + return False + + db.delete(config) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_customs_offices.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_customs_offices.py new file mode 100644 index 00000000..8f8db39e --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_customs_offices.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoCustomsOffices CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_customs_offices import ( + PedimentoCustomsOfficesCreate, + PedimentoCustomsOfficesUpdate, +) +from ..models.pedimento_customs_offices import PedimentoCustomsOffices + + +class PedimentoCustomsOfficesService: + """Service class for PedimentoCustomsOffices business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoCustomsOffices]: + """Get customs offices by pedimento ID""" + return ( + db.query(PedimentoCustomsOffices) + .filter( + PedimentoCustomsOffices.pedimento_id == pedimento_id, + PedimentoCustomsOffices.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, data: PedimentoCustomsOfficesCreate + ) -> PedimentoCustomsOffices: + """Create new customs offices""" + offices = PedimentoCustomsOffices(**data.model_dump()) + db.add(offices) + db.commit() + db.refresh(offices) + return offices + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + data: PedimentoCustomsOfficesUpdate, + ) -> Optional[PedimentoCustomsOffices]: + """Update customs offices""" + offices = PedimentoCustomsOfficesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not offices: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(offices, field, value) + + db.commit() + db.refresh(offices) + return offices + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete customs offices""" + offices = PedimentoCustomsOfficesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not offices: + return False + + db.delete(offices) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_dates.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_dates.py new file mode 100644 index 00000000..59e6c75a --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_dates.py @@ -0,0 +1,68 @@ +""" +Service layer for PedimentoDates CRUD operations +""" + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_dates import PedimentoDatesCreate, PedimentoDatesUpdate +from ..models.pedimento_dates import PedimentoDates + +logger = logging.getLogger(__name__) + + +class PedimentoDatesService: + """Service class for PedimentoDates business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoDates]: + """Get dates by pedimento ID""" + return ( + db.query(PedimentoDates) + .filter( + PedimentoDates.pedimento_id == pedimento_id, + PedimentoDates.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create(db: Session, data: PedimentoDatesCreate) -> PedimentoDates: + """Create new pedimento dates""" + dates = PedimentoDates(**data.model_dump()) + db.add(dates) + db.commit() + db.refresh(dates) + return dates + + @staticmethod + def update( + db: Session, pedimento_id: int, tenant_id: int, data: PedimentoDatesUpdate + ) -> Optional[PedimentoDates]: + """Update pedimento dates""" + dates = PedimentoDatesService.get_by_pedimento_id(db, pedimento_id, tenant_id) + if not dates: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(dates, field, value) + + db.commit() + db.refresh(dates) + return dates + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete pedimento dates""" + dates = PedimentoDatesService.get_by_pedimento_id(db, pedimento_id, tenant_id) + if not dates: + return False + + db.delete(dates) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_decrementables.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_decrementables.py new file mode 100644 index 00000000..73b7d690 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_decrementables.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoDecrementables CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_decrementables import ( + PedimentoDecrementablesCreate, + PedimentoDecrementablesUpdate, +) +from ..models.pedimento_decrementables import PedimentoDecrementables + + +class PedimentoDecrementablesService: + """Service class for PedimentoDecrementables business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoDecrementables]: + """Get decrementables by pedimento ID""" + return ( + db.query(PedimentoDecrementables) + .filter( + PedimentoDecrementables.pedimento_id == pedimento_id, + PedimentoDecrementables.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, data: PedimentoDecrementablesCreate + ) -> PedimentoDecrementables: + """Create new decrementables""" + decrementables = PedimentoDecrementables(**data.model_dump()) + db.add(decrementables) + db.commit() + db.refresh(decrementables) + return decrementables + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + data: PedimentoDecrementablesUpdate, + ) -> Optional[PedimentoDecrementables]: + """Update decrementables""" + decrementables = PedimentoDecrementablesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not decrementables: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(decrementables, field, value) + + db.commit() + db.refresh(decrementables) + return decrementables + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete decrementables""" + decrementables = PedimentoDecrementablesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not decrementables: + return False + + db.delete(decrementables) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_incrementables.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_incrementables.py new file mode 100644 index 00000000..2cdffadc --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_incrementables.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoIncrementables CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_incrementables import ( + PedimentoIncrementablesCreate, + PedimentoIncrementablesUpdate, +) +from ..models.pedimento_incrementables import PedimentoIncrementables + + +class PedimentoIncrementablesService: + """Service class for PedimentoIncrementables business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoIncrementables]: + """Get incrementables by pedimento ID""" + return ( + db.query(PedimentoIncrementables) + .filter( + PedimentoIncrementables.pedimento_id == pedimento_id, + PedimentoIncrementables.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, data: PedimentoIncrementablesCreate + ) -> PedimentoIncrementables: + """Create new incrementables""" + incrementables = PedimentoIncrementables(**data.model_dump()) + db.add(incrementables) + db.commit() + db.refresh(incrementables) + return incrementables + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + data: PedimentoIncrementablesUpdate, + ) -> Optional[PedimentoIncrementables]: + """Update incrementables""" + incrementables = PedimentoIncrementablesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not incrementables: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(incrementables, field, value) + + db.commit() + db.refresh(incrementables) + return incrementables + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete incrementables""" + incrementables = PedimentoIncrementablesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not incrementables: + return False + + db.delete(incrementables) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_indexes.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_indexes.py new file mode 100644 index 00000000..f671a27c --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_indexes.py @@ -0,0 +1,69 @@ +""" +Service layer for PedimentoIndexes CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_indexes import PedimentoIndexesCreate, PedimentoIndexesUpdate +from ..models.pedimento_indexes import PedimentoIndexes + + +class PedimentoIndexesService: + """Service class for PedimentoIndexes business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoIndexes]: + """Get indexes by pedimento ID""" + return ( + db.query(PedimentoIndexes) + .filter( + PedimentoIndexes.pedimento_id == pedimento_id, + PedimentoIndexes.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create(db: Session, data: PedimentoIndexesCreate) -> PedimentoIndexes: + """Create new indexes""" + indexes = PedimentoIndexes(**data.model_dump()) + db.add(indexes) + db.commit() + db.refresh(indexes) + return indexes + + @staticmethod + def update( + db: Session, pedimento_id: int, tenant_id: int, data: PedimentoIndexesUpdate + ) -> Optional[PedimentoIndexes]: + """Update indexes""" + indexes = PedimentoIndexesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not indexes: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(indexes, field, value) + + db.commit() + db.refresh(indexes) + return indexes + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete indexes""" + indexes = PedimentoIndexesService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not indexes: + return False + + db.delete(indexes) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_payments.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_payments.py new file mode 100644 index 00000000..1b56e2b9 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_payments.py @@ -0,0 +1,69 @@ +""" +Service layer for PedimentoPayments CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_payments import PedimentoPaymentsCreate, PedimentoPaymentsUpdate +from ..models.pedimento_payments import PedimentoPayments + + +class PedimentoPaymentsService: + """Service class for PedimentoPayments business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoPayments]: + """Get payments by pedimento ID""" + return ( + db.query(PedimentoPayments) + .filter( + PedimentoPayments.pedimento_id == pedimento_id, + PedimentoPayments.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create(db: Session, data: PedimentoPaymentsCreate) -> PedimentoPayments: + """Create new payments""" + payments = PedimentoPayments(**data.model_dump()) + db.add(payments) + db.commit() + db.refresh(payments) + return payments + + @staticmethod + def update( + db: Session, pedimento_id: int, tenant_id: int, data: PedimentoPaymentsUpdate + ) -> Optional[PedimentoPayments]: + """Update payments""" + payments = PedimentoPaymentsService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not payments: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(payments, field, value) + + db.commit() + db.refresh(payments) + return payments + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete payments""" + payments = PedimentoPaymentsService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not payments: + return False + + db.delete(payments) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py new file mode 100644 index 00000000..de32adfc --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py @@ -0,0 +1,79 @@ +""" +Service layer for PedimentoRectificationDestination CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_rectification_destination import ( + PedimentoRectificationDestinationCreate, + PedimentoRectificationDestinationUpdate, +) +from ..models.pedimento_rectification_destination import ( + PedimentoRectificationDestination, +) + + +class PedimentoRectificationDestinationService: + """Service class for PedimentoRectificationDestination business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoRectificationDestination]: + """Get rectification destination by pedimento ID""" + return ( + db.query(PedimentoRectificationDestination) + .filter( + PedimentoRectificationDestination.pedimento_id == pedimento_id, + PedimentoRectificationDestination.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, data: PedimentoRectificationDestinationCreate + ) -> PedimentoRectificationDestination: + """Create new rectification destination""" + rectification = PedimentoRectificationDestination(**data.model_dump()) + db.add(rectification) + db.commit() + db.refresh(rectification) + return rectification + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + data: PedimentoRectificationDestinationUpdate, + ) -> Optional[PedimentoRectificationDestination]: + """Update rectification destination""" + rectification = PedimentoRectificationDestinationService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not rectification: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(rectification, field, value) + + db.commit() + db.refresh(rectification) + return rectification + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete rectification destination""" + rectification = PedimentoRectificationDestinationService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not rectification: + return False + + db.delete(rectification) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py new file mode 100644 index 00000000..080bec9f --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoRectificationOrigin CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_rectification_origin import ( + PedimentoRectificationOriginCreate, + PedimentoRectificationOriginUpdate, +) +from ..models.pedimento_rectification_origin import PedimentoRectificationOrigin + + +class PedimentoRectificationOriginService: + """Service class for PedimentoRectificationOrigin business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoRectificationOrigin]: + """Get rectification origin by pedimento ID""" + return ( + db.query(PedimentoRectificationOrigin) + .filter( + PedimentoRectificationOrigin.pedimento_id == pedimento_id, + PedimentoRectificationOrigin.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, data: PedimentoRectificationOriginCreate + ) -> PedimentoRectificationOrigin: + """Create new rectification origin""" + rectification = PedimentoRectificationOrigin(**data.model_dump()) + db.add(rectification) + db.commit() + db.refresh(rectification) + return rectification + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + data: PedimentoRectificationOriginUpdate, + ) -> Optional[PedimentoRectificationOrigin]: + """Update rectification origin""" + rectification = PedimentoRectificationOriginService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not rectification: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(rectification, field, value) + + db.commit() + db.refresh(rectification) + return rectification + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete rectification origin""" + rectification = PedimentoRectificationOriginService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not rectification: + return False + + db.delete(rectification) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_transport_means.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_transport_means.py new file mode 100644 index 00000000..ae88a3d1 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_transport_means.py @@ -0,0 +1,77 @@ +""" +Service layer for PedimentoTransportMeans CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_transport_means import ( + PedimentoTransportMeansCreate, + PedimentoTransportMeansUpdate, +) +from ..models.pedimento_transport_means import PedimentoTransportMeans + + +class PedimentoTransportMeansService: + """Service class for PedimentoTransportMeans business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoTransportMeans]: + """Get transport means by pedimento ID""" + return ( + db.query(PedimentoTransportMeans) + .filter( + PedimentoTransportMeans.pedimento_id == pedimento_id, + PedimentoTransportMeans.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, data: PedimentoTransportMeansCreate + ) -> PedimentoTransportMeans: + """Create new transport means""" + transport = PedimentoTransportMeans(**data.model_dump()) + db.add(transport) + db.commit() + db.refresh(transport) + return transport + + @staticmethod + def update( + db: Session, + pedimento_id: int, + tenant_id: int, + data: PedimentoTransportMeansUpdate, + ) -> Optional[PedimentoTransportMeans]: + """Update transport means""" + transport = PedimentoTransportMeansService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not transport: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(transport, field, value) + + db.commit() + db.refresh(transport) + return transport + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete transport means""" + transport = PedimentoTransportMeansService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not transport: + return False + + db.delete(transport) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_validation.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_validation.py new file mode 100644 index 00000000..ecb17148 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_validation.py @@ -0,0 +1,72 @@ +""" +Service layer for PedimentoValidation CRUD operations +""" + +from typing import Optional + +from sqlalchemy.orm import Session + +from ..dtos.pedimento_validation import ( + PedimentoValidationCreate, + PedimentoValidationUpdate, +) +from ..models.pedimento_validation import PedimentoValidation + + +class PedimentoValidationService: + """Service class for PedimentoValidation business logic""" + + @staticmethod + def get_by_pedimento_id( + db: Session, pedimento_id: int, tenant_id: int + ) -> Optional[PedimentoValidation]: + """Get validation by pedimento ID""" + return ( + db.query(PedimentoValidation) + .filter( + PedimentoValidation.pedimento_id == pedimento_id, + PedimentoValidation.tenant_id == tenant_id, + ) + .first() + ) + + @staticmethod + def create(db: Session, data: PedimentoValidationCreate) -> PedimentoValidation: + """Create new validation""" + validation = PedimentoValidation(**data.model_dump()) + db.add(validation) + db.commit() + db.refresh(validation) + return validation + + @staticmethod + def update( + db: Session, pedimento_id: int, tenant_id: int, data: PedimentoValidationUpdate + ) -> Optional[PedimentoValidation]: + """Update validation""" + validation = PedimentoValidationService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not validation: + return None + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(validation, field, value) + + db.commit() + db.refresh(validation) + return validation + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int) -> bool: + """Delete validation""" + validation = PedimentoValidationService.get_by_pedimento_id( + db, pedimento_id, tenant_id + ) + if not validation: + return False + + db.delete(validation) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py new file mode 100644 index 00000000..d6506226 --- /dev/null +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -0,0 +1,410 @@ +""" +Service layer for Pedimentos CRUD operations +""" + +import logging +from typing import Any, Dict, List, Optional + +from sqlalchemy import desc +from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import selectinload +from datetime import datetime + +from ..dtos.pedimentos import PedimentosCreate, PedimentosUpdate + +from .pedimento_config_additional import PedimentoConfigAdditionalService +from .pedimento_config_calculations import PedimentoConfigCalculationsService +from .pedimento_config_parameters import PedimentoConfigParametersService +from .pedimento_config_surcharges import PedimentoConfigSurchargesService +from .pedimento_config_update_rectification import PedimentoConfigUpdateRectificationService +from .pedimento_config_updates import PedimentoConfigUpdatesService +from .pedimento_customs_offices import PedimentoCustomsOfficesService +from .pedimento_dates import PedimentoDatesService +from .pedimento_decrementables import PedimentoDecrementablesService +from .pedimento_incrementables import PedimentoIncrementablesService +from .pedimento_indexes import PedimentoIndexesService +from .pedimento_payments import PedimentoPaymentsService +from .pedimento_rectification_destination import PedimentoRectificationDestinationService +from .pedimento_rectification_origin import PedimentoRectificationOriginService +from .pedimento_transport_means import PedimentoTransportMeansService +from .pedimento_validation import PedimentoValidationService + +# Crear tablas relacionadas si existen datos +from ..models.pedimentos import Pedimentos +from ..models.pedimento_dates import PedimentoDates +from ..models.pedimento_decrementables import PedimentoDecrementables +from ..models.pedimento_incrementables import PedimentoIncrementables +from ..models.pedimento_indexes import PedimentoIndexes +from ..models.pedimento_validation import PedimentoValidation +from ..models.pedimento_customs_offices import PedimentoCustomsOffices +from ..models.pedimento_payments import PedimentoPayments +from ..models.pedimento_rectification_destination import PedimentoRectificationDestination +from ..models.pedimento_rectification_origin import PedimentoRectificationOrigin +from ..models.pedimento_transport_means import PedimentoTransportMeans +from ..models.pedimento_config_additional import PedimentoConfigAdditional +from ..models.pedimento_config_calculations import PedimentoConfigCalculations +from ..models.pedimento_config_parameters import PedimentoConfigParameters +from ..models.pedimento_config_surcharges import PedimentoConfigSurcharges +from ..models.pedimento_config_update_rectification import PedimentoConfigUpdateRectification +from ..models.pedimento_config_updates import PedimentoConfigUpdates + +logger = logging.getLogger(__name__) + + +class PedimentosService: + """Service class for Pedimentos business logic""" + + @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[Pedimentos], int]: + """ + Get all pedimentos for a tenant with pagination and filters + + Args: + db: Database session + tenant_id: Tenant ID + skip: Number of records to skip + limit: Maximum number of records to return + filters: Optional filters dict + + Returns: + Tuple of (list of pedimentos, total count) + """ + query = db.query(Pedimentos).filter( + Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id) + + if filters: + if filters.get("status"): + query = query.filter(Pedimentos.status == filters["status"]) + if filters.get("client_id"): + query = query.filter( + Pedimentos.client_id == filters["client_id"]) + if filters.get("year"): + query = query.filter(Pedimentos.year == filters["year"]) + + total = query.count() + + # Eager load all relationships for the response schema + items = ( + query.options( + selectinload(Pedimentos.pedimento_dates), + selectinload(Pedimentos.pedimento_decrementables), + selectinload(Pedimentos.pedimento_incrementables), + selectinload(Pedimentos.pedimento_indexes), + selectinload(Pedimentos.pedimento_validation), + selectinload(Pedimentos.pedimento_customs_offices), + selectinload(Pedimentos.pedimento_payments), + selectinload(Pedimentos.pedimento_rectification_destination), + selectinload(Pedimentos.pedimento_rectification_origin), + selectinload(Pedimentos.pedimento_transport_means), + selectinload(Pedimentos.pedimento_config_additional), + selectinload(Pedimentos.pedimento_config_calculations), + selectinload(Pedimentos.pedimento_config_parameters), + selectinload(Pedimentos.pedimento_config_surcharges), + selectinload(Pedimentos.pedimento_config_update_rectification), + selectinload(Pedimentos.pedimento_config_updates), + ) + .order_by(desc(Pedimentos.created_at)) + .offset(skip) + .limit(limit) + .all() + ) + + return items, total + + @staticmethod + def get_by_id( + db: Session, pedimento_id: int, tenant_id: int, company_id: int = None + ) -> Optional[Pedimentos]: + """ + Get a pedimento by ID + + Args: + db: Database session + pedimento_id: Pedimento ID + tenant_id: Tenant ID + company_id: Company ID (optional for backwards compatibility) + + Returns: + Pedimento or None if not found + """ + query = db.query(Pedimentos).filter( + Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id + ) + + if company_id is not None: + query = query.filter(Pedimentos.company_id == company_id) + + # Eager load all relationships for the response schema + query = query.options( + selectinload(Pedimentos.pedimento_dates), + selectinload(Pedimentos.pedimento_decrementables), + selectinload(Pedimentos.pedimento_incrementables), + selectinload(Pedimentos.pedimento_indexes), + selectinload(Pedimentos.pedimento_validation), + selectinload(Pedimentos.pedimento_customs_offices), + selectinload(Pedimentos.pedimento_payments), + selectinload(Pedimentos.pedimento_rectification_destination), + selectinload(Pedimentos.pedimento_rectification_origin), + selectinload(Pedimentos.pedimento_transport_means), + selectinload(Pedimentos.pedimento_config_additional), + selectinload(Pedimentos.pedimento_config_calculations), + selectinload(Pedimentos.pedimento_config_parameters), + selectinload(Pedimentos.pedimento_config_surcharges), + selectinload(Pedimentos.pedimento_config_update_rectification), + selectinload(Pedimentos.pedimento_config_updates), + ) + + return query.first() + + @staticmethod + def create( + db: Session, pedimento_data: PedimentosCreate, tenant_id: int, company_id: int + ) -> Pedimentos: + """ + Create a new pedimento with related tables + + Args: + db: Database session + pedimento_data: Pedimento creation data + tenant_id: Tenant ID + company_id: Company ID + + Returns: + Created pedimento + """ + try: + # Extraer datos de tablas relacionadas + related_data = { + 'pedimento_dates': pedimento_data.pedimento_dates, + 'pedimento_decrementables': pedimento_data.pedimento_decrementables, + 'pedimento_incrementables': pedimento_data.pedimento_incrementables, + 'pedimento_indexes': pedimento_data.pedimento_indexes, + 'pedimento_validation': pedimento_data.pedimento_validation, + 'pedimento_customs_offices': pedimento_data.pedimento_customs_offices, + 'pedimento_payments': pedimento_data.pedimento_payments, + 'pedimento_rectification_destination': pedimento_data.pedimento_rectification_destination, + 'pedimento_rectification_origin': pedimento_data.pedimento_rectification_origin, + 'pedimento_transport_means': pedimento_data.pedimento_transport_means, + 'pedimento_config_additional': pedimento_data.pedimento_config_additional, + 'pedimento_config_calculations': pedimento_data.pedimento_config_calculations, + 'pedimento_config_parameters': pedimento_data.pedimento_config_parameters, + 'pedimento_config_surcharges': pedimento_data.pedimento_config_surcharges, + 'pedimento_config_update_rectification': pedimento_data.pedimento_config_update_rectification, + 'pedimento_config_updates': pedimento_data.pedimento_config_updates, + } + + # Crear pedimento principal (excluyendo relaciones) + pedimento_dict = pedimento_data.model_dump(exclude={ + 'pedimento_dates', 'pedimento_decrementables', 'pedimento_incrementables', + 'pedimento_indexes', 'pedimento_validation', 'pedimento_customs_offices', + 'pedimento_payments', 'pedimento_rectification_destination', + 'pedimento_rectification_origin', 'pedimento_transport_means', + 'pedimento_config_additional', 'pedimento_config_calculations', + 'pedimento_config_parameters', 'pedimento_config_surcharges', + 'pedimento_config_update_rectification', 'pedimento_config_updates' + }) + + pedimento = Pedimentos(**pedimento_dict) + pedimento.tenant_id = tenant_id + pedimento.company_id = company_id + + db.add(pedimento) + db.flush() # Flush para obtener el ID sin commit + + # Helper function para crear objetos relacionados + def create_related(model_class, data, extra_fields=None): + if data or extra_fields: + # Inicializar obj_dict desde data si existe, sino como dict vacío + obj_dict = data.model_dump() if data else {} + # Agregar campos extra si se proporcionan + if extra_fields: + obj_dict.update(extra_fields) + obj = model_class(**obj_dict) + obj.pedimento_id = pedimento.id + obj.tenant_id = tenant_id + obj.company_id = company_id + db.add(obj) + + # Crear PedimentoDates con capture_time automático + create_related( + PedimentoDates, + related_data['pedimento_dates'], + extra_fields={'capture_time': datetime.now().time()} + ) + create_related(PedimentoDecrementables, + related_data['pedimento_decrementables']) + create_related(PedimentoIncrementables, + related_data['pedimento_incrementables']) + create_related(PedimentoIndexes, related_data['pedimento_indexes']) + create_related(PedimentoValidation, + related_data['pedimento_validation']) + create_related(PedimentoCustomsOffices, + related_data['pedimento_customs_offices']) + create_related(PedimentoPayments, + related_data['pedimento_payments']) + create_related(PedimentoRectificationDestination, + related_data['pedimento_rectification_destination']) + create_related(PedimentoRectificationOrigin, + related_data['pedimento_rectification_origin']) + create_related(PedimentoTransportMeans, + related_data['pedimento_transport_means']) + create_related(PedimentoConfigAdditional, + related_data['pedimento_config_additional']) + create_related(PedimentoConfigCalculations, + related_data['pedimento_config_calculations']) + create_related(PedimentoConfigParameters, + related_data['pedimento_config_parameters']) + create_related(PedimentoConfigSurcharges, + related_data['pedimento_config_surcharges']) + create_related(PedimentoConfigUpdateRectification, + related_data['pedimento_config_update_rectification']) + create_related(PedimentoConfigUpdates, + related_data['pedimento_config_updates']) + + db.commit() + db.refresh(pedimento) + return pedimento + + except Exception as e: + db.rollback() + logger.error(f"Error creating pedimento with related data: {e}") + raise + + @staticmethod + def update( + db: Session, pedimento_id: int, tenant_id: int, pedimento_data: PedimentosUpdate, company_id: int = None + ) -> Optional[Pedimentos]: + """ + Update a pedimento and its related tables + + Args: + db: Database session + pedimento_id: Pedimento ID + tenant_id: Tenant ID + pedimento_data: Updated data + company_id: Company ID (optional for backwards compatibility) + + Returns: + Updated pedimento or None if not found + """ + pedimento = PedimentosService.get_by_id( + db, pedimento_id, tenant_id, company_id) + if not pedimento: + return None + + try: + # Actualizar campos principales del pedimento + update_data = pedimento_data.model_dump(exclude_unset=True, exclude={ + 'pedimento_dates', 'pedimento_decrementables', 'pedimento_incrementables', + 'pedimento_indexes', 'pedimento_validation', 'pedimento_customs_offices', + 'pedimento_payments', 'pedimento_rectification_destination', + 'pedimento_rectification_origin', 'pedimento_transport_means', + 'pedimento_config_additional', 'pedimento_config_calculations', + 'pedimento_config_parameters', 'pedimento_config_surcharges', + 'pedimento_config_update_rectification', 'pedimento_config_updates' + }) + + for field, value in update_data.items(): + setattr(pedimento, field, value) + + db.flush() + + # Helper function para actualizar o crear objetos relacionados + def update_or_create_related(service_class, model_class, data_attr): + # Obtener datos del payload completo (no solo exclude_unset) + full_data = pedimento_data.model_dump() + + if data_attr not in full_data: + return + + data = full_data[data_attr] + if not data: + return + + existing = service_class.get_by_pedimento_id( + db, pedimento_id, tenant_id) + if existing: + # Actualizar existente + for field, value in data.items(): + if hasattr(existing, field): + setattr(existing, field, value) + else: + # Crear nuevo + obj = model_class(**data) + obj.pedimento_id = pedimento_id + obj.tenant_id = tenant_id + obj.company_id = company_id + db.add(obj) + + # Actualizar o crear tablas relacionadas + update_or_create_related( + PedimentoDatesService, PedimentoDates, 'pedimento_dates') + update_or_create_related( + PedimentoDecrementablesService, PedimentoDecrementables, 'pedimento_decrementables') + update_or_create_related( + PedimentoIncrementablesService, PedimentoIncrementables, 'pedimento_incrementables') + update_or_create_related( + PedimentoIndexesService, PedimentoIndexes, 'pedimento_indexes') + update_or_create_related( + PedimentoValidationService, PedimentoValidation, 'pedimento_validation') + update_or_create_related( + PedimentoCustomsOfficesService, PedimentoCustomsOffices, 'pedimento_customs_offices') + update_or_create_related( + PedimentoPaymentsService, PedimentoPayments, 'pedimento_payments') + update_or_create_related(PedimentoRectificationDestinationService, + PedimentoRectificationDestination, 'pedimento_rectification_destination') + update_or_create_related(PedimentoRectificationOriginService, + PedimentoRectificationOrigin, 'pedimento_rectification_origin') + update_or_create_related( + PedimentoTransportMeansService, PedimentoTransportMeans, 'pedimento_transport_means') + update_or_create_related(PedimentoConfigAdditionalService, + PedimentoConfigAdditional, 'pedimento_config_additional') + update_or_create_related(PedimentoConfigCalculationsService, + PedimentoConfigCalculations, 'pedimento_config_calculations') + update_or_create_related(PedimentoConfigParametersService, + PedimentoConfigParameters, 'pedimento_config_parameters') + update_or_create_related(PedimentoConfigSurchargesService, + PedimentoConfigSurcharges, 'pedimento_config_surcharges') + update_or_create_related(PedimentoConfigUpdateRectificationService, + PedimentoConfigUpdateRectification, 'pedimento_config_update_rectification') + update_or_create_related( + PedimentoConfigUpdatesService, PedimentoConfigUpdates, 'pedimento_config_updates') + + db.commit() + db.refresh(pedimento) + return pedimento + + except Exception as e: + db.rollback() + logger.error(f"Error updating pedimento with related data: {e}") + raise + + @staticmethod + def delete(db: Session, pedimento_id: int, tenant_id: int, company_id: int = None) -> bool: + """ + Delete a pedimento + + Args: + db: Database session + pedimento_id: Pedimento ID + tenant_id: Tenant ID + company_id: Company ID (optional for backwards compatibility) + + Returns: + True if deleted, False if not found + """ + pedimento = PedimentosService.get_by_id( + db, pedimento_id, tenant_id, company_id) + if not pedimento: + return False + + db.delete(pedimento) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/permission_rule_oct/dto.py b/backend/api/v1/modules/a76/permission_rule_oct/dto.py new file mode 100644 index 00000000..6df882ab --- /dev/null +++ b/backend/api/v1/modules/a76/permission_rule_oct/dto.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class PermissionRuleOctBaseDTO(BaseModel): + permission: str = Field(..., description="Permission identifier", max_length=20) + start_date: Optional[int] = Field(None, description="Start date") + end_date: Optional[int] = Field(None, description="End date") + sector: Optional[str] = Field(None, max_length=8, description="Sector") + system: Optional[str] = Field(None, max_length=5, description="System") + + +class PermissionRuleOctCreateDTO(PermissionRuleOctBaseDTO): + """Schema for creating a permission rule OCT""" + pass + + +class PermissionRuleOctUpdateDTO(PermissionRuleOctBaseDTO): + """Schema for updating a permission rule OCT""" + permission: Optional[str] = Field(None, description="Permission identifier", max_length=20) + + +class PermissionRuleOctResponseDTO(PermissionRuleOctBaseDTO): + """Schema for permission rule OCT response""" + id: int + company_id: int + tenant_id: int + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/permission_rule_oct/models.py b/backend/api/v1/modules/a76/permission_rule_oct/models.py new file mode 100644 index 00000000..56e47d2a --- /dev/null +++ b/backend/api/v1/modules/a76/permission_rule_oct/models.py @@ -0,0 +1,33 @@ +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 + + +class PermissionRuleOct(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "permission_rule_oct" + __table_args__ = ( + PrimaryKeyConstraint("id", name="permission_rule_oct_pkey"), + UniqueConstraint( + "tenant_id", + "company_id", + "permission", + name="permission_rule_oct_permission_tenant_ukey", + ), + {"schema": "a76"}, + ) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + + permission: Mapped[str] = mapped_column(String(20)) + start_date: Mapped[Optional[int]] = mapped_column() + end_date: Mapped[Optional[int]] = mapped_column() + sector: Mapped[Optional[str]] = mapped_column(String(8)) + system: Mapped[Optional[str]] = mapped_column(String(5)) diff --git a/backend/api/v1/modules/a76/permission_rule_oct/routes.py b/backend/api/v1/modules/a76/permission_rule_oct/routes.py new file mode 100644 index 00000000..c39eecd7 --- /dev/null +++ b/backend/api/v1/modules/a76/permission_rule_oct/routes.py @@ -0,0 +1,24 @@ +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from .dto import ( + PermissionRuleOctCreateDTO, + PermissionRuleOctResponseDTO, + PermissionRuleOctUpdateDTO, +) +from .services import PermissionRuleOctService + +# Create router using TenantCRUDRoutes factory +router = TenantCRUDRoutes( + service=PermissionRuleOctService, + create_schema=PermissionRuleOctCreateDTO, + update_schema=PermissionRuleOctUpdateDTO, + response_schema=PermissionRuleOctResponseDTO, + prefix="/permission-rule-oct", + tags=[], + resource_name="Permission Rule OCT", + id_name="id", # Using numeric ID + enable_list=True, # Enable GET /permission-rule-oct with pagination + enable_filters=True, # Enable filtering by permission, sector, system + default_page_size=50, + max_page_size=100, +).router diff --git a/backend/api/v1/modules/a76/permission_rule_oct/services.py b/backend/api/v1/modules/a76/permission_rule_oct/services.py new file mode 100644 index 00000000..894c895d --- /dev/null +++ b/backend/api/v1/modules/a76/permission_rule_oct/services.py @@ -0,0 +1,114 @@ +from typing import Optional, Tuple, List, Dict, Any + +from sqlalchemy.orm import Session + +from . import dto, models + + +class PermissionRuleOctService: + """Service for PermissionRuleOct 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.PermissionRuleOct], int]: + """Get all permission rules OCT for a tenant/company with pagination""" + query = db.query(models.PermissionRuleOct).filter( + models.PermissionRuleOct.tenant_id == tenant_id, + models.PermissionRuleOct.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("permission"): + query = query.filter( + models.PermissionRuleOct.permission.ilike(f"%{filters['permission']}%") + ) + if filters.get("sector"): + query = query.filter( + models.PermissionRuleOct.sector == filters["sector"] + ) + if filters.get("system"): + query = query.filter( + models.PermissionRuleOct.system == filters["system"] + ) + + total = query.count() + permissions = query.offset(skip).limit(limit).all() + + return permissions, total + + @staticmethod + def get_by_id( + db: Session, permission_id: int, tenant_id: int, company_id: int + ) -> Optional[models.PermissionRuleOct]: + """Get permission rule OCT by ID""" + return ( + db.query(models.PermissionRuleOct) + .filter( + models.PermissionRuleOct.id == permission_id, + models.PermissionRuleOct.tenant_id == tenant_id, + models.PermissionRuleOct.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + permission_data: dto.PermissionRuleOctCreateDTO, + tenant_id: int, + company_id: int, + ) -> models.PermissionRuleOct: + """Create a new permission rule OCT""" + new_permission = models.PermissionRuleOct( + **permission_data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + db.add(new_permission) + db.commit() + db.refresh(new_permission) + return new_permission + + @staticmethod + def update( + db: Session, + permission_id: int, + tenant_id: int, + company_id: int, + permission_data: dto.PermissionRuleOctUpdateDTO, + ) -> Optional[models.PermissionRuleOct]: + """Update a permission rule OCT""" + permission = PermissionRuleOctService.get_by_id( + db, permission_id, tenant_id, company_id + ) + if not permission: + return None + + # Update fields + update_data = permission_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(permission, field, value) + + db.commit() + db.refresh(permission) + return permission + + @staticmethod + def delete( + db: Session, permission_id: int, tenant_id: int, company_id: int + ) -> bool: + """Delete a permission rule OCT""" + permission = PermissionRuleOctService.get_by_id( + db, permission_id, tenant_id, company_id + ) + if not permission: + return False + + db.delete(permission) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/permission_rule_oct/test_permission_rule_oct.py b/backend/api/v1/modules/a76/permission_rule_oct/test_permission_rule_oct.py new file mode 100644 index 00000000..b7dbdc04 --- /dev/null +++ b/backend/api/v1/modules/a76/permission_rule_oct/test_permission_rule_oct.py @@ -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_permission_rules(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/permission_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_permission_rule_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/permission_rule_oct/invalid_id", headers=headers) + assert response.status_code == 404 + + +def test_create_permission_rule_forbidden(): + response = client.post("/permission_rule_oct/", json={"rule": "Test Rule"}) + assert response.status_code in (403, 405, 404) + + +def test_update_permission_rule_forbidden(): + response = client.put("/permission_rule_oct/1", json={"rule": "Updated Rule"}) + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py new file mode 100644 index 00000000..8bfee681 --- /dev/null +++ b/backend/api/v1/modules/a76/router.py @@ -0,0 +1,94 @@ +""" +Router principal de API v1 +Agrega todos los módulos de la aplicación +""" + +from fastapi import APIRouter + +from .customs_brokers.routes import router as customs_broker_router + +# Importar routers de módulos +from .invoices.routes import router as invoices_router +from .classes import router as classes_router +from .clients_and_providers import router as client_and_provider_router +from .general_catalogs.company import router as company_router +from .country_rule_oct.routes import router as country_rule_oct_router +from .transportation.drivers.routes import router as drivers_router +from .general_catalogs.exchange_rate.routes import router as exchange_rate_router +from .general_catalogs.identifiers.routes import router as identifiers_router +from .fraction_rule_octave.routes import router as fraction_rule_octave_router +from .general_catalogs.packages.routes import router as package_router +from .general_catalogs.ports.routes import router as ports_router +from .parts import router as parts_router +from .pedmientos.router import router as pedimentos_router +from .permission_rule_oct.routes import router as permission_rule_oct_router +from .general_catalogs.seal.routes import router as seal_router +from .general_catalogs.units_of_measure.routes import router as units_of_measure_router +from .general_catalogs.concepts.routes import router as concepts_router +from .general_catalogs.customs_broker_concepts.routes import router as customs_broker_concepts_router +from .general_catalogs.classification_concepts.routes import router as classification_concepts_router +from .general_catalogs.unit_conversions.routes import router as unit_conversions_router +from .general_catalogs.equivalencies.routes import router as equivalencies_router +from .general_catalogs.multi_currency_types.routes import router as multi_currency_types_router +from .general_catalogs.inpc.routes import router as inpc_router +from .general_catalogs.legends.routes import router as legends_router +from .general_catalogs.signatures.routes import router as signatures_router +from .general_catalogs.error_catalogs.routes import router as error_catalogs_router +from .general_catalogs.doda.routes import router as doda_router +from .general_catalogs.prevalidators.routes import router as prevalidators_router +from .general_catalogs.electronic_notices.routes import router as electronic_notices_router +from .transportation.trailers.routes import router as trailers_router +from .transportation.transporters.routes import router as transporters_router +from .transportation.vehicles.routes import router as vehicles_router + +# Router principal +router = APIRouter() + +# Registrar módulos +router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"]) +router.include_router(pedimentos_router, prefix="/a76") +router.include_router( + client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"] +) +router.include_router(company_router, prefix="/a76", tags=["a76 / company"]) +router.include_router(classes_router, prefix="/a76", tags=["a76 / classes"]) +router.include_router(parts_router, prefix="/a76", tags=["a76 / parts"]) +router.include_router( + permission_rule_oct_router, prefix="/a76", tags=["a76 / permission_rule_oct"] +) +router.include_router(package_router, prefix="/a76") +router.include_router(ports_router, prefix="/a76") +router.include_router(seal_router, prefix="/a76", tags=["a76 / seal"]) +router.include_router(units_of_measure_router, prefix="/a76") +router.include_router( + fraction_rule_octave_router, prefix="/a76", tags=["a76 / fraction_rule_octave"] +) +router.include_router(identifiers_router, prefix="/a76") +router.include_router( + country_rule_oct_router, prefix="/a76", tags=["a76 / country_rule_oct"] +) +router.include_router(exchange_rate_router, prefix="/a76", + tags=["a76 / exchange_rate"]) +router.include_router(trailers_router, prefix="/a76", tags=["a76 / trailers"]) +router.include_router( + customs_broker_router, prefix="/a76", tags=["a76 / customs_broker"] +) +router.include_router(drivers_router, prefix="/a76", tags=["a76 / drivers"]) +router.include_router(transporters_router, prefix="/a76", + tags=["a76 / transporters"]) +router.include_router(vehicles_router, prefix="/a76", tags=["a76 / vehicles"]) + +# Registrar catálogos generales adicionales +router.include_router(concepts_router, prefix="/a76") +router.include_router(customs_broker_concepts_router, prefix="/a76") +router.include_router(classification_concepts_router, prefix="/a76") +router.include_router(unit_conversions_router, prefix="/a76") +router.include_router(equivalencies_router, prefix="/a76") +router.include_router(multi_currency_types_router, prefix="/a76") +router.include_router(inpc_router, prefix="/a76") +router.include_router(legends_router, prefix="/a76") +router.include_router(signatures_router, prefix="/a76") +router.include_router(error_catalogs_router, prefix="/a76") +router.include_router(doda_router, prefix="/a76") +router.include_router(prevalidators_router, prefix="/a76") +router.include_router(electronic_notices_router, prefix="/a76") diff --git a/backend/api/v1/modules/a76/transportation/drivers/dto.py b/backend/api/v1/modules/a76/transportation/drivers/dto.py new file mode 100644 index 00000000..014459a6 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/drivers/dto.py @@ -0,0 +1,41 @@ +from typing import Optional + +from pydantic import BaseModel + + +class DriverBaseDTO(BaseModel): + transporter_key: str + line: int + driver_name: Optional[str] + license_number: Optional[str] + express_line_id: Optional[str] + ace_id: Optional[str] + birth_date: Optional[int] + gender: Optional[str] + birth_country: Optional[str] + hazardous_material_auth: Optional[str] + hazardous_material_state: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + id_key1: Optional[str] + id_number1: Optional[str] + id_state1: Optional[str] + id_country1: Optional[str] + id_key2: Optional[str] + id_number2: Optional[str] + id_state2: Optional[str] + id_country2: Optional[str] + badge_number: Optional[str] + class_type: Optional[str] + unique_badge_number: Optional[str] + company_id: str + tenant_id: str + + +class DriverCreateDTO(DriverBaseDTO): + pass + + +class DriverResponseDTO(DriverBaseDTO): + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/transportation/drivers/models.py b/backend/api/v1/modules/a76/transportation/drivers/models.py new file mode 100644 index 00000000..7c70a9c3 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/drivers/models.py @@ -0,0 +1,40 @@ +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String + + +class Driver(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "driver" + __table_args__ = ( + {"schema": "a76"}, + ) + + transporter_key = Column( + String(5), + ForeignKey("a76.transporter.transporter_key", ondelete="CASCADE"), + primary_key=True, + nullable=False, + ) + line = Column(Integer, primary_key=True, nullable=False) + driver_name = Column(String(80), nullable=True) + license_number = Column(String(29), nullable=True) + express_line_id = Column(String(17), nullable=True) + ace_id = Column(String(20), nullable=True) + birth_date = Column(Integer, nullable=True) + gender = Column(String(1), nullable=True) + birth_country = Column(String(3), nullable=True) + hazardous_material_auth = Column(String(2), nullable=True) + hazardous_material_state = Column(String(30), nullable=True) + first_name = Column(String(20), nullable=True) + last_name = Column(String(20), nullable=True) + id_key1 = Column(String(40), nullable=True) + id_number1 = Column(String(20), nullable=True) + id_state1 = Column(String(30), nullable=True) + id_country1 = Column(String(3), nullable=True) + id_key2 = Column(String(40), nullable=True) + id_number2 = Column(String(20), nullable=True) + id_state2 = Column(String(30), nullable=True) + id_country2 = Column(String(3), nullable=True) + badge_number = Column(String(20), nullable=True) + class_type = Column(String(1), nullable=True) + unique_badge_number = Column(String(100), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/drivers/routes.py b/backend/api/v1/modules/a76/transportation/drivers/routes.py new file mode 100644 index 00000000..ebab5154 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/drivers/routes.py @@ -0,0 +1,52 @@ +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 DriverCreateDTO, DriverResponseDTO +from .services import DriverService + +router = APIRouter(prefix="/drivers") + + +@router.get("/", response_model=List[DriverResponseDTO]) +async def list_drivers( + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user) +): + return db.query(DriverService).all() + + +@router.get("/{transporter_key}/{line}", response_model=DriverResponseDTO) +async def read_driver( + transporter_key: str, + line: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + driver = DriverService.get_driver_by_key_and_line(db, transporter_key, line) + if not driver: + raise HTTPException(status_code=404, detail="Driver not found") + return driver + + +@router.post("/", response_model=DriverResponseDTO, status_code=status.HTTP_201_CREATED) +async def create_driver( + driver_data: DriverCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + return DriverService.create_driver(db, driver_data) + + +@router.delete("/{transporter_key}/{line}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_driver( + transporter_key: str, + line: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + driver = DriverService.delete_driver(db, transporter_key, line) + if not driver: + raise HTTPException(status_code=404, detail="Driver not found") diff --git a/backend/api/v1/modules/a76/transportation/drivers/services.py b/backend/api/v1/modules/a76/transportation/drivers/services.py new file mode 100644 index 00000000..9800b62f --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/drivers/services.py @@ -0,0 +1,32 @@ +from sqlalchemy.orm import Session + +from . import dto, models + + +class DriverService: + @staticmethod + def get_driver_by_key_and_line(db: Session, transporter_key: str, line: int): + return ( + db.query(models.Driver) + .filter( + models.Driver.transporter_key == transporter_key, + models.Driver.line == line, + ) + .first() + ) + + @staticmethod + def create_driver(db: Session, driver_data: dto.DriverCreateDTO): + new_driver = models.Driver(**driver_data.dict()) + db.add(new_driver) + db.commit() + db.refresh(new_driver) + return new_driver + + @staticmethod + def delete_driver(db: Session, transporter_key: str, line: int): + driver = DriverService.get_driver_by_key_and_line(db, transporter_key, line) + if driver: + db.delete(driver) + db.commit() + return driver diff --git a/backend/api/v1/modules/a76/transportation/trailers/dto.py b/backend/api/v1/modules/a76/transportation/trailers/dto.py new file mode 100644 index 00000000..548f1d8f --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/trailers/dto.py @@ -0,0 +1,34 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class TrailerBaseDTO(BaseModel): + trailer_number: str = Field(..., description="Trailer number (primary identifier)") + ace_trailer_number: Optional[str] = None + trailer_type_key: Optional[str] = None + seal: Optional[str] = None + entity_code: Optional[str] = None + plate_number: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + container_key: Optional[str] = None + + +class TrailerCreateDTO(TrailerBaseDTO): + """Schema for creating a trailer""" + pass + + +class TrailerUpdateDTO(TrailerBaseDTO): + """Schema for updating a trailer""" + trailer_number: Optional[str] = Field(None, description="Trailer number (cannot be modified)") + + +class TrailerResponseDTO(TrailerBaseDTO): + """Schema for trailer response""" + company_id: int + tenant_id: int + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/transportation/trailers/models.py b/backend/api/v1/modules/a76/transportation/trailers/models.py new file mode 100644 index 00000000..038c8987 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/trailers/models.py @@ -0,0 +1,22 @@ +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, String + + +class Trailer(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "trailer" + __table_args__ = ( + {"schema": "a76"}, + ) + + trailer_number = Column(String(20), primary_key=True, nullable=False) + ace_trailer_number = Column(String(10), nullable=True) + trailer_type_key = Column( + String(2), ForeignKey("a76.trailer_type.trailer_type_key"), nullable=True + ) + seal = Column(String(15), nullable=True) + entity_code = Column(String(1), nullable=True) + plate_number = Column(String(17), nullable=True) + state = Column(String(30), nullable=True) + country = Column(String(3), nullable=True) + container_key = Column(String(3), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/trailers/routes.py b/backend/api/v1/modules/a76/transportation/trailers/routes.py new file mode 100644 index 00000000..ad108e46 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/trailers/routes.py @@ -0,0 +1,22 @@ +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from .dto import TrailerCreateDTO, TrailerResponseDTO, TrailerUpdateDTO +from .services import TrailerService + +# Create router using TenantCRUDRoutes factory +# Note: trailer_number is a string (not int) and is used as the primary key +router = TenantCRUDRoutes( + service=TrailerService, + create_schema=TrailerCreateDTO, + update_schema=TrailerUpdateDTO, + response_schema=TrailerResponseDTO, + prefix="/trailers", + tags=[], + resource_name="Trailer", + id_name="trailer_number", # Using trailer_number instead of numeric ID + id_type=str, # Specify that the ID is a string + enable_list=True, # Enable GET /trailers with pagination + enable_filters=True, # Enable filtering by plate_number and trailer_type_key + default_page_size=50, + max_page_size=100, +).router diff --git a/backend/api/v1/modules/a76/transportation/trailers/services.py b/backend/api/v1/modules/a76/transportation/trailers/services.py new file mode 100644 index 00000000..43332b64 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/trailers/services.py @@ -0,0 +1,108 @@ +from typing import Optional, Tuple, List, Dict, Any + +from sqlalchemy.orm import Session + +from . import dto, models + + +class TrailerService: + """Service for Trailer 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.Trailer], int]: + """Get all trailers for a tenant/company with pagination""" + query = db.query(models.Trailer).filter( + models.Trailer.tenant_id == tenant_id, + models.Trailer.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("plate_number"): + query = query.filter( + models.Trailer.plate_number.ilike(f"%{filters['plate_number']}%") + ) + if filters.get("trailer_type_key"): + query = query.filter( + models.Trailer.trailer_type_key == filters["trailer_type_key"] + ) + + total = query.count() + trailers = query.offset(skip).limit(limit).all() + + return trailers, total + + @staticmethod + def get_by_id( + db: Session, trailer_number: str, tenant_id: int, company_id: int + ) -> Optional[models.Trailer]: + """Get trailer by trailer_number""" + return ( + db.query(models.Trailer) + .filter( + models.Trailer.trailer_number == trailer_number, + models.Trailer.tenant_id == tenant_id, + models.Trailer.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + trailer_data: dto.TrailerCreateDTO, + tenant_id: int, + company_id: int, + ) -> models.Trailer: + """Create a new trailer""" + new_trailer = models.Trailer( + **trailer_data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + db.add(new_trailer) + db.commit() + db.refresh(new_trailer) + return new_trailer + + @staticmethod + def update( + db: Session, + trailer_number: str, + tenant_id: int, + company_id: int, + trailer_data: dto.TrailerUpdateDTO, + ) -> Optional[models.Trailer]: + """Update a trailer""" + trailer = TrailerService.get_by_id(db, trailer_number, tenant_id, company_id) + if not trailer: + return None + + # Update fields (excluding trailer_number as it's the primary key) + update_data = trailer_data.model_dump( + exclude_unset=True, exclude={"trailer_number"} + ) + for field, value in update_data.items(): + setattr(trailer, field, value) + + db.commit() + db.refresh(trailer) + return trailer + + @staticmethod + def delete( + db: Session, trailer_number: str, tenant_id: int, company_id: int + ) -> bool: + """Delete a trailer""" + trailer = TrailerService.get_by_id(db, trailer_number, tenant_id, company_id) + if not trailer: + return False + + db.delete(trailer) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/transportation/transporters/dto.py b/backend/api/v1/modules/a76/transportation/transporters/dto.py new file mode 100644 index 00000000..06391a6e --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/transporters/dto.py @@ -0,0 +1,44 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class TransporterBaseDTO(BaseModel): + transporter_key: str = Field(..., description="Transporter key (primary identifier)") + name: Optional[str] = None + short_name: Optional[str] = None + responsible: Optional[str] = None + rfc: Optional[str] = None + streets: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + loader_code: Optional[str] = None + caat_code: Optional[str] = None + transport_code: Optional[str] = None + transport_interface_type: Optional[str] = None + ftp_server: Optional[str] = None + ftp_user: Optional[str] = None + ftp_password: Optional[str] = None + ftp_directory: Optional[str] = None + filler_code: Optional[str] = None + + +class TransporterCreateDTO(TransporterBaseDTO): + """Schema for creating a transporter""" + pass + + +class TransporterUpdateDTO(TransporterBaseDTO): + """Schema for updating a transporter""" + transporter_key: Optional[str] = Field(None, description="Transporter key (cannot be modified)") + + +class TransporterResponseDTO(TransporterBaseDTO): + """Schema for transporter response""" + company_id: int + tenant_id: int + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/transportation/transporters/models.py b/backend/api/v1/modules/a76/transportation/transporters/models.py new file mode 100644 index 00000000..cc355dbf --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/transporters/models.py @@ -0,0 +1,30 @@ +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import Column, ForeignKeyConstraint, String + + +class Transporter(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "transporter" + __table_args__ = ( + {"schema": "a76"}, + ) + + transporter_key = Column(String(5), primary_key=True, nullable=False) + name = Column(String(256), nullable=True) + short_name = Column(String(10), nullable=True) + responsible = Column(String(100), nullable=True) + rfc = Column(String(30), nullable=True) + streets = Column(String(100), nullable=True) + postal_code = Column(String(15), nullable=True) + city = Column(String(30), nullable=True) + state = Column(String(30), nullable=True) + country = Column(String(3), nullable=True) + loader_code = Column(String(9), nullable=True) + caat_code = Column(String(49), nullable=True) + transport_code = Column(String(8), nullable=True) + transport_interface_type = Column(String(20), nullable=True) + ftp_server = Column(String(200), nullable=True) + ftp_user = Column(String(200), nullable=True) + ftp_password = Column(String(100), nullable=True) + ftp_directory = Column(String(1000), nullable=True) + filler_code = Column(String(4), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/transporters/routes.py b/backend/api/v1/modules/a76/transportation/transporters/routes.py new file mode 100644 index 00000000..11796e47 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/transporters/routes.py @@ -0,0 +1,22 @@ +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from .dto import TransporterCreateDTO, TransporterResponseDTO, TransporterUpdateDTO +from .services import TransporterService + +# Create router using TenantCRUDRoutes factory +# Note: transporter_key is a string (not int) and is used as the primary key +router = TenantCRUDRoutes( + service=TransporterService, + create_schema=TransporterCreateDTO, + update_schema=TransporterUpdateDTO, + response_schema=TransporterResponseDTO, + prefix="/transporters", + tags=[], + resource_name="Transporter", + id_name="transporter_key", # Using transporter_key instead of numeric ID + id_type=str, # Specify that the ID is a string + enable_list=True, # Enable GET /transporters with pagination + enable_filters=True, # Enable filtering by name and rfc + default_page_size=50, + max_page_size=100, +).router diff --git a/backend/api/v1/modules/a76/transportation/transporters/services.py b/backend/api/v1/modules/a76/transportation/transporters/services.py new file mode 100644 index 00000000..1af393e5 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/transporters/services.py @@ -0,0 +1,112 @@ +from typing import Optional, Tuple, List, Dict, Any + +from sqlalchemy.orm import Session + +from . import dto, models + + +class TransporterService: + """Service for Transporter 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.Transporter], int]: + """Get all transporters for a tenant/company with pagination""" + query = db.query(models.Transporter).filter( + models.Transporter.tenant_id == tenant_id, + models.Transporter.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("name"): + query = query.filter( + models.Transporter.name.ilike(f"%{filters['name']}%") + ) + if filters.get("rfc"): + query = query.filter( + models.Transporter.rfc.ilike(f"%{filters['rfc']}%") + ) + + total = query.count() + transporters = query.offset(skip).limit(limit).all() + + return transporters, total + + @staticmethod + def get_by_id( + db: Session, transporter_key: str, tenant_id: int, company_id: int + ) -> Optional[models.Transporter]: + """Get transporter by transporter_key""" + return ( + db.query(models.Transporter) + .filter( + models.Transporter.transporter_key == transporter_key, + models.Transporter.tenant_id == tenant_id, + models.Transporter.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + transporter_data: dto.TransporterCreateDTO, + tenant_id: int, + company_id: int, + ) -> models.Transporter: + """Create a new transporter""" + new_transporter = models.Transporter( + **transporter_data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + db.add(new_transporter) + db.commit() + db.refresh(new_transporter) + return new_transporter + + @staticmethod + def update( + db: Session, + transporter_key: str, + tenant_id: int, + company_id: int, + transporter_data: dto.TransporterUpdateDTO, + ) -> Optional[models.Transporter]: + """Update a transporter""" + transporter = TransporterService.get_by_id( + db, transporter_key, tenant_id, company_id + ) + if not transporter: + return None + + # Update fields (excluding transporter_key as it's the primary key) + update_data = transporter_data.model_dump( + exclude_unset=True, exclude={"transporter_key"} + ) + for field, value in update_data.items(): + setattr(transporter, field, value) + + db.commit() + db.refresh(transporter) + return transporter + + @staticmethod + def delete( + db: Session, transporter_key: str, tenant_id: int, company_id: int + ) -> bool: + """Delete a transporter""" + transporter = TransporterService.get_by_id( + db, transporter_key, tenant_id, company_id + ) + if not transporter: + return False + + db.delete(transporter) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/transportation/vehicles/dto.py b/backend/api/v1/modules/a76/transportation/vehicles/dto.py new file mode 100644 index 00000000..7c7d28b9 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/vehicles/dto.py @@ -0,0 +1,51 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class VehicleBaseDTO(BaseModel): + vehicle_key: str = Field(..., description="Vehicle key (primary identifier)") + ace_vehicle_key: Optional[str] = None + transporter_key: Optional[str] = None + transport_identifier: Optional[str] = None + transport_type: Optional[str] = None + entity_code: Optional[str] = None + transponder_number: Optional[str] = None + dot_number: Optional[str] = None + plate_number: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + seal: Optional[str] = None + insurance_company_name: Optional[str] = None + insurance_number: Optional[str] = None + insurance_amount: Optional[float] = None + insurance_date: Optional[int] = None + box_number: Optional[str] = None + brand: Optional[str] = None + year: Optional[str] = None + series: Optional[str] = None + description: Optional[str] = None + engine_number: Optional[str] = None + sct_permission: Optional[str] = None + color: Optional[str] = None + container_key: Optional[str] = None + + +class VehicleCreateDTO(VehicleBaseDTO): + """Schema for creating a vehicle""" + pass + + +class VehicleUpdateDTO(VehicleBaseDTO): + """Schema for updating a vehicle""" + vehicle_key: Optional[str] = Field(None, description="Vehicle key (cannot be modified)") + + +class VehicleResponseDTO(VehicleBaseDTO): + """Schema for vehicle response""" + company_id: int + tenant_id: int + + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/a76/transportation/vehicles/models.py b/backend/api/v1/modules/a76/transportation/vehicles/models.py new file mode 100644 index 00000000..9339daf8 --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/vehicles/models.py @@ -0,0 +1,37 @@ +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import DECIMAL, Column, Integer, String + + +class Vehicle(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "vehicle" + __table_args__ = ( + {"schema": "a76"}, + ) + + vehicle_key = Column(String(14), primary_key=True, nullable=False) + ace_vehicle_key = Column(String(10), nullable=True) + transporter_key = Column(String(23), nullable=True) + transport_identifier = Column(String(30), nullable=True) + transport_type = Column(String(2), nullable=True) + entity_code = Column(String(1), nullable=True) + transponder_number = Column(String(16), nullable=True) + dot_number = Column(String(8), nullable=True) + plate_number = Column(String(17), nullable=True) + city = Column(String(30), nullable=True) + state = Column(String(30), nullable=True) + country = Column(String(3), nullable=True) + seal = Column(String(49), nullable=True) + insurance_company_name = Column(String(30), nullable=True) + insurance_number = Column(String(20), nullable=True) + insurance_amount = Column(DECIMAL(13, 2), nullable=True) + insurance_date = Column(Integer, nullable=True) + box_number = Column(String(300), nullable=True) + brand = Column(String(20), nullable=True) + year = Column(String(4), nullable=True) + series = Column(String(30), nullable=True) + description = Column(String(100), nullable=True) + engine_number = Column(String(50), nullable=True) + sct_permission = Column(String(40), nullable=True) + color = Column(String(20), nullable=True) + container_key = Column(String(3), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/vehicles/routes.py b/backend/api/v1/modules/a76/transportation/vehicles/routes.py new file mode 100644 index 00000000..1afc20ed --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/vehicles/routes.py @@ -0,0 +1,22 @@ +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes + +from .dto import VehicleCreateDTO, VehicleResponseDTO, VehicleUpdateDTO +from .services import VehicleService + +# Create router using TenantCRUDRoutes factory +# Note: vehicle_key is a string (not int) and is used as the primary key +router = TenantCRUDRoutes( + service=VehicleService, + create_schema=VehicleCreateDTO, + update_schema=VehicleUpdateDTO, + response_schema=VehicleResponseDTO, + prefix="/vehicles", + tags=[], + resource_name="Vehicle", + id_name="vehicle_key", # Using vehicle_key instead of numeric ID + id_type=str, # Specify that the ID is a string + enable_list=True, # Enable GET /vehicles with pagination + enable_filters=True, # Enable filtering by plate_number and transport_type + default_page_size=50, + max_page_size=100, +).router diff --git a/backend/api/v1/modules/a76/transportation/vehicles/services.py b/backend/api/v1/modules/a76/transportation/vehicles/services.py new file mode 100644 index 00000000..d21bd36f --- /dev/null +++ b/backend/api/v1/modules/a76/transportation/vehicles/services.py @@ -0,0 +1,106 @@ +from typing import Optional, Tuple, List, Dict, Any + +from sqlalchemy.orm import Session + +from . import dto, models + + +class VehicleService: + """Service for Vehicle 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.Vehicle], int]: + """Get all vehicles for a tenant/company with pagination""" + query = db.query(models.Vehicle).filter( + models.Vehicle.tenant_id == tenant_id, + models.Vehicle.company_id == company_id, + ) + + # Apply filters if provided + if filters: + if filters.get("plate_number"): + query = query.filter( + models.Vehicle.plate_number.ilike(f"%{filters['plate_number']}%") + ) + if filters.get("transport_type"): + query = query.filter( + models.Vehicle.transport_type == filters["transport_type"] + ) + + total = query.count() + vehicles = query.offset(skip).limit(limit).all() + + return vehicles, total + + @staticmethod + def get_by_id( + db: Session, vehicle_key: str, tenant_id: int, company_id: int + ) -> Optional[models.Vehicle]: + """Get vehicle by vehicle_key""" + return ( + db.query(models.Vehicle) + .filter( + models.Vehicle.vehicle_key == vehicle_key, + models.Vehicle.tenant_id == tenant_id, + models.Vehicle.company_id == company_id, + ) + .first() + ) + + @staticmethod + def create( + db: Session, + vehicle_data: dto.VehicleCreateDTO, + tenant_id: int, + company_id: int, + ) -> models.Vehicle: + """Create a new vehicle""" + new_vehicle = models.Vehicle( + **vehicle_data.model_dump(), tenant_id=tenant_id, company_id=company_id + ) + db.add(new_vehicle) + db.commit() + db.refresh(new_vehicle) + return new_vehicle + + @staticmethod + def update( + db: Session, + vehicle_key: str, + tenant_id: int, + company_id: int, + vehicle_data: dto.VehicleUpdateDTO, + ) -> Optional[models.Vehicle]: + """Update a vehicle""" + vehicle = VehicleService.get_by_id(db, vehicle_key, tenant_id, company_id) + if not vehicle: + return None + + # Update fields (excluding vehicle_key as it's the primary key) + update_data = vehicle_data.model_dump(exclude_unset=True, exclude={"vehicle_key"}) + for field, value in update_data.items(): + setattr(vehicle, field, value) + + db.commit() + db.refresh(vehicle) + return vehicle + + @staticmethod + def delete( + db: Session, vehicle_key: str, tenant_id: int, company_id: int + ) -> bool: + """Delete a vehicle""" + vehicle = VehicleService.get_by_id(db, vehicle_key, tenant_id, company_id) + if not vehicle: + return False + + db.delete(vehicle) + db.commit() + return True diff --git a/backend/api/v1/modules/a76/auth/__init__.py b/backend/api/v1/modules/core/auth/__init__.py similarity index 98% rename from backend/api/v1/modules/a76/auth/__init__.py rename to backend/api/v1/modules/core/auth/__init__.py index 08c9bf95..a4f62822 100644 --- a/backend/api/v1/modules/a76/auth/__init__.py +++ b/backend/api/v1/modules/core/auth/__init__.py @@ -1,6 +1,7 @@ """ Módulo de Authentication """ + from .routes import router __all__ = ["router"] diff --git a/backend/api/v1/modules/a76/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py similarity index 90% rename from backend/api/v1/modules/a76/auth/dto.py rename to backend/api/v1/modules/core/auth/dto.py index 23f97533..e3ef9969 100644 --- a/backend/api/v1/modules/a76/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -1,58 +1,64 @@ """ DTOs para módulo de autenticación """ -from pydantic import BaseModel, EmailStr, Field + from typing import Optional +from pydantic import BaseModel, EmailStr, Field + class LoginRequestDTO(BaseModel): """DTO para solicitud de login""" + username: str = Field(..., description="Usuario o email") password: str = Field(..., min_length=6, description="Contraseña") tenant_slug: str = Field(..., description="Slug del tenant") - + class Config: json_schema_extra = { "example": { "username": "usuario@ejemplo.com", "password": "password123", - "tenant_slug": "empresa-abc" + "tenant_slug": "empresa-abc", } } class TokenResponseDTO(BaseModel): """DTO para respuesta de token""" + access_token: str refresh_token: str token_type: str = "bearer" expires_in: int - + class Config: json_schema_extra = { "example": { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer", - "expires_in": 3600 + "expires_in": 3600, } } class RefreshTokenRequestDTO(BaseModel): """DTO para solicitud de refresh token""" + refresh_token: str = Field(..., description="Refresh token") class UserInfoResponseDTO(BaseModel): """DTO para información de usuario""" + sub: str email: Optional[str] = None name: Optional[str] = None preferred_username: Optional[str] = None tenant_id: Optional[int] = None roles: list[str] = [] - + class Config: json_schema_extra = { "example": { @@ -61,25 +67,29 @@ class UserInfoResponseDTO(BaseModel): "name": "Juan Pérez", "preferred_username": "jperez", "tenant_id": 1, - "roles": ["user", "admin"] + "roles": ["user", "admin"], } } class LogoutRequestDTO(BaseModel): """DTO para solicitud de logout""" + refresh_token: str = Field(..., description="Refresh token para invalidar") class RegisterRequestDTO(BaseModel): """DTO para solicitud de registro""" - username: str = Field(..., min_length=3, max_length=50, description="Nombre de usuario") + + username: str = Field( + ..., min_length=3, max_length=50, description="Nombre de usuario" + ) email: EmailStr = Field(..., description="Email del usuario") password: str = Field(..., min_length=8, description="Contraseña") first_name: str = Field(..., min_length=2, max_length=50, description="Nombre") last_name: str = Field(..., min_length=2, max_length=50, description="Apellido") tenant_slug: str = Field(..., description="Slug del tenant") - + class Config: json_schema_extra = { "example": { @@ -88,54 +98,57 @@ class RegisterRequestDTO(BaseModel): "password": "MiPassword123!", "first_name": "Juan", "last_name": "Pérez", - "tenant_slug": "empresa-abc" + "tenant_slug": "empresa-abc", } } class RegisterResponseDTO(BaseModel): """DTO para respuesta de registro""" + user_id: str username: str email: str message: str - + class Config: json_schema_extra = { "example": { "user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "username": "jperez", "email": "jperez@ejemplo.com", - "message": "User registered successfully" + "message": "User registered successfully", } } class ExchangeCodeRequestDTO(BaseModel): """DTO para intercambiar authorization code por tokens (OAuth2 flow)""" + code: str = Field(..., description="Authorization code de OAuth2") redirect_uri: str = Field(..., description="Redirect URI usado en la autorización") tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)") - + class Config: json_schema_extra = { "example": { "code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...", "redirect_uri": "http://localhost:5173/auth/callback", - "tenant_slug": "empresa-abc" + "tenant_slug": "empresa-abc", } } class SetCookieRequestDTO(BaseModel): """DTO para establecer cookies de autenticación""" + access_token: str = Field(..., description="Access token JWT") refresh_token: str = Field(..., description="Refresh token JWT") - + class Config: json_schema_extra = { "example": { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", } } diff --git a/backend/api/v1/modules/a76/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py similarity index 85% rename from backend/api/v1/modules/a76/auth/routes.py rename to backend/api/v1/modules/core/auth/routes.py index d0bf28ff..1d327aee 100644 --- a/backend/api/v1/modules/a76/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -1,22 +1,23 @@ """ Endpoints API para autenticación """ -from fastapi import APIRouter, Depends, HTTPException, Response -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + from .dto import ( + ExchangeCodeRequestDTO, LoginRequestDTO, - TokenResponseDTO, - RefreshTokenRequestDTO, - UserInfoResponseDTO, LogoutRequestDTO, + RefreshTokenRequestDTO, RegisterRequestDTO, RegisterResponseDTO, - ExchangeCodeRequestDTO, - SetCookieRequestDTO + SetCookieRequestDTO, + TokenResponseDTO, + UserInfoResponseDTO, ) from .service import AuthService @@ -26,12 +27,11 @@ security = HTTPBearer() @router.post("/register", response_model=RegisterResponseDTO, status_code=201) async def register( - register_data: RegisterRequestDTO, - db: Session = Depends(get_core_db) + register_data: RegisterRequestDTO, db: Session = Depends(get_core_db) ): """ Registra un nuevo usuario en Keycloak - + El usuario debe proporcionar: - username: Nombre de usuario único - email: Email único @@ -39,7 +39,7 @@ async def register( - first_name: Nombre - last_name: Apellido - tenant_slug: Slug del tenant al que pertenece - + El usuario se crea automáticamente en Keycloak con: - Cuenta habilitada - Rol 'user' asignado por defecto @@ -50,13 +50,10 @@ async def register( @router.post("/login", response_model=TokenResponseDTO) -async def login( - login_data: LoginRequestDTO, - db: Session = Depends(get_core_db) -): +async def login(login_data: LoginRequestDTO, db: Session = Depends(get_core_db)): """ Autentica usuario con Keycloak y retorna tokens JWT - + El usuario debe proporcionar: - username: Usuario o email - password: Contraseña @@ -68,8 +65,7 @@ async def login( @router.post("/refresh", response_model=TokenResponseDTO) async def refresh_token( - refresh_data: RefreshTokenRequestDTO, - db: Session = Depends(get_core_db) + refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db) ): """ Refresca el access token usando el refresh token @@ -81,7 +77,7 @@ async def refresh_token( @router.get("/me", response_model=UserInfoResponseDTO) async def get_current_user_info( credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_core_db) + db: Session = Depends(get_core_db), ): """ Obtiene información del usuario actual desde el token @@ -94,7 +90,7 @@ async def get_current_user_info( async def logout( logout_data: LogoutRequestDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), ): """ Cierra sesión invalidando el refresh token @@ -105,15 +101,14 @@ async def logout( @router.post("/exchange-code", response_model=TokenResponseDTO) async def exchange_code( - exchange_data: ExchangeCodeRequestDTO, - db: Session = Depends(get_core_db) + exchange_data: ExchangeCodeRequestDTO, db: Session = Depends(get_core_db) ): """ Intercambia un authorization code de OAuth2 por tokens - + Este endpoint es útil cuando el frontend usa el flujo de autorización con proveedores externos (Microsoft, Google, etc.) a través de Keycloak. - + El código se obtiene después de que el usuario se autentica con el proveedor externo y Keycloak lo redirige al frontend con el código en los query params. """ @@ -125,15 +120,15 @@ async def exchange_code( async def set_cookie( cookie_data: SetCookieRequestDTO, response: Response, - db: Session = Depends(get_core_db) + db: Session = Depends(get_core_db), ): """ Establece cookies HttpOnly con los tokens de autenticación - + Este endpoint se llama desde el frontend después de una autenticación SSO exitosa para establecer las cookies de sesión necesarias para la validación server-side en los layouts protegidos. - + Las cookies se configuran como: - HttpOnly: No accesibles desde JavaScript (mayor seguridad) - Secure: Solo se envían por HTTPS (en producción) @@ -145,7 +140,7 @@ async def set_cookie( try: # Validar el access token user_info = service.get_user_info(cookie_data.access_token) - + # Establecer las cookies # Access token cookie response.set_cookie( @@ -155,9 +150,9 @@ async def set_cookie( secure=False, # TODO: Cambiar a True en producción con HTTPS samesite="lax", # Protección CSRF max_age=3600, # 1 hora (ajustar según configuración del token) - path="/" + path="/", ) - + # Refresh token cookie response.set_cookie( key="refresh_token", @@ -166,17 +161,14 @@ async def set_cookie( secure=False, # TODO: Cambiar a True en producción con HTTPS samesite="lax", max_age=86400, # 24 horas (ajustar según configuración del token) - path="/" + path="/", ) - + return { "success": True, "message": "Cookies establecidas correctamente", - "user": user_info + "user": user_info, } - + except Exception as e: - raise HTTPException( - status_code=400, - detail=f"Error validando tokens: {str(e)}" - ) \ No newline at end of file + raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}") diff --git a/backend/api/v1/modules/a76/auth/service.py b/backend/api/v1/modules/core/auth/service.py similarity index 63% rename from backend/api/v1/modules/a76/auth/service.py rename to backend/api/v1/modules/core/auth/service.py index 24a35dd0..96153be7 100644 --- a/backend/api/v1/modules/a76/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -1,21 +1,25 @@ """ Servicio de autenticación con Keycloak """ -from keycloak import KeycloakOpenID, KeycloakAdmin -from keycloak.exceptions import KeycloakError -from fastapi import HTTPException -from sqlalchemy.orm import Session + import logging +from api.v1.modules.core.tenants.service import TenantService +from api.v1.modules.core.user_tenant.service import UserTenantService from core.config import settings +from fastapi import HTTPException +from keycloak import KeycloakAdmin, KeycloakOpenID +from keycloak.exceptions import KeycloakError +from sqlalchemy.orm import Session + from .dto import ( LoginRequestDTO, - TokenResponseDTO, - RefreshTokenRequestDTO, - UserInfoResponseDTO, LogoutRequestDTO, + RefreshTokenRequestDTO, RegisterRequestDTO, - RegisterResponseDTO + RegisterResponseDTO, + TokenResponseDTO, + UserInfoResponseDTO, ) logger = logging.getLogger(__name__) @@ -23,65 +27,129 @@ logger = logging.getLogger(__name__) class AuthService: """Servicio de autenticación""" - + def __init__(self, db: Session): self.db = db self.keycloak_openid = KeycloakOpenID( - server_url=settings.KEYCLOAK_SERVER_URL, + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", client_id=settings.KEYCLOAK_CLIENT_ID, realm_name=settings.KEYCLOAK_REALM, - client_secret_key=settings.KEYCLOAK_CLIENT_SECRET + client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, ) - + def login(self, login_data: LoginRequestDTO) -> TokenResponseDTO: """ Autentica usuario y obtiene tokens - + Args: login_data: Credenciales de login - + Returns: TokenResponseDTO con access_token y refresh_token - + Raises: HTTPException: Si las credenciales son inválidas """ try: # Verificar que el tenant existe - from api.v1.modules.a76.tenants.service import TenantService tenant_service = TenantService(self.db) + user_tenant_service = UserTenantService(self.db) tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug) - + if not tenant: raise HTTPException(status_code=404, detail="Tenant not found") - + if not tenant.is_active: raise HTTPException(status_code=403, detail="Tenant is not active") - + # Crear nueva instancia de KeycloakOpenID con el realm del tenant keycloak_client = KeycloakOpenID( - server_url=settings.KEYCLOAK_SERVER_URL, + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", client_id=settings.KEYCLOAK_CLIENT_ID, realm_name=tenant.keycloak_realm, - client_secret_key=settings.KEYCLOAK_CLIENT_SECRET + client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, ) - - # Obtener token de Keycloak + + # PASO 1: Primero actualizamos los atributos del usuario ANTES de autenticar + # Esto es necesario para que los Protocol Mappers incluyan los valores correctos + # en el token que se generará a continuación + + # Para obtener el user_id, necesitamos hacer una autenticación temporal + # o buscar el usuario por username + try: + keycloak_admin = KeycloakAdmin( + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", + username=settings.KEYCLOAK_ADMIN_USERNAME, + password=settings.KEYCLOAK_ADMIN_PASSWORD, + realm_name=tenant.keycloak_realm, + user_realm_name="master", + verify=True, + ) + + # Buscar usuario por username + users = keycloak_admin.get_users({"username": login_data.username}) + + if users and len(users) > 0: + user_id = users[0]["id"] + + # Verificar si el usuario tiene acceso a este tenant + has_access = user_tenant_service.user_has_access_to_tenant( + user_id, tenant.id + ) + + if not has_access: + logger.warning( + f"User {user_id} tried to access tenant {tenant.id} without permission" + ) + raise HTTPException( + status_code=403, + detail="You don't have access to this tenant", + ) + + # Obtener los datos actuales del usuario + current_user = keycloak_admin.get_user(user_id) + current_attributes = current_user.get("attributes", {}) + + # Actualizar los atributos de tenant + current_attributes["tenant_id"] = [str(tenant.id)] + current_attributes["tenant_slug"] = [tenant.slug] + + # Actualizar el usuario con los nuevos atributos + update_payload = { + "email": current_user.get("email"), + "firstName": current_user.get("firstName"), + "lastName": current_user.get("lastName"), + "enabled": current_user.get("enabled", True), + "emailVerified": current_user.get("emailVerified", False), + "attributes": current_attributes, + } + + keycloak_admin.update_user(user_id=user_id, payload=update_payload) + + except KeycloakError as e: + logger.warning(f"Could not pre-update user attributes: {str(e)}") + # Continuamos con el login aunque falle la actualización + except HTTPException: + raise # Re-lanzamos las excepciones HTTP (como acceso denegado) + except Exception as e: + logger.warning(f"Error pre-updating user attributes: {str(e)}") + + # PASO 2: Ahora autenticamos al usuario + # Si los Protocol Mappers están configurados, el token incluirá + # automáticamente los atributos tenant_id y tenant_slug actualizados token_response = keycloak_client.token( username=login_data.username, password=login_data.password, - grant_type=["password"] + grant_type=["password"], ) - - logger.info(f"User logged in: {login_data.username} (tenant: {tenant.slug})") - + return TokenResponseDTO( access_token=token_response["access_token"], refresh_token=token_response["refresh_token"], token_type="bearer", - expires_in=token_response["expires_in"] + expires_in=token_response["expires_in"], ) - + except KeycloakError as e: logger.warning(f"Keycloak authentication failed: {str(e)}") raise HTTPException(status_code=401, detail="Invalid credentials") @@ -90,14 +158,14 @@ class AuthService: except Exception as e: logger.error(f"Login error: {str(e)}") raise HTTPException(status_code=500, detail="Authentication error") - + def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO: """ Refresca el access token usando refresh token - + Args: refresh_data: Refresh token - + Returns: TokenResponseDTO con nuevos tokens """ @@ -105,75 +173,76 @@ class AuthService: token_response = self.keycloak_openid.refresh_token( refresh_data.refresh_token ) - + return TokenResponseDTO( access_token=token_response["access_token"], refresh_token=token_response["refresh_token"], token_type="bearer", - expires_in=token_response["expires_in"] + expires_in=token_response["expires_in"], ) - + except KeycloakError as e: logger.warning(f"Token refresh failed: {str(e)}") - raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + raise HTTPException( + status_code=401, detail="Invalid or expired refresh token" + ) except Exception as e: logger.error(f"Token refresh error: {str(e)}") raise HTTPException(status_code=500, detail="Token refresh error") - + def get_user_info(self, access_token: str) -> UserInfoResponseDTO: """ Obtiene información del usuario desde el token - + Args: access_token: Access token JWT - + Returns: UserInfoResponseDTO con información del usuario """ try: user_info = self.keycloak_openid.userinfo(access_token) - + # Extraer roles roles = [] if "realm_access" in user_info: roles = user_info["realm_access"].get("roles", []) - + # Extraer tenant_id si está presente tenant_id = user_info.get("tenant_id") if not tenant_id and "attributes" in user_info: tenant_id = user_info["attributes"].get("tenant_id") - + return UserInfoResponseDTO( sub=user_info.get("sub"), email=user_info.get("email"), name=user_info.get("name"), preferred_username=user_info.get("preferred_username"), tenant_id=int(tenant_id) if tenant_id else None, - roles=roles + roles=roles, ) - + except KeycloakError as e: logger.warning(f"Get user info failed: {str(e)}") raise HTTPException(status_code=401, detail="Invalid token") except Exception as e: logger.error(f"Get user info error: {str(e)}") raise HTTPException(status_code=500, detail="Error retrieving user info") - + def logout(self, logout_data: LogoutRequestDTO) -> dict: """ Cierra sesión invalidando el refresh token - + Args: logout_data: Refresh token a invalidar - + Returns: Dict con mensaje de éxito """ try: self.keycloak_openid.logout(logout_data.refresh_token) - logger.info("User logged out successfully") return {"message": "Logged out successfully"} - + except KeycloakError as e: logger.warning(f"Logout failed: {str(e)}") # No lanzamos error aquí, el logout puede fallar si el token ya expiró @@ -181,42 +250,43 @@ class AuthService: except Exception as e: logger.error(f"Logout error: {str(e)}") raise HTTPException(status_code=500, detail="Logout error") - + def register(self, register_data: RegisterRequestDTO) -> RegisterResponseDTO: """ Registra un nuevo usuario en Keycloak - + Args: register_data: Datos del usuario a registrar - + Returns: RegisterResponseDTO con información del usuario creado - + Raises: HTTPException: Si el registro falla """ try: # Verificar que el tenant existe - from api.v1.modules.a76.tenants.service import TenantService + from api.v1.modules.core.tenants.service import TenantService + tenant_service = TenantService(self.db) tenant = tenant_service.get_tenant_by_slug(register_data.tenant_slug) - + if not tenant: raise HTTPException(status_code=404, detail="Tenant not found") - + if not tenant.is_active: raise HTTPException(status_code=403, detail="Tenant is not active") - + # Crear instancia de KeycloakAdmin para gestión de usuarios keycloak_admin = KeycloakAdmin( - server_url=settings.KEYCLOAK_SERVER_URL, + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", username=settings.KEYCLOAK_ADMIN_USERNAME, password=settings.KEYCLOAK_ADMIN_PASSWORD, realm_name=tenant.keycloak_realm, user_realm_name="master", # El admin suele estar en master realm - verify=True + verify=True, ) - + # Preparar datos del usuario para Keycloak user_data = { "username": register_data.username, @@ -225,126 +295,142 @@ class AuthService: "lastName": register_data.last_name, "enabled": True, "emailVerified": False, - "credentials": [{ - "type": "password", - "value": register_data.password, - "temporary": False - }], - "attributes": { - "tenant_id": str(tenant.id), - "tenant_slug": tenant.slug - } + "credentials": [ + { + "type": "password", + "value": register_data.password, + "temporary": False, + } + ], + "attributes": {"tenant_id": str(tenant.id), "tenant_slug": tenant.slug}, } - + # Crear usuario en Keycloak user_id = keycloak_admin.create_user(user_data) - + # Asignar rol por defecto (user) - opcional, solo si existe try: user_role = keycloak_admin.get_realm_role("user") if user_role: keycloak_admin.assign_realm_roles(user_id, [user_role]) - logger.info(f"Assigned 'user' role to {register_data.username}") except KeycloakError as e: # El rol 'user' no existe, no es un error crítico logger.warning(f"Could not assign 'user' role: {str(e)}") - - logger.info(f"User registered: {register_data.username} (tenant: {tenant.slug}, user_id: {user_id})") - + + # Agregar el usuario al tenant en la base de datos + try: + from api.v1.modules.core.user_tenant.service import UserTenantService + + user_tenant_service = UserTenantService(self.db) + user_tenant_service.add_user_to_tenant( + keycloak_user_id=user_id, + tenant_id=tenant.id, + role="user", # Rol por defecto + ) + except Exception as e: + # Si falla, hacer rollback del usuario en Keycloak + logger.error(f"Failed to add user to tenant in database: {str(e)}") + try: + keycloak_admin.delete_user(user_id) + except Exception as e: + pass + raise HTTPException( + status_code=500, detail="Failed to register user in database" + ) + return RegisterResponseDTO( user_id=user_id, username=register_data.username, email=register_data.email, - message="User registered successfully" + message="User registered successfully", ) - + except KeycloakError as e: error_message = str(e) logger.warning(f"Keycloak registration failed: {error_message}") - + # Mensajes de error más específicos if "User exists" in error_message or "409" in error_message: - raise HTTPException(status_code=409, detail="Username or email already exists") + raise HTTPException( + status_code=409, detail="Username or email already exists" + ) elif "Invalid" in error_message: raise HTTPException(status_code=400, detail="Invalid user data") else: raise HTTPException(status_code=500, detail="Registration error") - + except HTTPException: raise except Exception as e: logger.error(f"Registration error: {str(e)}") raise HTTPException(status_code=500, detail="Registration error") - + def exchange_code(self, exchange_data) -> TokenResponseDTO: """ Intercambia un authorization code por tokens - + Este método se usa cuando el frontend recibe un código de autorización después de un login con proveedor externo (Microsoft, Google, etc.) a través de Keycloak. - + Args: exchange_data: Datos del código y redirect_uri - + Returns: TokenResponseDTO con access_token y refresh_token - + Raises: HTTPException: Si el código es inválido o expiró """ try: # Importar el DTO aquí para evitar referencias circulares - from .dto import ExchangeCodeRequestDTO - + # Intercambiar código por tokens usando Keycloak token_response = self.keycloak_openid.token( - grant_type='authorization_code', + grant_type="authorization_code", code=exchange_data.code, - redirect_uri=exchange_data.redirect_uri + redirect_uri=exchange_data.redirect_uri, ) - - logger.info(f"Code exchanged successfully") - + # Si se proporciona tenant_slug, podríamos validar que el usuario pertenece a ese tenant # Por ahora simplemente retornamos los tokens if exchange_data.tenant_slug: - # Decodificar token para obtener tenant_id del usuario - user_info = self.keycloak_openid.introspect(token_response['access_token']) - user_tenant_id = user_info.get('tenant_id') - + # Validar que el tenant existe y está activo - from api.v1.modules.a76.tenants.service import TenantService tenant_service = TenantService(self.db) tenant = tenant_service.get_tenant_by_slug(exchange_data.tenant_slug) - + if not tenant: raise HTTPException(status_code=404, detail="Tenant not found") - + if not tenant.is_active: raise HTTPException(status_code=403, detail="Tenant is not active") - + # Opcional: Verificar que el usuario pertenece al tenant # Esto depende de cómo manejes los tenants en tu aplicación - + return TokenResponseDTO( - access_token=token_response['access_token'], - refresh_token=token_response['refresh_token'], - token_type=token_response.get('token_type', 'bearer'), - expires_in=token_response.get('expires_in', 3600) + access_token=token_response["access_token"], + refresh_token=token_response["refresh_token"], + token_type=token_response.get("token_type", "bearer"), + expires_in=token_response.get("expires_in", 3600), ) - + except KeycloakError as e: error_message = str(e) logger.warning(f"Code exchange failed: {error_message}") - + if "invalid_grant" in error_message.lower(): - raise HTTPException(status_code=400, detail="Invalid or expired authorization code") + raise HTTPException( + status_code=400, detail="Invalid or expired authorization code" + ) elif "invalid_client" in error_message.lower(): - raise HTTPException(status_code=401, detail="Invalid client credentials") + raise HTTPException( + status_code=401, detail="Invalid client credentials" + ) else: raise HTTPException(status_code=500, detail="Token exchange error") - + except HTTPException: raise except Exception as e: diff --git a/backend/api/v1/modules/a76/licenses/__init__.py b/backend/api/v1/modules/core/licenses/__init__.py similarity index 98% rename from backend/api/v1/modules/a76/licenses/__init__.py rename to backend/api/v1/modules/core/licenses/__init__.py index feb458dd..fffdb512 100644 --- a/backend/api/v1/modules/a76/licenses/__init__.py +++ b/backend/api/v1/modules/core/licenses/__init__.py @@ -1,6 +1,7 @@ """ Módulo de Licenses """ + from .routes import router __all__ = ["router"] diff --git a/backend/api/v1/modules/a76/licenses/dto.py b/backend/api/v1/modules/core/licenses/dto.py similarity index 91% rename from backend/api/v1/modules/a76/licenses/dto.py rename to backend/api/v1/modules/core/licenses/dto.py index 9e968356..4636d70d 100644 --- a/backend/api/v1/modules/a76/licenses/dto.py +++ b/backend/api/v1/modules/core/licenses/dto.py @@ -1,14 +1,17 @@ """ DTOs para módulo de licencias """ -from pydantic import BaseModel, Field -from typing import Optional + from datetime import datetime from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field class LicensePlanDTO(str, Enum): """Planes de licencia""" + FREE = "free" BASIC = "basic" PROFESSIONAL = "professional" @@ -17,6 +20,7 @@ class LicensePlanDTO(str, Enum): class LicenseStatusDTO(str, Enum): """Estados de licencia""" + ACTIVE = "active" EXPIRED = "expired" SUSPENDED = "suspended" @@ -26,20 +30,25 @@ class LicenseStatusDTO(str, Enum): class LicenseCreateDTO(BaseModel): """DTO para crear una nueva licencia""" + tenant_id: int = Field(..., description="ID del tenant") plan: LicensePlanDTO = Field(..., description="Plan de licencia") max_users: int = Field(default=5, ge=1, description="Número máximo de usuarios") - max_storage_gb: int = Field(default=10, ge=1, description="Almacenamiento máximo en GB") - max_monthly_operations: int = Field(default=1000, ge=1, description="Operaciones mensuales máximas") - + max_storage_gb: int = Field( + default=10, ge=1, description="Almacenamiento máximo en GB" + ) + max_monthly_operations: int = Field( + default=1000, ge=1, description="Operaciones mensuales máximas" + ) + feature_api_access: bool = Field(default=True) feature_advanced_reports: bool = Field(default=False) feature_integrations: bool = Field(default=False) feature_dedicated_support: bool = Field(default=False) - + starts_at: datetime = Field(..., description="Fecha de inicio de vigencia") expires_at: datetime = Field(..., description="Fecha de expiración") - + class Config: json_schema_extra = { "example": { @@ -53,60 +62,63 @@ class LicenseCreateDTO(BaseModel): "feature_integrations": True, "feature_dedicated_support": False, "starts_at": "2025-01-01T00:00:00Z", - "expires_at": "2025-12-31T23:59:59Z" + "expires_at": "2025-12-31T23:59:59Z", } } class LicenseUpdateDTO(BaseModel): """DTO para actualizar una licencia""" + plan: Optional[LicensePlanDTO] = None status: Optional[LicenseStatusDTO] = None max_users: Optional[int] = Field(None, ge=1) max_storage_gb: Optional[int] = Field(None, ge=1) max_monthly_operations: Optional[int] = Field(None, ge=1) - + feature_api_access: Optional[bool] = None feature_advanced_reports: Optional[bool] = None feature_integrations: Optional[bool] = None feature_dedicated_support: Optional[bool] = None - + expires_at: Optional[datetime] = None class LicenseResponseDTO(BaseModel): """DTO para respuesta de licencia""" + id: int tenant_id: int plan: LicensePlanDTO status: LicenseStatusDTO - + max_users: int max_storage_gb: int max_monthly_operations: int - + feature_api_access: bool feature_advanced_reports: bool feature_integrations: bool feature_dedicated_support: bool - + starts_at: datetime expires_at: datetime created_at: datetime updated_at: datetime - + class Config: from_attributes = True class LicenseValidationResponseDTO(BaseModel): """DTO para respuesta de validación de licencia""" + is_valid: bool status: LicenseStatusDTO plan: LicensePlanDTO expires_at: datetime reason: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -114,13 +126,14 @@ class LicenseValidationResponseDTO(BaseModel): "status": "active", "plan": "professional", "expires_at": "2025-12-31T23:59:59Z", - "reason": None + "reason": None, } } class LicenseUsageResponseDTO(BaseModel): """DTO para respuesta de uso de licencia""" + tenant_id: int period_start: datetime period_end: datetime @@ -128,16 +141,16 @@ class LicenseUsageResponseDTO(BaseModel): storage_used_gb: int operations_count: int api_calls_count: int - + # Límites actuales max_users: int max_storage_gb: int max_monthly_operations: int - + # Porcentajes de uso users_usage_percent: float storage_usage_percent: float operations_usage_percent: float - + class Config: from_attributes = True diff --git a/backend/api/v1/modules/a76/licenses/models.py b/backend/api/v1/modules/core/licenses/models.py similarity index 66% rename from backend/api/v1/modules/a76/licenses/models.py rename to backend/api/v1/modules/core/licenses/models.py index eb3ada1f..2bf4bcb9 100644 --- a/backend/api/v1/modules/a76/licenses/models.py +++ b/backend/api/v1/modules/core/licenses/models.py @@ -1,15 +1,19 @@ """ Modelos ORM para gestión de licencias """ -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum as SQLEnum -from sqlalchemy.sql import func -from sqlalchemy.orm import relationship -from core.database import Base + import enum +from api.v1.common.base_models import TimestampMixin +from core.database import Base +from sqlalchemy import Boolean, Column, DateTime +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import ForeignKey, Integer + class LicensePlan(enum.Enum): """Planes de licencia disponibles""" + FREE = "free" BASIC = "basic" PROFESSIONAL = "professional" @@ -18,6 +22,7 @@ class LicensePlan(enum.Enum): class LicenseStatus(enum.Enum): """Estados de licencia""" + ACTIVE = "active" EXPIRED = "expired" SUSPENDED = "suspended" @@ -25,65 +30,65 @@ class LicenseStatus(enum.Enum): CANCELLED = "cancelled" -class License(Base): +class License(Base, TimestampMixin): """ Modelo de Licencia - Control de planes y límites por tenant """ + __tablename__ = "licenses" - __table_args__ = {"schema": "a76"} - + __table_args__ = {"schema": "core"} + id = Column(Integer, primary_key=True, index=True) - tenant_id = Column(Integer, ForeignKey("a76.tenants.id"), nullable=False, unique=True, index=True) - + tenant_id = Column( + Integer, ForeignKey("core.tenants.id"), nullable=False, unique=True, index=True + ) + # Plan y características plan = Column(SQLEnum(LicensePlan), default=LicensePlan.FREE, nullable=False) - status = Column(SQLEnum(LicenseStatus), default=LicenseStatus.PENDING, nullable=False) - + status = Column( + SQLEnum(LicenseStatus), default=LicenseStatus.PENDING, nullable=False + ) + # Límites del plan max_users = Column(Integer, default=5, nullable=False) max_storage_gb = Column(Integer, default=10, nullable=False) max_monthly_operations = Column(Integer, default=1000, nullable=False) - + # Features habilitadas (booleans) feature_api_access = Column(Boolean, default=True) feature_advanced_reports = Column(Boolean, default=False) feature_integrations = Column(Boolean, default=False) feature_dedicated_support = Column(Boolean, default=False) - + # Vigencia starts_at = Column(DateTime(timezone=True), nullable=False) expires_at = Column(DateTime(timezone=True), nullable=False) - - # Timestamps - created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - + def __repr__(self): return f"" -class LicenseUsage(Base): +class LicenseUsage(Base, TimestampMixin): """ Modelo para tracking de uso de licencia """ + __tablename__ = "license_usage" - __table_args__ = {"schema": "a76"} - + __table_args__ = {"schema": "core"} + id = Column(Integer, primary_key=True, index=True) - tenant_id = Column(Integer, ForeignKey("a76.tenants.id"), nullable=False, index=True) - + tenant_id = Column( + Integer, ForeignKey("core.tenants.id"), nullable=False, index=True + ) + # Métricas de uso period_start = Column(DateTime(timezone=True), nullable=False) period_end = Column(DateTime(timezone=True), nullable=False) - + active_users = Column(Integer, default=0) storage_used_gb = Column(Integer, default=0) operations_count = Column(Integer, default=0) api_calls_count = Column(Integer, default=0) - - # Timestamps - created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - + def __repr__(self): return f"" diff --git a/backend/api/v1/modules/a76/licenses/routes.py b/backend/api/v1/modules/core/licenses/routes.py similarity index 88% rename from backend/api/v1/modules/a76/licenses/routes.py rename to backend/api/v1/modules/core/licenses/routes.py index 2703840b..d88b4ae6 100644 --- a/backend/api/v1/modules/a76/licenses/routes.py +++ b/backend/api/v1/modules/core/licenses/routes.py @@ -1,32 +1,33 @@ """ Endpoints API para gestión de licencias """ -from fastapi import APIRouter, Depends, HTTPException, Request -from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + from .dto import ( LicenseCreateDTO, - LicenseUpdateDTO, LicenseResponseDTO, + LicenseUpdateDTO, + LicenseUsageResponseDTO, LicenseValidationResponseDTO, - LicenseUsageResponseDTO ) from .service import LicenseService -router = APIRouter(prefix="/licenses", tags=["Licenses"]) +router = APIRouter(prefix="/licenses") @router.post("/", response_model=LicenseResponseDTO, status_code=201) async def create_license( license_data: LicenseCreateDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): """ Crea una nueva licencia para un tenant - + Requiere rol: admin """ service = LicenseService(db) @@ -37,7 +38,7 @@ async def create_license( async def get_license_by_tenant( tenant_id: int, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), ): """ Obtiene la licencia de un tenant específico @@ -54,11 +55,11 @@ async def update_license( tenant_id: int, license_data: LicenseUpdateDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): """ Actualiza la licencia de un tenant - + Requiere rol: admin """ service = LicenseService(db) @@ -72,7 +73,7 @@ async def update_license( async def validate_license( tenant_id: int, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), ): """ Valida si la licencia de un tenant está activa y vigente @@ -86,7 +87,7 @@ async def validate_license( async def get_license_usage( tenant_id: int, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), ): """ Obtiene el uso actual de la licencia de un tenant @@ -102,7 +103,7 @@ async def get_license_usage( async def get_my_license( request: Request, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), ): """ Obtiene la licencia del tenant del usuario actual @@ -110,7 +111,7 @@ async def get_my_license( tenant_id = getattr(request.state, "tenant_id", None) if not tenant_id: raise HTTPException(status_code=400, detail="Tenant ID not found in request") - + service = LicenseService(db) license = service.get_license_by_tenant(tenant_id) if not license: diff --git a/backend/api/v1/modules/a76/licenses/service.py b/backend/api/v1/modules/core/licenses/service.py similarity index 81% rename from backend/api/v1/modules/a76/licenses/service.py rename to backend/api/v1/modules/core/licenses/service.py index ef81ded7..8c379e19 100644 --- a/backend/api/v1/modules/a76/licenses/service.py +++ b/backend/api/v1/modules/core/licenses/service.py @@ -1,56 +1,59 @@ """ Servicio de lógica de negocio para licencias """ -from sqlalchemy.orm import Session -from sqlalchemy.exc import IntegrityError -from fastapi import HTTPException -from typing import Optional -from datetime import datetime, timezone -import logging -from .models import License, LicenseUsage, LicensePlan, LicenseStatus +import logging +from datetime import datetime, timezone +from typing import Optional + +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + from .dto import ( LicenseCreateDTO, - LicenseUpdateDTO, LicenseResponseDTO, - LicenseValidationResponseDTO, - LicenseUsageResponseDTO + LicenseUpdateDTO, + LicenseUsageResponseDTO, ) +from .models import License, LicensePlan, LicenseStatus, LicenseUsage logger = logging.getLogger(__name__) class LicenseService: """Servicio para gestión de licencias""" - + def __init__(self, db: Session): self.db = db - + def create_license(self, license_data: LicenseCreateDTO) -> LicenseResponseDTO: """ Crea una nueva licencia para un tenant - + Args: license_data: Datos de la licencia - + Returns: LicenseResponseDTO - + Raises: HTTPException: Si el tenant ya tiene licencia o hay error """ try: # Verificar que el tenant no tenga ya una licencia - existing = self.db.query(License).filter( - License.tenant_id == license_data.tenant_id - ).first() - + existing = ( + self.db.query(License) + .filter(License.tenant_id == license_data.tenant_id) + .first() + ) + if existing: raise HTTPException( status_code=400, - detail=f"Tenant {license_data.tenant_id} already has a license" + detail=f"Tenant {license_data.tenant_id} already has a license", ) - + # Crear licencia db_license = License( tenant_id=license_data.tenant_id, @@ -64,17 +67,15 @@ class LicenseService: feature_integrations=license_data.feature_integrations, feature_dedicated_support=license_data.feature_dedicated_support, starts_at=license_data.starts_at, - expires_at=license_data.expires_at + expires_at=license_data.expires_at, ) - + self.db.add(db_license) self.db.commit() self.db.refresh(db_license) - - logger.info(f"License created for tenant {license_data.tenant_id}") - + return LicenseResponseDTO.model_validate(db_license) - + except IntegrityError as e: self.db.rollback() logger.error(f"IntegrityError creating license: {str(e)}") @@ -85,14 +86,14 @@ class LicenseService: self.db.rollback() logger.error(f"Error creating license: {str(e)}") raise HTTPException(status_code=500, detail="Error creating license") - + def get_license_by_tenant(self, tenant_id: int) -> Optional[LicenseResponseDTO]: """ Obtiene la licencia de un tenant - + Args: tenant_id: ID del tenant - + Returns: LicenseResponseDTO o None si no existe """ @@ -100,22 +101,24 @@ class LicenseService: if not license: return None return LicenseResponseDTO.model_validate(license) - - def update_license(self, tenant_id: int, license_data: LicenseUpdateDTO) -> Optional[LicenseResponseDTO]: + + def update_license( + self, tenant_id: int, license_data: LicenseUpdateDTO + ) -> Optional[LicenseResponseDTO]: """ Actualiza una licencia - + Args: tenant_id: ID del tenant license_data: Datos a actualizar - + Returns: LicenseResponseDTO actualizado o None si no existe """ license = self.db.query(License).filter(License.tenant_id == tenant_id).first() if not license: return None - + # Actualizar campos proporcionados update_data = license_data.model_dump(exclude_unset=True) for field, value in update_data.items(): @@ -123,7 +126,7 @@ class LicenseService: # Convertir enums value = LicensePlan(value) if field == "plan" else LicenseStatus(value) setattr(license, field, value) - + try: self.db.commit() self.db.refresh(license) @@ -133,30 +136,30 @@ class LicenseService: self.db.rollback() logger.error(f"Error updating license for tenant {tenant_id}: {str(e)}") raise HTTPException(status_code=500, detail="Error updating license") - + def validate_license(self, tenant_id: int) -> dict: """ Valida si la licencia de un tenant está activa y vigente - + Args: tenant_id: ID del tenant - + Returns: Dict con información de validación """ license = self.db.query(License).filter(License.tenant_id == tenant_id).first() - + if not license: return { "is_valid": False, "status": "not_found", "plan": None, "expires_at": None, - "reason": "License not found" + "reason": "License not found", } - + now = datetime.now(timezone.utc) - + # Verificar estado if license.status != LicenseStatus.ACTIVE: return { @@ -164,51 +167,54 @@ class LicenseService: "status": license.status.value, "plan": license.plan.value, "expires_at": license.expires_at, - "reason": f"License status is {license.status.value}" + "reason": f"License status is {license.status.value}", } - + # Verificar vigencia if license.expires_at < now: # Auto-actualizar a expirada license.status = LicenseStatus.EXPIRED self.db.commit() - + return { "is_valid": False, "status": "expired", "plan": license.plan.value, "expires_at": license.expires_at, - "reason": "License has expired" + "reason": "License has expired", } - + # Licencia válida return { "is_valid": True, "status": license.status.value, "plan": license.plan.value, "expires_at": license.expires_at, - "reason": None + "reason": None, } - + def get_usage(self, tenant_id: int) -> Optional[LicenseUsageResponseDTO]: """ Obtiene el uso actual de la licencia de un tenant - + Args: tenant_id: ID del tenant - + Returns: LicenseUsageResponseDTO o None """ license = self.db.query(License).filter(License.tenant_id == tenant_id).first() if not license: return None - + # Obtener último registro de uso - usage = self.db.query(LicenseUsage).filter( - LicenseUsage.tenant_id == tenant_id - ).order_by(LicenseUsage.created_at.desc()).first() - + usage = ( + self.db.query(LicenseUsage) + .filter(LicenseUsage.tenant_id == tenant_id) + .order_by(LicenseUsage.created_at.desc()) + .first() + ) + if not usage: # Crear registro inicial si no existe usage = LicenseUsage( @@ -218,14 +224,26 @@ class LicenseService: active_users=0, storage_used_gb=0, operations_count=0, - api_calls_count=0 + api_calls_count=0, ) - + # Calcular porcentajes - users_usage = (usage.active_users / license.max_users * 100) if license.max_users > 0 else 0 - storage_usage = (usage.storage_used_gb / license.max_storage_gb * 100) if license.max_storage_gb > 0 else 0 - operations_usage = (usage.operations_count / license.max_monthly_operations * 100) if license.max_monthly_operations > 0 else 0 - + users_usage = ( + (usage.active_users / license.max_users * 100) + if license.max_users > 0 + else 0 + ) + storage_usage = ( + (usage.storage_used_gb / license.max_storage_gb * 100) + if license.max_storage_gb > 0 + else 0 + ) + operations_usage = ( + (usage.operations_count / license.max_monthly_operations * 100) + if license.max_monthly_operations > 0 + else 0 + ) + return LicenseUsageResponseDTO( tenant_id=tenant_id, period_start=usage.period_start, @@ -239,5 +257,5 @@ class LicenseService: max_monthly_operations=license.max_monthly_operations, users_usage_percent=round(users_usage, 2), storage_usage_percent=round(storage_usage, 2), - operations_usage_percent=round(operations_usage, 2) + operations_usage_percent=round(operations_usage, 2), ) diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py new file mode 100644 index 00000000..66b5b0d2 --- /dev/null +++ b/backend/api/v1/modules/core/router.py @@ -0,0 +1,12 @@ +from .auth.routes import router as auth_router +from .licenses.routes import router as licenses_router +from .tenants.routes import router as tenants_router +from .user_tenant.routes import router as user_tenant_router +from fastapi import APIRouter + +router = APIRouter() + +router.include_router(auth_router) +router.include_router(tenants_router, prefix="/core", tags=["core / tenants"]) +router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"]) +router.include_router(licenses_router, prefix="/core", tags=["core / licenses"]) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/tenants/__init__.py b/backend/api/v1/modules/core/tenants/__init__.py similarity index 98% rename from backend/api/v1/modules/a76/tenants/__init__.py rename to backend/api/v1/modules/core/tenants/__init__.py index 7c89dc70..73aa739e 100644 --- a/backend/api/v1/modules/a76/tenants/__init__.py +++ b/backend/api/v1/modules/core/tenants/__init__.py @@ -1,6 +1,7 @@ """ Módulo de Tenants """ + from .routes import router __all__ = ["router"] diff --git a/backend/api/v1/modules/a76/tenants/dto.py b/backend/api/v1/modules/core/tenants/dto.py similarity index 73% rename from backend/api/v1/modules/a76/tenants/dto.py rename to backend/api/v1/modules/core/tenants/dto.py index 9fa9b174..46094676 100644 --- a/backend/api/v1/modules/a76/tenants/dto.py +++ b/backend/api/v1/modules/core/tenants/dto.py @@ -2,29 +2,45 @@ DTOs (Data Transfer Objects) para módulo de tenants Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS """ -from pydantic import BaseModel, Field, EmailStr -from typing import Optional + from datetime import datetime from enum import Enum +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field class TenantTypeDTO(str, Enum): """Tipo de tenant""" + SHARED = "shared" DEDICATED = "dedicated" class TenantCreateDTO(BaseModel): """DTO para crear un nuevo tenant""" - name: str = Field(..., min_length=3, max_length=255, description="Nombre del tenant") - slug: str = Field(..., min_length=3, max_length=100, description="Identificador único del tenant") - keycloak_realm: str = Field(..., min_length=3, max_length=255, description="Nombre del realm en Keycloak") - type: TenantTypeDTO = Field(default=TenantTypeDTO.SHARED, description="Tipo de tenant") - - contact_name: Optional[str] = Field(None, max_length=255, description="Nombre de contacto") + + name: str = Field( + ..., min_length=3, max_length=255, description="Nombre del tenant" + ) + slug: str = Field( + ..., min_length=3, max_length=100, description="Identificador único del tenant" + ) + keycloak_realm: str = Field( + ..., min_length=3, max_length=255, description="Nombre del realm en Keycloak" + ) + type: TenantTypeDTO = Field( + default=TenantTypeDTO.SHARED, description="Tipo de tenant" + ) + + contact_name: Optional[str] = Field( + None, max_length=255, description="Nombre de contacto" + ) contact_email: Optional[EmailStr] = Field(None, description="Email de contacto") - contact_phone: Optional[str] = Field(None, max_length=50, description="Teléfono de contacto") - + contact_phone: Optional[str] = Field( + None, max_length=50, description="Teléfono de contacto" + ) + class Config: json_schema_extra = { "example": { @@ -34,30 +50,32 @@ class TenantCreateDTO(BaseModel): "type": "shared", "contact_name": "Juan Pérez", "contact_email": "juan.perez@empresa-abc.com", - "contact_phone": "+52 55 1234 5678" + "contact_phone": "+52 55 1234 5678", } } class TenantUpdateDTO(BaseModel): """DTO para actualizar un tenant""" + name: Optional[str] = Field(None, min_length=3, max_length=255) contact_name: Optional[str] = Field(None, max_length=255) contact_email: Optional[EmailStr] = None contact_phone: Optional[str] = Field(None, max_length=50) is_active: Optional[bool] = None - + class Config: json_schema_extra = { "example": { "name": "Empresa ABC S.A. de C.V. - Actualizado", - "contact_email": "nuevo@empresa-abc.com" + "contact_email": "nuevo@empresa-abc.com", } } class TenantResponseDTO(BaseModel): """DTO para respuesta de tenant""" + id: int name: str slug: str @@ -69,7 +87,7 @@ class TenantResponseDTO(BaseModel): is_active: bool created_at: datetime updated_at: datetime - + class Config: from_attributes = True json_schema_extra = { @@ -84,13 +102,14 @@ class TenantResponseDTO(BaseModel): "contact_phone": "+52 55 1234 5678", "is_active": True, "created_at": "2025-01-15T10:30:00Z", - "updated_at": "2025-01-15T10:30:00Z" + "updated_at": "2025-01-15T10:30:00Z", } } class TenantListResponseDTO(BaseModel): """DTO para lista de tenants""" + tenants: list[TenantResponseDTO] total: int page: int diff --git a/backend/api/v1/modules/a76/tenants/models.py b/backend/api/v1/modules/core/tenants/models.py similarity index 65% rename from backend/api/v1/modules/a76/tenants/models.py rename to backend/api/v1/modules/core/tenants/models.py index 3e564be9..e76fd34e 100644 --- a/backend/api/v1/modules/a76/tenants/models.py +++ b/backend/api/v1/modules/core/tenants/models.py @@ -1,50 +1,62 @@ """ Modelos ORM para gestión de tenants """ -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Enum as SQLEnum -from sqlalchemy.sql import func -from core.database import Base + import enum +from typing import TYPE_CHECKING, List + +from api.v1.common.base_models import TimestampMixin +from core.database import Base +from sqlalchemy import Boolean, Column +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import Integer, String, Text +from sqlalchemy.orm import Mapped, relationship + +if TYPE_CHECKING: + from api.v1.modules.core.user_tenant.models import UserTenant class TenantType(enum.Enum): """Tipo de tenant según tamaño y necesidades""" + SHARED = "shared" # BD compartida DEDICATED = "dedicated" # BD dedicada -class Tenant(Base): +class Tenant(Base, TimestampMixin): """ Modelo de Tenant - Cliente/Organización en el sistema Cada tenant puede tener BD compartida o dedicada """ + __tablename__ = "tenants" - __table_args__ = {"schema": "a76"} - + __table_args__ = {"schema": "core", "extend_existing": True} + id = Column(Integer, primary_key=True, index=True) name = Column(String(255), nullable=False, index=True) slug = Column(String(100), unique=True, nullable=False, index=True) - + # Tipo de tenant (compartido o dedicado) type = Column(SQLEnum(TenantType), default=TenantType.SHARED, nullable=False) - + # Keycloak realm asociado - keycloak_realm = Column(String(255), unique=True, nullable=False) - + keycloak_realm = Column(String(255), nullable=False) + # Configuración de BD dedicada (JSON string o NULL si usa BD compartida) db_config = Column(Text, nullable=True) # JSON: {host, port, name, user, password} - + # Información de contacto contact_name = Column(String(255)) contact_email = Column(String(255)) contact_phone = Column(String(50)) - + # Estado is_active = Column(Boolean, default=True, nullable=False) - - # Timestamps - created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - + + # Relación con UserTenant + user_relations: Mapped[List["UserTenant"]] = relationship( + "UserTenant", back_populates="tenant" + ) + def __repr__(self): return f"" diff --git a/backend/api/v1/modules/a76/tenants/routes.py b/backend/api/v1/modules/core/tenants/routes.py similarity index 81% rename from backend/api/v1/modules/a76/tenants/routes.py rename to backend/api/v1/modules/core/tenants/routes.py index da18099f..ef3c5a5f 100644 --- a/backend/api/v1/modules/a76/tenants/routes.py +++ b/backend/api/v1/modules/core/tenants/routes.py @@ -1,27 +1,32 @@ """ Endpoints API para gestión de tenants """ -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from typing import List from core.database import get_core_db from core.security import get_current_user, has_role -from .dto import TenantCreateDTO, TenantUpdateDTO, TenantResponseDTO, TenantListResponseDTO +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from .dto import ( + TenantCreateDTO, + TenantListResponseDTO, + TenantResponseDTO, + TenantUpdateDTO, +) from .service import TenantService -router = APIRouter(prefix="/tenants", tags=["Tenants"]) +router = APIRouter(prefix="/tenants") @router.post("/", response_model=TenantResponseDTO, status_code=201) async def create_tenant( tenant_data: TenantCreateDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): """ Crea un nuevo tenant en el sistema - + Requiere rol: admin """ service = TenantService(db) @@ -34,29 +39,27 @@ async def list_tenants( page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), active_only: bool = Query(False, description="Solo tenants activos"), db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): """ Lista todos los tenants - + Requiere rol: admin """ service = TenantService(db) skip = (page - 1) * page_size tenants = service.list_tenants(skip=skip, limit=page_size, active_only=active_only) - + # Contar total from .models import Tenant + query = db.query(Tenant) if active_only: - query = query.filter(Tenant.is_active == True) + query = query.filter(Tenant.is_active) total = query.count() - + return TenantListResponseDTO( - tenants=tenants, - total=total, - page=page, - page_size=page_size + tenants=tenants, total=total, page=page, page_size=page_size ) @@ -64,7 +67,7 @@ async def list_tenants( async def get_tenant( tenant_id: int, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), ): """ Obtiene información de un tenant por ID @@ -81,11 +84,11 @@ async def update_tenant( tenant_id: int, tenant_data: TenantUpdateDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): """ Actualiza un tenant - + Requiere rol: admin """ service = TenantService(db) @@ -99,11 +102,11 @@ async def update_tenant( async def delete_tenant( tenant_id: int, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): """ Elimina (desactiva) un tenant - + Requiere rol: admin """ service = TenantService(db) @@ -116,7 +119,7 @@ async def delete_tenant( async def get_tenant_by_slug( slug: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), ): """ Obtiene un tenant por su slug diff --git a/backend/api/v1/modules/a76/tenants/service.py b/backend/api/v1/modules/core/tenants/service.py similarity index 83% rename from backend/api/v1/modules/a76/tenants/service.py rename to backend/api/v1/modules/core/tenants/service.py index 7b19f056..c3169e75 100644 --- a/backend/api/v1/modules/a76/tenants/service.py +++ b/backend/api/v1/modules/core/tenants/service.py @@ -1,44 +1,51 @@ """ Capa de servicio para lógica de negocio de tenants """ -from sqlalchemy.orm import Session -from sqlalchemy.exc import IntegrityError -from fastapi import HTTPException -from typing import List, Optional + import json import logging +from typing import List, Optional +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .dto import TenantCreateDTO, TenantResponseDTO, TenantUpdateDTO from .models import Tenant, TenantType -from .dto import TenantCreateDTO, TenantUpdateDTO, TenantResponseDTO logger = logging.getLogger(__name__) class TenantService: """Servicio para gestión de tenants""" - + def __init__(self, db: Session): self.db = db - + def create_tenant(self, tenant_data: TenantCreateDTO) -> TenantResponseDTO: """ Crea un nuevo tenant en el sistema - + Args: tenant_data: Datos del tenant a crear - + Returns: TenantResponseDTO con información del tenant creado - + Raises: HTTPException: Si el slug o realm ya existen """ try: # Verificar que no exista el slug - existing = self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first() + existing = ( + self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first() + ) if existing: - raise HTTPException(status_code=400, detail=f"Tenant with slug '{tenant_data.slug}' already exists") - + raise HTTPException( + status_code=400, + detail=f"Tenant with slug '{tenant_data.slug}' already exists", + ) + # Crear tenant db_tenant = Tenant( name=tenant_data.name, @@ -48,35 +55,37 @@ class TenantService: contact_name=tenant_data.contact_name, contact_email=tenant_data.contact_email, contact_phone=tenant_data.contact_phone, - is_active=True + is_active=True, ) - + self.db.add(db_tenant) self.db.commit() self.db.refresh(db_tenant) - + logger.info(f"Tenant created: {db_tenant.id} - {db_tenant.name}") - + return TenantResponseDTO.model_validate(db_tenant) - + except IntegrityError as e: self.db.rollback() logger.error(f"IntegrityError creating tenant: {str(e)}") - raise HTTPException(status_code=400, detail="Tenant with this slug or realm already exists") + raise HTTPException( + status_code=400, detail="Tenant with this slug or realm already exists" + ) except HTTPException: raise except Exception as e: self.db.rollback() logger.error(f"Error creating tenant: {str(e)}") raise HTTPException(status_code=500, detail="Error creating tenant") - + def get_tenant(self, tenant_id: int) -> Optional[TenantResponseDTO]: """ Obtiene un tenant por ID - + Args: tenant_id: ID del tenant - + Returns: TenantResponseDTO o None si no existe """ @@ -84,54 +93,58 @@ class TenantService: if not tenant: return None return TenantResponseDTO.model_validate(tenant) - + def get_tenant_by_slug(self, slug: str) -> Optional[TenantResponseDTO]: """Obtiene un tenant por slug""" tenant = self.db.query(Tenant).filter(Tenant.slug == slug).first() if not tenant: return None return TenantResponseDTO.model_validate(tenant) - - def list_tenants(self, skip: int = 0, limit: int = 100, active_only: bool = False) -> List[TenantResponseDTO]: + + def list_tenants( + self, skip: int = 0, limit: int = 100, active_only: bool = False + ) -> List[TenantResponseDTO]: """ Lista todos los tenants - + Args: skip: Número de registros a omitir limit: Número máximo de registros a retornar active_only: Si True, solo retorna tenants activos - + Returns: Lista de TenantResponseDTO """ query = self.db.query(Tenant) - + if active_only: - query = query.filter(Tenant.is_active == True) - + query = query.filter(Tenant.is_active) + tenants = query.offset(skip).limit(limit).all() return [TenantResponseDTO.model_validate(t) for t in tenants] - - def update_tenant(self, tenant_id: int, tenant_data: TenantUpdateDTO) -> Optional[TenantResponseDTO]: + + def update_tenant( + self, tenant_id: int, tenant_data: TenantUpdateDTO + ) -> Optional[TenantResponseDTO]: """ Actualiza un tenant - + Args: tenant_id: ID del tenant a actualizar tenant_data: Datos a actualizar - + Returns: TenantResponseDTO actualizado o None si no existe """ tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() if not tenant: return None - + # Actualizar solo campos proporcionados update_data = tenant_data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(tenant, field, value) - + try: self.db.commit() self.db.refresh(tenant) @@ -141,24 +154,24 @@ class TenantService: self.db.rollback() logger.error(f"Error updating tenant {tenant_id}: {str(e)}") raise HTTPException(status_code=500, detail="Error updating tenant") - + def delete_tenant(self, tenant_id: int) -> bool: """ Elimina (desactiva) un tenant - + Args: tenant_id: ID del tenant a eliminar - + Returns: True si se eliminó, False si no existe """ tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() if not tenant: return False - + # Soft delete: marcar como inactivo tenant.is_active = False - + try: self.db.commit() logger.info(f"Tenant deleted (soft): {tenant_id}") @@ -167,25 +180,27 @@ class TenantService: self.db.rollback() logger.error(f"Error deleting tenant {tenant_id}: {str(e)}") raise HTTPException(status_code=500, detail="Error deleting tenant") - - def upgrade_to_dedicated(self, tenant_id: int, db_config: dict) -> Optional[TenantResponseDTO]: + + def upgrade_to_dedicated( + self, tenant_id: int, db_config: dict + ) -> Optional[TenantResponseDTO]: """ Actualiza un tenant de BD compartida a BD dedicada - + Args: tenant_id: ID del tenant db_config: Configuración de BD dedicada - + Returns: TenantResponseDTO actualizado """ tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() if not tenant: return None - + tenant.type = TenantType.DEDICATED tenant.db_config = json.dumps(db_config) - + try: self.db.commit() self.db.refresh(tenant) diff --git a/backend/api/v1/modules/core/user_tenant/dto.py b/backend/api/v1/modules/core/user_tenant/dto.py new file mode 100644 index 00000000..8c648aad --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/dto.py @@ -0,0 +1,67 @@ +""" +DTOs para gestión de relaciones usuario-tenant +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class AddUserToTenantRequestDTO(BaseModel): + """Request para agregar un usuario a un tenant""" + + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + role: Optional[str] = Field(None, description="Rol del usuario en el tenant") + + +class RemoveUserFromTenantRequestDTO(BaseModel): + """Request para eliminar un usuario de un tenant""" + + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + soft_delete: bool = Field(True, description="Si True, desactiva. Si False, elimina") + + +class UpdateUserRoleRequestDTO(BaseModel): + """Request para actualizar el rol de un usuario en un tenant""" + + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + role: str = Field(..., description="Nuevo rol del usuario") + + +class UserTenantResponseDTO(BaseModel): + """Response con información de relación usuario-tenant""" + + id: int + keycloak_user_id: str + tenant_id: int + is_active: bool + role: Optional[str] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TenantBasicInfoDTO(BaseModel): + """Información básica de un tenant""" + + id: int + name: str + slug: str + is_active: bool + keycloak_realm: str + + class Config: + from_attributes = True + + +class UserTenantsResponseDTO(BaseModel): + """Response con los tenants de un usuario""" + + keycloak_user_id: str + tenants: list[TenantBasicInfoDTO] diff --git a/backend/api/v1/modules/core/user_tenant/models.py b/backend/api/v1/modules/core/user_tenant/models.py new file mode 100644 index 00000000..879f3dd1 --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/models.py @@ -0,0 +1,47 @@ +""" +Modelo de relación entre usuarios (Keycloak) y tenants +""" + +from typing import TYPE_CHECKING, Optional + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import Boolean, ForeignKeyConstraint, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.core.tenants.models import Tenant + + +class UserTenant(Base, TenantScopedMixin, TimestampMixin): + """ + Relación muchos-a-muchos entre usuarios de Keycloak y tenants + + Un usuario puede pertenecer a múltiples tenants + Un tenant puede tener múltiples usuarios + """ + + __tablename__ = "user_tenants" + __table_args__ = ( + UniqueConstraint( + "keycloak_user_id", "tenant_id", "company_id", name="uq_user_tenant" + ), + {"schema": "core", "extend_existing": True}, + ) + + # Primary Key + id: Mapped[int] = mapped_column(primary_key=True, index=True) + + # ID del usuario en Keycloak (UUID string) + keycloak_user_id: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + + # Estado de la relación + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # Información adicional - Rol del usuario en este tenant (opcional) + role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + + # Relación con Tenant + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations") diff --git a/backend/api/v1/modules/core/user_tenant/routes.py b/backend/api/v1/modules/core/user_tenant/routes.py new file mode 100644 index 00000000..63cd1079 --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/routes.py @@ -0,0 +1,141 @@ +""" +Rutas para gestión de relaciones usuario-tenant +""" + +from typing import List + +from core.database import get_core_db +from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from .dto import ( + AddUserToTenantRequestDTO, + RemoveUserFromTenantRequestDTO, + TenantBasicInfoDTO, + UpdateUserRoleRequestDTO, + UserTenantResponseDTO, + UserTenantsResponseDTO, +) +from .service import UserTenantService + +router = APIRouter(prefix="/user-tenants") + + +@router.post("/add", response_model=UserTenantResponseDTO) +def add_user_to_tenant( + data: AddUserToTenantRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Agrega un usuario a un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + result = service.add_user_to_tenant( + keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role + ) + return result + + +@router.post("/remove") +def remove_user_from_tenant( + data: RemoveUserFromTenantRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Elimina un usuario de un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + service.remove_user_from_tenant( + keycloak_user_id=data.keycloak_user_id, + tenant_id=data.tenant_id, + soft_delete=data.soft_delete, + ) + return {"message": "User removed from tenant successfully"} + + +@router.put("/update-role", response_model=UserTenantResponseDTO) +def update_user_role( + data: UpdateUserRoleRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Actualiza el rol de un usuario en un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + result = service.update_user_role_in_tenant( + keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role + ) + return result + + +@router.get("/user/{keycloak_user_id}", response_model=UserTenantsResponseDTO) +def get_user_tenants( + keycloak_user_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene todos los tenants a los que tiene acceso un usuario + + Los usuarios solo pueden ver sus propios tenants, a menos que sean admin + """ + # Verificar que el usuario solo pueda ver sus propios tenants (excepto admin) + if current_user.get("sub") != keycloak_user_id: + # TODO: Verificar si es admin + raise HTTPException( + status_code=403, detail="You can only view your own tenants" + ) + + service = UserTenantService(db) + tenants = service.get_user_tenants(keycloak_user_id) + + return UserTenantsResponseDTO( + keycloak_user_id=keycloak_user_id, + tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants], + ) + + +@router.get("/tenant/{tenant_id}", response_model=List[UserTenantResponseDTO]) +def get_tenant_users( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Obtiene todos los usuarios que tienen acceso a un tenant + + Requiere permisos de administrador del tenant + """ + service = UserTenantService(db) + user_tenants = service.get_tenant_users(tenant_id) + return user_tenants + + +@router.get("/check-access/{keycloak_user_id}/{tenant_id}") +def check_user_access( + keycloak_user_id: str, + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Verifica si un usuario tiene acceso a un tenant + """ + service = UserTenantService(db) + has_access = service.user_has_access_to_tenant(keycloak_user_id, tenant_id) + + return { + "keycloak_user_id": keycloak_user_id, + "tenant_id": tenant_id, + "has_access": has_access, + } diff --git a/backend/api/v1/modules/core/user_tenant/service.py b/backend/api/v1/modules/core/user_tenant/service.py new file mode 100644 index 00000000..d474a672 --- /dev/null +++ b/backend/api/v1/modules/core/user_tenant/service.py @@ -0,0 +1,225 @@ +""" +Servicio para gestionar relaciones entre usuarios y tenants +""" + +import logging +from typing import List, Optional + +from fastapi import HTTPException +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from ..tenants.models import Tenant +from .models import UserTenant + +logger = logging.getLogger(__name__) + + +class UserTenantService: + """Servicio para gestionar acceso de usuarios a tenants""" + + def __init__(self, db: Session): + self.db = db + + def add_user_to_tenant( + self, keycloak_user_id: str, tenant_id: int, role: Optional[str] = None + ) -> UserTenant: + """ + Agrega un usuario a un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + role: Rol opcional del usuario en este tenant + + Returns: + UserTenant creado + """ + # Verificar que el tenant existe + tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + + # Verificar si la relación ya existe + existing = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + ) + ) + .first() + ) + + if existing: + # Si existe pero está inactiva, reactivarla + if not existing.is_active: + existing.is_active = True + existing.role = role + self.db.commit() + self.db.refresh(existing) + return existing + else: + raise HTTPException( + status_code=409, detail="User already has access to this tenant" + ) + + # Crear nueva relación + user_tenant = UserTenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + role=role, + is_active=True, + ) + + self.db.add(user_tenant) + self.db.commit() + self.db.refresh(user_tenant) + return user_tenant + + def remove_user_from_tenant( + self, keycloak_user_id: str, tenant_id: int, soft_delete: bool = True + ) -> bool: + """ + Elimina un usuario de un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + soft_delete: Si True, solo marca como inactivo. Si False, elimina físicamente + + Returns: + True si se eliminó correctamente + """ + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException( + status_code=404, detail="User-tenant relationship not found" + ) + + if soft_delete: + user_tenant.is_active = False + self.db.commit() + else: + self.db.delete(user_tenant) + self.db.commit() + + return True + + def get_user_tenants(self, keycloak_user_id: str) -> List[Tenant]: + """ + Obtiene todos los tenants a los que tiene acceso un usuario + + Args: + keycloak_user_id: ID del usuario en Keycloak + + Returns: + Lista de tenants + """ + user_tenants = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active, + ) + ) + .all() + ) + + tenant_ids = [ut.tenant_id for ut in user_tenants] + + tenants = ( + self.db.query(Tenant) + .filter(and_(Tenant.id.in_(tenant_ids), Tenant.is_active)) + .all() + ) + + return tenants + + def get_tenant_users(self, tenant_id: int) -> List[UserTenant]: + """ + Obtiene todos los usuarios que tienen acceso a un tenant + + Args: + tenant_id: ID del tenant + + Returns: + Lista de relaciones UserTenant + """ + return ( + self.db.query(UserTenant) + .filter(and_(UserTenant.tenant_id == tenant_id, UserTenant.is_active)) + .all() + ) + + def user_has_access_to_tenant(self, keycloak_user_id: str, tenant_id: int) -> bool: + """ + Verifica si un usuario tiene acceso a un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + + Returns: + True si tiene acceso, False en caso contrario + """ + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.is_active, + ) + ) + .first() + ) + + return user_tenant is not None + + def update_user_role_in_tenant( + self, keycloak_user_id: str, tenant_id: int, role: str + ) -> UserTenant: + """ + Actualiza el rol de un usuario en un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + role: Nuevo rol + + Returns: + UserTenant actualizado + """ + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException( + status_code=404, detail="User-tenant relationship not found" + ) + + user_tenant.role = role + self.db.commit() + self.db.refresh(user_tenant) + return user_tenant diff --git a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/dto.py b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/dto.py index fd577ffa..b36e3d0e 100644 --- a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/dto.py +++ b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/dto.py @@ -1,11 +1,12 @@ -from pydantic import BaseModel, Field from typing import Optional +from pydantic import BaseModel, ConfigDict, Field + + class CodePedimentoRegimenDTO(BaseModel): - id: int + id: Optional[int] = None pedimento_code: str = Field(..., min_length=1, max_length=3) regimen_code: Optional[str] = Field(None, min_length=1, max_length=3) type_code: Optional[str] = Field(None, min_length=1, max_length=1) - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/models.py b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/models.py index 6e936f97..9896d4e7 100644 --- a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/models.py +++ b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/models.py @@ -1,31 +1,44 @@ -from typing import Optional -from sqlalchemy import String, Integer, ForeignKey, ForeignKeyConstraint, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, Optional + from core.database import Base +from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from ..pedimento_codes.models import PedimentoCode + from ..pedimento_regimens.models import RegimenPedimento + class CodePedimentoRegimen(Base): - __tablename__ = "code_pedimento_regimens" #GClavePedRegimen + __tablename__ = "code_pedimento_regimens" # GClavePedRegimen __table_args__ = ( - ForeignKeyConstraint(['pedimento_code'], ['public.pedimento_codes.code'], name='fk_codeped'), - ForeignKeyConstraint(['regimen_code'], ['public.pedimento_regimens.code'], name='fk_regimenped'), - PrimaryKeyConstraint('id', name='clave_pedimento_regimens_pkey'), - {"schema": "public"} + ForeignKeyConstraint( + ["pedimento_code"], ["public.pedimento_codes.code"], name="fk_codeped" + ), + ForeignKeyConstraint( + ["regimen_code"], ["public.pedimento_regimens.code"], name="fk_regimenped" + ), + PrimaryKeyConstraint("id", name="clave_pedimento_regimens_pkey"), + {"schema": "public", "extend_existing": True}, ) id: Mapped[int] = mapped_column(Integer) pedimento_code: Mapped[str] = mapped_column(String(3), nullable=False) - regimen_code: Mapped[Optional[str]] = mapped_column(String(3), nullable=False) - type_code: Mapped[Optional[str]] = mapped_column(String(1)) # si aplica un tipo de relación + regimen_code: Mapped[Optional[str]] = mapped_column( + String(3), nullable=False) + type_code: Mapped[Optional[str]] = mapped_column( + String(1) + ) # si aplica un tipo de relación # Relaciones ORM - #GClavePed - pedimento: Mapped['PedimentoCode'] = relationship( - 'PedimentoCode', back_populates='regimens' + # GClavePed + pedimento: Mapped["PedimentoCode"] = relationship( + "PedimentoCode", back_populates="regimens" ) - #GRegimenPed - regimen: Mapped[Optional['RegimenPedimento']] = relationship( - 'RegimenPedimento', back_populates='claves_pedimento' + # GRegimenPed + regimen: Mapped[Optional["RegimenPedimento"]] = relationship( + "RegimenPedimento", back_populates="claves_pedimento" ) def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py index 747045fe..76533e83 100644 --- a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py +++ b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/routes.py @@ -1,29 +1,63 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import CodePedimentoRegimen +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import CodePedimentoRegimenDTO +from .models import CodePedimentoRegimen -router = APIRouter(prefix="/code-pedimento-regimens", tags=["Code Pedimento Regimens"]) +router = APIRouter(prefix="/code-pedimento-regimens") + + +@router.get("/", response_model=Dict[str, Any]) +def list_code_pedimento_regimens( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + code: str = Query(None, description="Filter by code"), + regime: str = Query(None, description="Filter by regime"), + type: str = Query(None, description="Filter by type"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(CodePedimentoRegimen) + + if code is not None: + query = query.filter(CodePedimentoRegimen.pedimento_code == code) + if regime is not None: + query = query.filter(CodePedimentoRegimen.regime == regime) + if type is not None: + query = query.filter(CodePedimentoRegimen.type == type) + + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [CodePedimentoRegimenDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[CodePedimentoRegimenDTO]) -def list_code_pedimento_regimens(db: Session = Depends(get_core_db)): - objs = db.query(CodePedimentoRegimen).all() - return [CodePedimentoRegimenDTO.model_validate(obj) for obj in objs] @router.get("/{id}", response_model=CodePedimentoRegimenDTO) -def get_code_pedimento_regimen(id: int, db: Session = Depends(get_core_db)): +def get_code_pedimento_regimen( + id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(CodePedimentoRegimen).filter(CodePedimentoRegimen.id == id).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return CodePedimentoRegimenDTO.model_validate(obj) + @router.post("/", response_model=CodePedimentoRegimenDTO, status_code=201) def create_code_pedimento_regimen( data: CodePedimentoRegimenDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = CodePedimentoRegimen(**data.model_dump()) db.add(obj) @@ -31,12 +65,13 @@ def create_code_pedimento_regimen( db.refresh(obj) return CodePedimentoRegimenDTO.model_validate(obj) + @router.put("/{id}", response_model=CodePedimentoRegimenDTO) def update_code_pedimento_regimen( id: int, data: CodePedimentoRegimenDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(CodePedimentoRegimen).filter(CodePedimentoRegimen.id == id).first() if not obj: @@ -47,11 +82,12 @@ def update_code_pedimento_regimen( db.refresh(obj) return CodePedimentoRegimenDTO.model_validate(obj) + @router.delete("/{id}", status_code=204) def delete_code_pedimento_regimen( id: int, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(CodePedimentoRegimen).filter(CodePedimentoRegimen.id == id).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py index f71a7600..1df2120c 100644 --- a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py +++ b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py @@ -33,8 +33,8 @@ seed = [ ("H1", "EXD", "E"), ("H8", "EXD", "E"), ("I1", "EXD", "E"), - #("J1", "EXD", "E"), - #("J2", "EXD", "E"), + # ("J1", "EXD", "E"), + # ("J2", "EXD", "E"), ("K1", "EXD", "E"), ("K2", "EXD", "E"), ("K3", "EXD", "E"), @@ -53,7 +53,7 @@ seed = [ ("A1", "IMD", "I"), ("A3", "IMD", "I"), ("C1", "IMD", "I"), - #("C2", "IMD", "I"), + # ("C2", "IMD", "I"), ("C3", "IMD", "I"), ("D1", "IMD", "I"), ("F3", "IMD", "I"), @@ -78,19 +78,19 @@ seed = [ ("V9", "IMD", "I"), ("VF", "IMD", "I"), ("VU", "IMD", "I"), - #("A2", "ITE", "I"), - #("A8", "ITE", "I"), - #("AA", "ITE", "I"), + # ("A2", "ITE", "I"), + # ("A8", "ITE", "I"), + # ("AA", "ITE", "I"), ("AF", "ITE", "I"), ("E1", "ITE", "I"), ("E3", "ITE", "I"), - #("H3", "ITE", "I"), + # ("H3", "ITE", "I"), ("IN", "ITE", "I"), ("R1", "ITE", "I"), ("V1", "ITE", "I"), ("A6", "ITR", "I"), - #("A7", "ITR", "I"), - #("A9", "ITR", "I"), + # ("A7", "ITR", "I"), + # ("A9", "ITR", "I"), ("AD", "ITR", "I"), ("AF", "ITR", "I"), ("AJ", "ITR", "I"), @@ -104,7 +104,7 @@ seed = [ ("BP", "ITR", "I"), ("E2", "ITR", "I"), ("E4", "ITR", "I"), - #("H3", "ITR", "I"), + # ("H3", "ITR", "I"), ("R1", "ITR", "I"), ("V1", "ITR", "I"), ("V4", "ITR", "I"), @@ -120,5 +120,5 @@ seed = [ ("T3", "TRA", "I"), ("T6", "TRA", "E"), ("T7", "TRA", "I"), - ("T9", "TRA", "I") -] \ No newline at end of file + ("T9", "TRA", "I"), +] diff --git a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/test_code_pedimento_regimens.py b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/test_code_pedimento_regimens.py new file mode 100644 index 00000000..4f9f6041 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/test_code_pedimento_regimens.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.code_pedimento_regimens.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_code_pedimento_regimens(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/code-pedimento-regimens/", 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_code_pedimento_regimen_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/code-pedimento-regimens/999999", headers=headers) + assert response.status_code == 404 + + +def test_create_code_pedimento_regimen_forbidden(): + response = client.post( + "/code-pedimento-regimens/", json={"id": 999999, "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_code_pedimento_regimen_forbidden(): + response = client.put( + "/code-pedimento-regimens/999999", json={"id": 999999, "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_code_pedimento_regimen_forbidden(): + response = client.delete("/code-pedimento-regimens/999999") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/conftest.py b/backend/api/v1/modules/public/reference_data/conftest.py new file mode 100644 index 00000000..2b5b3377 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/conftest.py @@ -0,0 +1,17 @@ +import pytest +from api.v1.modules.public.reference_data.transport_types.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture(scope="session") +def access_token(): + # Reemplaza este token por uno válido generado por Keycloak + return "aqui-va-tu-token-valido" + + +@pytest.fixture(scope="session") +def client(): + app = FastAPI() + app.include_router(router) + return TestClient(app) diff --git a/backend/api/v1/modules/public/reference_data/containers/dto.py b/backend/api/v1/modules/public/reference_data/containers/dto.py index aa82cd78..e16fea52 100644 --- a/backend/api/v1/modules/public/reference_data/containers/dto.py +++ b/backend/api/v1/modules/public/reference_data/containers/dto.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class ContainerDTO(BaseModel): key: str = Field(..., min_length=1, max_length=3) description: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/containers/models.py b/backend/api/v1/modules/public/reference_data/containers/models.py index c53cebac..b8d3c630 100644 --- a/backend/api/v1/modules/public/reference_data/containers/models.py +++ b/backend/api/v1/modules/public/reference_data/containers/models.py @@ -1,16 +1,21 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class Container(Base): - __tablename__ = "containers" #GContenedores + __tablename__ = "containers" # GContenedores __table_args__ = ( PrimaryKeyConstraint("key", name="containers_pkey"), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, # opcional ) - key: Mapped[str] = mapped_column(String(3), nullable=False) # mantiene ceros iniciales - description: Mapped[str] = mapped_column(String(500), nullable=False) # descripción legal en español + key: Mapped[str] = mapped_column( + String(3), nullable=False + ) # mantiene ceros iniciales + description: Mapped[str] = mapped_column( + String(500), nullable=False + ) # descripción legal en español def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/api/v1/modules/public/reference_data/containers/routes.py b/backend/api/v1/modules/public/reference_data/containers/routes.py index 13c8a147..ea0f18e1 100644 --- a/backend/api/v1/modules/public/reference_data/containers/routes.py +++ b/backend/api/v1/modules/public/reference_data/containers/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import Container +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import ContainerDTO +from .models import Container -router = APIRouter(prefix="/containers", tags=["Containers"]) +router = APIRouter(prefix="/containers") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_containers( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(Container) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [ContainerDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[ContainerDTO]) -def list_containers(db: Session = Depends(get_core_db)): - return db.query(Container).all() @router.get("/{key}", response_model=ContainerDTO) -def get_container(key: str, db: Session = Depends(get_core_db)): +async def get_container( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(Container).filter(Container.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=ContainerDTO, status_code=201) -def create_container( +async def create_container( data: ContainerDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = Container(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_container( db.refresh(obj) return obj + @router.put("/{key}", response_model=ContainerDTO) -def update_container( +async def update_container( key: str, data: ContainerDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Container).filter(Container.key == key).first() if not obj: @@ -46,11 +71,12 @@ def update_container( db.refresh(obj) return obj + @router.delete("/{key}", status_code=204) -def delete_container( +async def delete_container( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Container).filter(Container.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/containers/seed.py b/backend/api/v1/modules/public/reference_data/containers/seed.py index 22e85145..84ceb7bf 100644 --- a/backend/api/v1/modules/public/reference_data/containers/seed.py +++ b/backend/api/v1/modules/public/reference_data/containers/seed.py @@ -1,13 +1,13 @@ seed = [ - ("1", "CONTENEDOR ESTANDAR 20' (STANDARD CONTAINER 20')."), - ("2", "CONTENEDOR ESTANDAR 40' (STANDARD CONTAINER 40')."), - ("3", "CONTENEDOR ESTANDAR DE CUBO ALTO 40' (HIGH CUBE STANDARD CONTAINER 40')."), - ("4", "CONTENEDOR TAPA DURA 20’ (HARDTOP CONTAINER 20')."), - ("5", "CONTENEDOR TAPA DURA 40’ (HARDTOP CONTAINER 40')."), - ("6", "CONTENEDOR TAPA ABIERTA 20’ (OPEN TOP CONTAINER 20')."), - ("7", "CONTENEDOR TAPA ABIERTA 40' (OPEN TOP CONTAINER 40')."), - ("8", "FLAT 20' (FLAT 20')."), - ("9", "FLAT 40' (FLAT 40')."), + ("1", "CONTENEDOR ESTANDAR 20' (STANDARD CONTAINER 20')."), + ("2", "CONTENEDOR ESTANDAR 40' (STANDARD CONTAINER 40')."), + ("3", "CONTENEDOR ESTANDAR DE CUBO ALTO 40' (HIGH CUBE STANDARD CONTAINER 40')."), + ("4", "CONTENEDOR TAPA DURA 20’ (HARDTOP CONTAINER 20')."), + ("5", "CONTENEDOR TAPA DURA 40’ (HARDTOP CONTAINER 40')."), + ("6", "CONTENEDOR TAPA ABIERTA 20’ (OPEN TOP CONTAINER 20')."), + ("7", "CONTENEDOR TAPA ABIERTA 40' (OPEN TOP CONTAINER 40')."), + ("8", "FLAT 20' (FLAT 20')."), + ("9", "FLAT 40' (FLAT 40')."), ("10", "PLATAFORMA 20' (PLATFORM 20')."), ("11", "PLATAFORMA 40' (PLATFORM 40')."), ("12", "CONTENEDOR VENTILADO 20’ (VENTILATED CONTAINER 20')."), @@ -15,9 +15,12 @@ seed = [ ("14", "CONTENEDOR TERMICO 40' (INSULATED CONTAINER 40')."), ("15", "CONTENEDOR REFRIGERANTE 20’ (REFRIGERATED CONTAINER 20')."), ("16", "CONTENEDOR REFRIGERANTE 40’ (REFRIGERATED CONTAINER 40')."), - ("17", "CONTENEDOR REFRIGERANTE CUBO ALTO 40’ (HIGH CUBE REFRIGERATED CONTAINER 40')."), + ( + "17", + "CONTENEDOR REFRIGERANTE CUBO ALTO 40’ (HIGH CUBE REFRIGERATED CONTAINER 40').", + ), ("18", "CONTENEDOR CARGA A GRANEL 20’ (BULK CONTAINER 20')."), - ("19", "CONTENEDOR TIPO TANQUE 20’ (TANK CONTAINER 20')."), + ("19", "CONTENEDOR TIPO TANQUE 20’ (TANK CONTAINER 20')."), ("20", "CONTENEDOR ESTANDAR 45' (STANDARD CONTAINER 45')."), ("21", "CONTENEDOR ESTANDAR 48' (STANDARD CONTAINER 48')."), ("22", "CONTENEDOR ESTANDAR 53' (STANDARD CONTAINER 53')."), @@ -27,7 +30,7 @@ seed = [ ("26", "SEMIRREMOLQUE CON RACKS PARA ENVASES DE BEBIDAS."), ("27", "SEMIRREMOLQUE CUELLO DE GANZO."), ("28", "SEMIRREMOLQUE TOLVA CUBIERTO."), - ("29", "SEMIRREMOLQUE TOLVA (ABIERTO)."), + ("29", "SEMIRREMOLQUE TOLVA (ABIERTO)."), ("30", "AUTO-TOLVA CUBIERTO/DESCARGA NEUMATICA."), ("31", "SEMIRREMOLQUE CHASIS."), ("32", "SEMIRREMOLQUE AUTOCARGABLE (CON SISTEMA DE ELEVACION)."), @@ -37,7 +40,7 @@ seed = [ ("36", "PLATAFORMA DE 28’."), ("37", "PLATAFORMA DE 45’."), ("38", "PLATAFORMA DE 48’."), - ("39", "SEMIRREMOLQUE PARA TRANSPORTE DE CABALLOS."), + ("39", "SEMIRREMOLQUE PARA TRANSPORTE DE CABALLOS."), ("40", "SEMIRREMOLQUE PARA TRANSPORTE DE GANADO."), ("41", "SEMIRREMOLQUE TANQUE (LIQUIDOS)/SIN CALEFACCION/SIN AISLAR."), ("42", "SEMIRREOLQUE TANQUE (LIQUIDOS)/CON CALEFACCION/SIN AISLAR."), @@ -47,7 +50,7 @@ seed = [ ("46", "SEMIRREMOLQUE TANQUE (GAS)/CON CALEFACCION/SIN AISLAR."), ("47", "SEMIRREMOLQUE TANQUE (GAS)/SIN CALEFACCION/AISLADO."), ("48", "SEMIRREMOLQUE TANQUE (GAS)/CON CALEFACCION/AISLADO."), - ("49", "SEMIRREMOLQUE TANQUE (QUIMICOS)/SIN CALEFACCION/SIN AISLAR."), + ("49", "SEMIRREMOLQUE TANQUE (QUIMICOS)/SIN CALEFACCION/SIN AISLAR."), ("50", "SEMIRREMOLQUE TANQUE (QUIMICOS)/CON CALEFACCION/SIN AISLAR."), ("51", "SEMIRREMOLQUE TANQUE (QUIMICOS)/SIN CALEFACCION/AISLADO."), ("52", "SEMIRREMOLQUE TANQUE (QUIMICOS)/CON CALEFACCION/AISLADO."), @@ -68,4 +71,4 @@ seed = [ ("67", "CAMIÓN UNITARIO DE TRES EJES"), ("68", "VEHÍCULOS CON CAPACIDAD DE CARGA DE HASTA 3.5. TONELADAS"), ("69", "TRACTOCAMIÓN"), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/containers/test_containers.py b/backend/api/v1/modules/public/reference_data/containers/test_containers.py new file mode 100644 index 00000000..3527be0c --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/containers/test_containers.py @@ -0,0 +1,40 @@ +import pytest +from api.v1.modules.public.reference_data.containers.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_containers(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/containers/", 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_container_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/containers/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_container_forbidden(): + response = client.post("/containers/", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_update_container_forbidden(): + response = client.put("/containers/TST", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_delete_container_forbidden(): + response = client.delete("/containers/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/countries/dto.py b/backend/api/v1/modules/public/reference_data/countries/dto.py index 0a261ca5..3b197100 100644 --- a/backend/api/v1/modules/public/reference_data/countries/dto.py +++ b/backend/api/v1/modules/public/reference_data/countries/dto.py @@ -1,4 +1,5 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class CountryDTO(BaseModel): m3_key: str = Field(..., min_length=1, max_length=3) @@ -7,6 +8,4 @@ class CountryDTO(BaseModel): description_es: str description_en: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/countries/models.py b/backend/api/v1/modules/public/reference_data/countries/models.py index 6e434af2..7ddcf47c 100644 --- a/backend/api/v1/modules/public/reference_data/countries/models.py +++ b/backend/api/v1/modules/public/reference_data/countries/models.py @@ -1,20 +1,29 @@ -from sqlalchemy import String, PrimaryKeyConstraint, Index -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import Index, PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class Country(Base): - __tablename__ = "countries" #GPaises + __tablename__ = "countries" # GPaises __table_args__ = ( PrimaryKeyConstraint("m3_key", name="countries_pkey"), Index("ak_country_ame", "ame_key", unique=True), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, # opcional ) - m3_key: Mapped[str] = mapped_column(String(3), nullable=False) # clave M3 - mex_key: Mapped[str] = mapped_column(String(2), nullable=False) # clave país México - ame_key: Mapped[str] = mapped_column(String(2), nullable=False) # clave país América / regional - description_es: Mapped[str] = mapped_column(String(50), nullable=False) # nombre oficial en español - description_en: Mapped[str] = mapped_column(String(50), nullable=False) # nombre en inglés para UI + m3_key: Mapped[str] = mapped_column( + String(3), primary_key=True, nullable=False) # clave M3 + mex_key: Mapped[str] = mapped_column( + String(2), nullable=False) # clave país México + ame_key: Mapped[str] = mapped_column( + String(2), nullable=False + ) # clave país América / regional + description_es: Mapped[str] = mapped_column( + String(50), nullable=False + ) # nombre oficial en español + description_en: Mapped[str] = mapped_column( + String(50), nullable=False + ) # nombre en inglés para UI def __repr__(self): return f"" diff --git a/backend/api/v1/modules/public/reference_data/countries/routes.py b/backend/api/v1/modules/public/reference_data/countries/routes.py index 7282e6b7..626028b9 100644 --- a/backend/api/v1/modules/public/reference_data/countries/routes.py +++ b/backend/api/v1/modules/public/reference_data/countries/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import Country +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import CountryDTO +from .models import Country -router = APIRouter(prefix="/countries", tags=["Countries"]) +router = APIRouter(prefix="/countries") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_countries( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(Country) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [CountryDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[CountryDTO]) -def list_countries(db: Session = Depends(get_core_db)): - return db.query(Country).all() @router.get("/{m3_key}", response_model=CountryDTO) -def get_country(m3_key: str, db: Session = Depends(get_core_db)): +async def get_country( + m3_key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(Country).filter(Country.m3_key == m3_key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=CountryDTO, status_code=201) -def create_country( +async def create_country( data: CountryDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = Country(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_country( db.refresh(obj) return obj + @router.put("/{m3_key}", response_model=CountryDTO) -def update_country( +async def update_country( m3_key: str, data: CountryDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Country).filter(Country.m3_key == m3_key).first() if not obj: @@ -46,11 +71,12 @@ def update_country( db.refresh(obj) return obj + @router.delete("/{m3_key}", status_code=204) -def delete_country( +async def delete_country( m3_key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Country).filter(Country.m3_key == m3_key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/countries/seed.py b/backend/api/v1/modules/public/reference_data/countries/seed.py index 33fce2d3..bba72c82 100644 --- a/backend/api/v1/modules/public/reference_data/countries/seed.py +++ b/backend/api/v1/modules/public/reference_data/countries/seed.py @@ -1,15 +1,45 @@ seed = [ - ("ABW", "A0", "AW", "Aruba (Territorio Holandes de Ultramar)", "Aruba (Netherlands Territory)"), - ("AFG", "A1", "AF", "Afganistan (Emirato Islamico De)", "Afghanistan (Islamic Emirate of)"), + ( + "ABW", + "A0", + "AW", + "Aruba (Territorio Holandes de Ultramar)", + "Aruba (Netherlands Territory)", + ), + ( + "AFG", + "A1", + "AF", + "Afganistan (Emirato Islamico De)", + "Afghanistan (Islamic Emirate of)", + ), ("AGO", "A8", "AO", "Angola ( Republica De )", "Angola (People's Republic of )"), ("AIA", "AI", "AI", "Anguila", "Anguilla"), - ("ALB", "A2", "AL", "Albania ( Republica De)", "Albania (People's Socialist Republic)"), + ( + "ALB", + "A2", + "AL", + "Albania ( Republica De)", + "Albania (People's Socialist Republic)", + ), ("AND", "A7", "AD", "Andorra (Principado De)", "Andorra (Principated of)"), - ("ANT", "B1", "AN", "Antillas Neerlandesas (Terr. Holandes de Ultramar)", "Antilles Netherlands"), + ( + "ANT", + "B1", + "AN", + "Antillas Neerlandesas (Terr. Holandes de Ultramar)", + "Antilles Netherlands", + ), ("ARE", "G6", "AE", "Emiratos Arabes Unidos", "United Arab Emirates"), ("ARG", "B4", "AR", "Argentina ( Republica )", "Argentina (Republic of)"), ("ARM", "AM", "AM", "Armenia (Republica De)", "Armenia (Republic of)"), - ("ATG", "A9", "AG", "Antigua Y Barbuda (Com. Britanica de Naciones)", "Antigua & Barbuda (Brithish Community)"), + ( + "ATG", + "A9", + "AG", + "Antigua Y Barbuda (Com. Britanica de Naciones)", + "Antigua & Barbuda (Brithish Community)", + ), ("AUS", "B5", "AU", "Australia ( Comunidad De )", "Australia (Community of)"), ("AUT", "B6", "AT", "Austria ( Republica De )", "Austria (Republic of)"), ("AZE", "AZ", "AZ", "Azerbaijan (Republica Azerbaijani)", "Azerbaijan"), @@ -17,7 +47,13 @@ seed = [ ("BEL", "C2", "BE", "Belgica ( Reino De )", "Belgium (Kingdom of)"), ("BEN", "F9", "BJ", "Benin ( Republica De)", "Benin (People's Republic of)"), ("BFA", "A6", "BF", "Burkina Faso", "Burkina Faso"), - ("BGD", "B9", "BD", "Bangladesh ( Republica Popular De )", "Bangladesh (People's Republic of)"), + ( + "BGD", + "B9", + "BD", + "Bangladesh ( Republica Popular De )", + "Bangladesh (People's Republic of)", + ), ("BGR", "D1", "BG", "Bulgaria ( Republica De )", "Bulgaria (Republic of)"), ("BHR", "B8", "BH", "Bahrein ( Estado De )", "Bahrain (State of)"), ("BHS", "B7", "BS", "Bahamas( Comunidad De Las )", "Bahamas (Community of the )"), @@ -26,20 +62,50 @@ seed = [ ("BLZ", "C3", "BZ", "Belice", "Belize"), ("BMU", "C4", "BM", "Bermudas", "Bermuda"), ("BOL", "C6", "BO", "Bolivia ( Republica De )", "Bolivia (Republic of)"), - ("BRA", "C8", "BR", "Brasil (Republica Federativa De)", "Brazil (Federative Republic of)"), - ("BRB", "C1", "BB", "Barbados (Comunidad Britanica de Naciones)", "Barbados (Brithish Community of Nations)"), + ( + "BRA", + "C8", + "BR", + "Brasil (Republica Federativa De)", + "Brazil (Federative Republic of)", + ), + ( + "BRB", + "C1", + "BB", + "Barbados (Comunidad Britanica de Naciones)", + "Barbados (Brithish Community of Nations)", + ), ("BRN", "C9", "BN", "Brunei (Estado De)(Residencia de Paz)", "Brunei (State of)"), ("BTN", "D3", "BT", "Butan (Reino De )", "Bhutan (Royal Goverment of)"), ("BUR", "BU", "BU", "Burma ( Birmania )", "Burma (Birmany)"), ("BWA", "C7", "BW", "Bostwana ( Republica De )", "Botswana (Republic of)"), ("CAF", "CF", "RB", "Republica Centro Africana", "Central African Republic"), ("CAN", "D9", "CA", "Canada", "Canada"), - ("CCK", "E3", "CC", "Cocos ( Keeling, Islas Australianas)", "Cocos Keeling Islands (Australian Island"), + ( + "CCK", + "E3", + "CC", + "Cocos ( Keeling, Islas Australianas)", + "Cocos Keeling Islands (Australian Island", + ), ("CHE", "U8", "CH", "Suiza (Confederacion)", "Switzerland (Confederation)"), ("CHL", "F6", "CL", "Chile ( Republica De )", "Chile (Republic of)"), - ("CHN", "Z3", "CN", "China ( Republica Popular) Derogado", "China (People's Republic of)"), + ( + "CHN", + "Z3", + "CN", + "China ( Republica Popular) Derogado", + "China (People's Republic of)", + ), ("CIA", "E2", "VA", "Ciudad Del Vaticano ( Estado De La )", "Vatican City State"), - ("CIV", "F1", "CT", "Costa de Marfil (Republica De La)", "Ivory Coast (Republic of)"), + ( + "CIV", + "F1", + "CT", + "Costa de Marfil (Republica De La)", + "Ivory Coast (Republic of)", + ), ("CMR", "D8", "CM", "Camerun ( Republica Del )", "Cameroon (Republic of the)"), ("COG", "E6", "CG", "Congo ( Republica Del )", " Congo (Republic of the)"), ("COK", "E7", "CK", "Cook ( Islas )", "Cook Islands"), @@ -48,50 +114,134 @@ seed = [ ("CPV", "D4", "CV", "Cabo Verde ( Republica De )", "Cape Verde (Republic of)"), ("CRI", "F2", "CR", "Costa Rica ( Republica De )", "Costa Rica (Republic of)"), ("CUB", "F3", "CU", "Cuba ( Republica De )", "Cuba (Republic of)"), - ("CUR", "D0", "UR", "Curazao (Terr. Holandes De Ultramar)", "Curazao (Netherlands Territory)"), + ( + "CUR", + "D0", + "UR", + "Curazao (Terr. Holandes De Ultramar)", + "Curazao (Netherlands Territory)", + ), ("CXI", "N8", "CX", "Navidad ( Christmas ) ( Islas )", "Christmas Islands"), ("CYM", "D6", "KY", "Caiman ( Islas )", "Cayman Islands"), ("CYP", "F8", "CY", "Chipre ( Republica De )", "Cyprus (Island of)"), ("CZE", "CZ", "CZ", "Republica Checa", "Czech Federative Republic"), - ("DEU", "A4", "DE", "Alemania ( Republica Federal De )", "Germany (Federal Republic of)"), + ( + "DEU", + "A4", + "DE", + "Alemania ( Republica Federal De )", + "Germany (Federal Republic of)", + ), ("DJI", "V4", "DJ", "Djibouti ( Republica De )", "Djibouti (Republic of)"), ("DMA", "G2", "DM", "Dominica ( Comunidad De )", "Dominica (Community of)"), ("DNK", "G1", "DK", "Dinamarca ( Reino De )", "Denmark (Kingdom of)"), ("DOM", "S2", "DO", "Republica Dominicana", "Dominican Republic"), - ("DSM", "FM", "FM", "Estado Federado De Micronesia", "Micronesia Federated State of"), - ("DZA", "B3", "DZ", "Argelia ( Republica Democratica y Popular de) Dero", "Argelia (People's Democratic Republic)"), + ( + "DSM", + "FM", + "FM", + "Estado Federado De Micronesia", + "Micronesia Federated State of", + ), + ( + "DZA", + "B3", + "DZ", + "Argelia ( Republica Democratica y Popular de) Dero", + "Argelia (People's Democratic Republic)", + ), ("ECU", "G3", "EC", "Ecuador ( Republica Del)", "Ecuador (Republic of the)"), ("EGY", "G4", "EG", "Egipto ( Republica Arabe De )", "Egypt (Arab Republic of)"), ("EMU", "EU", "EU", "Comunidad Europea", "European Economic Community"), ("ERI", "ER", "ER", "Eritrea (Estado De)", "Eritrea (State of)"), - ("ESH", "EH", "EH", "Sahara Occidental (Rep. Arabe Saharavi Dem.)", "Western Sahara (Arab Democratic Rep.)"), + ( + "ESH", + "EH", + "EH", + "Sahara Occidental (Rep. Arabe Saharavi Dem.)", + "Western Sahara (Arab Democratic Rep.)", + ), ("ESP", "G7", "ES", "España ( Reino De )", "Spain (Kingdom of)"), ("EST", "G0", "EE", "Estonia (Republica De)", "Estonia (Republic of)"), - ("ETH", "G9", "ET", "Etiopia ( Republica Democratica Federal)", "Ethiopia (Federal Democratic Republic)"), + ( + "ETH", + "G9", + "ET", + "Etiopia ( Republica Democratica Federal)", + "Ethiopia (Federal Democratic Republic)", + ), ("FIN", "H4", "FI", "Finlandia ( Republica De )", "Finland (Republic of)"), ("FJI", "H1", "FJ", "Fidji (Republica De )", "Fiji Islands"), ("FLK", "FK", "IV", "Islas Malvinas (R.U.)", "Malvine Islands"), ("FRA", "H5", "FR", "Francia (Republica Francesa)", "France (Republic)"), - ("FXA", "TF", "TF", "Territorios Franceses Austriales y Antarticos", "French Territory of Antartic Austral"), + ( + "FXA", + "TF", + "TF", + "Territorios Franceses Austriales y Antarticos", + "French Territory of Antartic Austral", + ), ("GAB", "H6", "GA", "Gabonesa ( Republica )", "Gabonese Republic"), - ("GBR", "R9", "GB", "Reino Unido de la Gran Bretaña e Irlanda del Norte", "United Kingdom (Great Britain, Ireland N"), + ( + "GBR", + "R9", + "GB", + "Reino Unido de la Gran Bretaña e Irlanda del Norte", + "United Kingdom (Great Britain, Ireland N", + ), ("GEO", "GE", "GE", "Georgia (Republica De)", "Georgia (Republic of)"), ("GHA", "H8", "GH", "Ghana ( Republica De )", "Ghana (Republic of)"), ("GIB", "GI", "GI", "Gibraltar (R.U.)", "Gibraltar (U. K.)"), ("GIN", "I8", "GN", "Guinea ( Republica De )", "Guinea (Republic of)"), - ("GLP", "I4", "GP", "Guadalupe (Departamento De)", "Guadeloupe (French Caribean Dependences)"), + ( + "GLP", + "I4", + "GP", + "Guadalupe (Departamento De)", + "Guadeloupe (French Caribean Dependences)", + ), ("GMB", "H7", "GM", "Gambia ( Republica De La)", "Gambia (Republic of)"), - ("GNB", "J1", "GW", "Guinea-Bissau ( Republica De )", "Guinea-Bissau (Republic of)"), - ("GNQ", "I9", "GQ", "Guinea Ecuatorial ( Republica De )", "Equatorial Guinea (Republic of)"), - ("GRC", "I2", "GR", "Grecia (Republica Helenica)", "Greece (Helenical Republic of)"), + ( + "GNB", + "J1", + "GW", + "Guinea-Bissau ( Republica De )", + "Guinea-Bissau (Republic of)", + ), + ( + "GNQ", + "I9", + "GQ", + "Guinea Ecuatorial ( Republica De )", + "Equatorial Guinea (Republic of)", + ), + ( + "GRC", + "I2", + "GR", + "Grecia (Republica Helenica)", + "Greece (Helenical Republic of)", + ), ("GRD", "I1", "GD", "Granada", "Grenada"), ("GRL", "GL", "GL", "Groenlandia (Dinamarca)", "Greenland (Denmark)"), ("GTM", "I6", "GT", "Guatemala ( Republica De )", "Guatemala (Republic of)"), ("GUF", "I7", "GF", "Guyana Francesa", "French Guyana"), ("GUM", "I5", "GU", "Guam ( E.U.A )", "Guam (U.S.A.)"), - ("GUY", "J2", "GY", "Guyana ( Republica Cooperativa De )", "Guyana (Cooperative Republic of)"), + ( + "GUY", + "J2", + "GY", + "Guyana ( Republica Cooperativa De )", + "Guyana (Cooperative Republic of)", + ), ("GZA", "GZ", "GZ", "Franja De Gaza", "Gaza Strip"), - ("HKG", "J6", "HK", "Hong Kong (Region Admiva. Especial de la Rep. )", "Hong Kong (Territory of)"), + ( + "HKG", + "J6", + "HK", + "Hong Kong (Region Admiva. Especial de la Rep. )", + "Hong Kong (Territory of)", + ), ("HND", "J5", "HN", "Honduras ( Republica De )", "Honduras (Republic of)"), ("HRV", "HR", "HR", "Croacia (Republica De)", "Croatia (Republic of)"), ("HTI", "J3", "HT", "Haiti ( Republica De )", "Haiti (Republic of)"), @@ -99,13 +249,25 @@ seed = [ ("IDN", "J9", "ID", "Indonesia ( Republica De )", "Indonesia (Republic of)"), ("IND", "J8", "IN", "India ( Republica De)", "India (Republic of the)"), ("IRL", "K3", "IE", "Irlanda ( Republica De )", "Ireland (Republic of)"), - ("IRN", "K2", "IR", "Iran ( Republica Islamica Del )", "Iran (Islamic Republic of)"), + ( + "IRN", + "K2", + "IR", + "Iran ( Republica Islamica Del )", + "Iran (Islamic Republic of)", + ), ("IRQ", "K1", "IQ", "Irak ( Republica De )", "Iraq (Republic of)"), ("ISL", "K4", "IS", "Islandia ( Republica De )", "Iceland (Republic of)"), ("ISR", "K5", "IL", "Israel ( Estado De )", "Israel (State of)"), ("ITA", "K6", "IT", "Italia (Republica Italiana)", "Italy (Republic)"), ("JAM", "K7", "JM", "Jamaica", "Jamaica"), - ("JOR", "L1", "JO", "Jordania ( Reino Hachemita De )", "Jordan (Hachemite Kingdom of)"), + ( + "JOR", + "L1", + "JO", + "Jordania ( Reino Hachemita De )", + "Jordan (Hachemite Kingdom of)", + ), ("JPN", "K9", "JP", "Japon", "Japan"), ("KAZ", "KZ", "KZ", "Kazakhstan (Republica de)", "Kazakhstan"), ("KCD", "Z9", "PD", "Paises No Declarados", "Not Declared Countries"), @@ -113,49 +275,133 @@ seed = [ ("KGZ", "KG", "KG", "Kyrgyzstan (Republica Kirgyzia)", "Kyrgyzstan"), ("KHM", "D7", "KH", "Camboya (Reino de)", "Cambodia"), ("KIR", "L0", "KI", "Kiribati (Republica de)", "Kiribati"), - ("KNA", "S9", "KN", "San Cristobal Y Nieves (Fed. de)(San Kitts-Nevis)", "St. Christopher - Nevis"), + ( + "KNA", + "S9", + "KN", + "San Cristobal Y Nieves (Fed. de)(San Kitts-Nevis)", + "St. Christopher - Nevis", + ), ("KOR", "E8", "KR", "Corea (Republica De)(Corea del Sur)", "Korea Republic of"), ("KWT", "L3", "KW", "Kuwait (Estado de)", "kuwait"), - ("LAO", "L4", "LA", "Republica Democratica Popular Laos", "Laos (People's Democratic Republic of)"), + ( + "LAO", + "L4", + "LA", + "Republica Democratica Popular Laos", + "Laos (People's Democratic Republic of)", + ), ("LBN", "L7", "LB", "Libano (Republica de)", "Lebanon"), ("LBR", "L8", "LR", "Liberia ( Republica De )", "Liberia (Republic of)"), - ("LBY", "L9", "LY", "Libia (Jamahiriya Libia Araba Pop. Soc.)", "Lybia (Arab Jamahiriya)"), + ( + "LBY", + "L9", + "LY", + "Libia (Jamahiriya Libia Araba Pop. Soc.)", + "Lybia (Arab Jamahiriya)", + ), ("LCA", "T4", "LC", "Santa Lucia", "Saint Lucia"), ("LHM", "HM", "HM", "Islas Heard Y Mcdonald", "Heard & MacDonald Islands"), - ("LIE", "L5", "LI", "Liechtenstein (Principado de)", "Liechtenstein (Principated of)"), - ("LKA", "U4", "LK", "Sri Lanka ( Republica Democratica Soc.)", "Sri Lanka (Socialist Democratic Republic"), + ( + "LIE", + "L5", + "LI", + "Liechtenstein (Principado de)", + "Liechtenstein (Principated of)", + ), + ( + "LKA", + "U4", + "LK", + "Sri Lanka ( Republica Democratica Soc.)", + "Sri Lanka (Socialist Democratic Republic", + ), ("LSO", "L6", "LS", "Lesotho ( Reino De )", "Lesotho (Kingdom of)"), ("LTU", "Y2", "LT", "Lituania (Republica de)", "Lithuania"), - ("LUX", "M0", "LU", "Luxemburgo ( Gran Ducado De)", "Luxembourg (Great Ducated of)"), + ( + "LUX", + "M0", + "LU", + "Luxemburgo ( Gran Ducado De)", + "Luxembourg (Great Ducated of)", + ), ("LVA", "Y1", "LV", "Letonia (Republica de)", "Latvia"), ("MAC", "M1", "MO", "Macao", "Macau"), ("MAR", "M8", "MR", "Marruecos ( Reino De )", "Morocco (Kingdom of)"), ("MCO", "N0", "MC", "Monaco (Principado De)", "Monaco (Principated of)"), ("MDA", "MD", "MD", "Moldavia (Republica de)", "Moldova"), - ("MDG", "M2", "MG", "Madagascar ( Republica De)", "Madagascar (Democratic Republic of)"), + ( + "MDG", + "M2", + "MG", + "Madagascar ( Republica De)", + "Madagascar (Democratic Republic of)", + ), ("MDV", "M5", "MV", "Maldivas ( Republica De )", "Maldives (Republic of the)"), ("MEX", "N3", "MX", "Mexico (Estados Unidos Mexicanos)", "Mexico"), ("MHL", "MH", "MH", "Islas Marshall", "Marshall Islands"), - ("MKD", "MK", "MK", "Macedonia (Antigua Rep. Yugoslava De)", "Macedonia (Old Yugoslavian Republic)"), + ( + "MKD", + "MK", + "MK", + "Macedonia (Antigua Rep. Yugoslava De)", + "Macedonia (Old Yugoslavian Republic)", + ), ("MLI", "M6", "ML", "Mali ( Republica De )", "Mali (Republic of)"), ("MLT", "M7", "MT", "Malta ( Republica De )", "Malta and Gozo (Republic of)"), ("MMR", "C5", "MM", "Myanmar ( Union De )", "Myammar (Union of)"), ("MNE", "ME", "ME", "Montenegro", ""), ("MNG", "N4", "MN", "Mongolia", "Mongolia (People's Republic of)"), - ("MNP", "MP", "IM", "Islas Marianas Septentrionales", "Marianes Septentrional Islands"), - ("MOZ", "N6", "MZ", "Mozambique ( Republica De)", "Mozambique (People's Republic of)"), - ("MRT", "N2", "RT", "Mauritania ( Republica Islamica De )", "Mauritania (Islamic Republic of)"), + ( + "MNP", + "MP", + "IM", + "Islas Marianas Septentrionales", + "Marianes Septentrional Islands", + ), + ( + "MOZ", + "N6", + "MZ", + "Mozambique ( Republica De)", + "Mozambique (People's Republic of)", + ), + ( + "MRT", + "N2", + "RT", + "Mauritania ( Republica Islamica De )", + "Mauritania (Islamic Republic of)", + ), ("MSR", "N5", "MS", "Monserrat ( Isla )", "Montserrat Island"), - ("MTQ", "M9", "MQ", "Martinica (Departamento de) (Francia)", "Martinique (Department of)"), + ( + "MTQ", + "M9", + "MQ", + "Martinica (Departamento de) (Francia)", + "Martinique (Department of)", + ), ("MUS", "N1", "MU", "Mauricio ( Republica De )", "Mauritius (State of)"), ("MWI", "M4", "MW", "Malawi ( Republica De )", "Malawi (Republic of)"), ("MYS", "M3", "MY", "Malasia", "Malaysia (Federation of)"), ("NAM", "P0", "NA", "Namibia ( Republica De )", "Namibia (Republic of)"), - ("NCA", "P7", "TE", "Terr. Frances Ultramar Nueva Caledonia", "French Territory of New Caledonia"), + ( + "NCA", + "P7", + "TE", + "Terr. Frances Ultramar Nueva Caledonia", + "French Territory of New Caledonia", + ), ("NCL", "NC", "NC", "Nueva Caledonia (Terr.Frances de Ultramar)", "New Caledonia"), ("NER", "P2", "NE", "Niger ( Republica De)", "Niger (Federal Republic of)"), ("NFK", "P5", "NF", "Norfolk ( Isla )", "Norfolk Island"), - ("NGA", "P3", "NG", "Nigeria ( Republica Federal De)", "Nigeria (Federal Republic of)"), + ( + "NGA", + "P3", + "NG", + "Nigeria ( Republica Federal De)", + "Nigeria (Federal Republic of)", + ), ("NIC", "P1", "NI", "Nicaragua ( Republica De )", "Nicaragua (Republic of)"), ("NIU", "P4", "NU", "Nive ( Isla )", "Nive Island"), ("NOR", "P6", "NO", "Noruega ( Reino De )", "Norway (Kingdom of)"), @@ -163,27 +409,81 @@ seed = [ ("NRU", "N7", "NR", "Nauru", "Nauru"), ("NZL", "P9", "NZ", "Nueva Zelandia", "New Zealand"), ("OMN", "Q2", "OM", "Oman (Sultanato De )", "Oman (Sultanate of)"), - ("PAK", "Q7", "PK", "Pakistan ( Republica Islamica De )", "Pakistan (Islamic Republic of)"), + ( + "PAK", + "Q7", + "PK", + "Pakistan ( Republica Islamica De )", + "Pakistan (Islamic Republic of)", + ), ("PAN", "Q8", "PA", "Panama ( Republica De )", "Panama (Republic of)"), - ("PCN", "R3", "PN", "Pitcairns ( Islas Dependencia Britanica )", "Pitcairn Island (Brithish Dependence)"), + ( + "PCN", + "R3", + "PN", + "Pitcairns ( Islas Dependencia Britanica )", + "Pitcairn Island (Brithish Dependence)", + ), ("PER", "R2", "PE", "Peru ( Republica Del )", "Peru (Republic of )"), - ("PHL", "H3", "PH", "Filipinas ( Republica De Las )", "Philippines (Republic of the)"), - ("PIK", "Q3", "PI", "Pacifico Islas Del ( Admon. E.U.A. )", "Pacific Islands (U.S.A. Administration)"), + ( + "PHL", + "H3", + "PH", + "Filipinas ( Republica De Las )", + "Philippines (Republic of the)", + ), + ( + "PIK", + "Q3", + "PI", + "Pacifico Islas Del ( Admon. E.U.A. )", + "Pacific Islands (U.S.A. Administration)", + ), ("PLW", "PW", "PW", "Palau (Republica De)", "Palau (Republic of)"), - ("PNG", "P8", "PP", "Papua Nueva Guinea (Edo. Independiente de)", "Papua New Guinea (Independent State of)"), + ( + "PNG", + "P8", + "PP", + "Papua Nueva Guinea (Edo. Independiente de)", + "Papua New Guinea (Independent State of)", + ), ("POL", "R5", "PL", "Polonia ( Republica De )", "Poland (Republic of)"), - ("PRI", "R7", "PR", "Puerto Rico (Edo.Libre Asociado de la Com. de) Der", "Puerto Rico (Free Asociated State of)"), - ("PRK", "E9", "KP", "Corea ( Rep. Pop. Dem.de)(Corea del Norte)", "Korea (North)(People's Democratic Rep.of"), + ( + "PRI", + "R7", + "PR", + "Puerto Rico (Edo.Libre Asociado de la Com. de) Der", + "Puerto Rico (Free Asociated State of)", + ), + ( + "PRK", + "E9", + "KP", + "Corea ( Rep. Pop. Dem.de)(Corea del Norte)", + "Korea (North)(People's Democratic Rep.of", + ), ("PRT", "R6", "PT", "Portugal (Republica Portuguesa)", "Portugal (Republic of)"), ("PRY", "R1", "PY", "Paraguay ( Republica Del )", "Paraguay (Republic of)"), ("PSE", "PS", "PS", "Palestina", ""), ("PTY", "Z2", "ZO", "Zona Del Canal De Panama", "Zone of the Panama's Channel"), ("PYF", "R4", "PF", "Polinesia Francesa", "French Polynesia"), ("QAT", "R8", "QA", "Qatar ( Estado De )", "Qatar (State of)"), - ("REU", "S3", "RE", "Reunion (Departamento de la) ( Francia)", "Reunion Islands (French Department)"), + ( + "REU", + "S3", + "RE", + "Reunion (Departamento de la) ( Francia)", + "Reunion Islands (French Department)", + ), ("RKE", "E1", "RK", "Canal Islas del ( Islas Normandas )", "Channel Islands"), ("ROM", "S5", "RO", "Rumania", "Romania (Republic of)"), - ("RUH", "NT", "NT", "Zona Neutral Iraq-Arabia Saudita", "Neutral Zone of Iraq - Saudi Arabia"), + ( + "RUH", + "NT", + "NT", + "Zona Neutral Iraq-Arabia Saudita", + "Neutral Zone of Iraq - Saudi Arabia", + ), ("RUS", "RU", "RU", "Rusia (Federacion Rusa)", "Russia (Federation)"), ("RWA", "S6", "RW", "Republica Ruandesa", "Rwanda"), ("SAU", "B2", "SA", "Arabia Saudita ( Reino De )", "Saudi Arabia (Kingdom of)"), @@ -191,21 +491,51 @@ seed = [ ("SEN", "T6", "SN", "Senegal ( Republica Del )", "Senegal (Republic of the)"), ("SGP", "U1", "SG", "Singapur ( Republica De )", "Singapore (Republic of)"), ("SHN", "T3", "SH", "Santa Elena", "St. Helena"), - ("SJM", "SJ", "SJ", "Islas Svalbard Y Jan Mayen (Noruega)", "Svalbard & Jan Mayen Islands"), - ("SLB", "SB", "SB", "Islas Salomon (Com. Britanica de Naciones)", "Solomon Islands (Brithish Community)"), + ( + "SJM", + "SJ", + "SJ", + "Islas Svalbard Y Jan Mayen (Noruega)", + "Svalbard & Jan Mayen Islands", + ), + ( + "SLB", + "SB", + "SB", + "Islas Salomon (Com. Britanica de Naciones)", + "Solomon Islands (Brithish Community)", + ), ("SLE", "T8", "SL", "Sierra Leona ( Republica De )", "Sierra Leone (Republic of)"), ("SLV", "G5", "SV", "El Salvador ( Republica De )", "El Salvador (Republic of)"), - ("SMR", "T0", "SM", "San Marino (Serenisima Republica De)", "San Marino (Republic of"), + ( + "SMR", + "T0", + "SM", + "San Marino (Serenisima Republica De)", + "San Marino (Republic of", + ), ("SOM", "U3", "SO", "Somalia", "Somalia (Democratic Republic of)"), ("SPM", "T1", "PM", "San Pedro Y Miquelon", "St. Pierre and Miquelon"), ("SRB", "RS", "RS", "Republica de Serbia", ""), - ("STP", "T5", "ST", "Santo Tome Y Principe (Rep. Democratica de)", "Sao Tome and Principe (Dem. Rep.)"), + ( + "STP", + "T5", + "ST", + "Santo Tome Y Principe (Rep. Democratica de)", + "Sao Tome and Principe (Dem. Rep.)", + ), ("SUR", "U9", "SR", "Suriname ( Republica De )", "Surinam (Republic of)"), ("SVK", "SK", "SK", "Republica Eslovaca", "Slovakia (Republic)"), ("SVN", "SI", "SI", "Eslovenia (Republica De)", "Slovenia (Republic of)"), ("SWE", "U7", "SE", "Suecia ( Reino De )", "Sweden (Kingdom of)"), ("SWZ", "V0", "SZ", "Swazilandia ( Reino De )", "Swaziland (Kingdom of)"), - ("SYC", "T7", "SC", "Seychelles (Republica De Las)", "Seychelles (Republic of the)"), + ( + "SYC", + "T7", + "SC", + "Seychelles (Republica De Las)", + "Seychelles (Republic of the)", + ), ("SYR", "U2", "SY", "Siria ( Republica Arabe )", "Syrian Arab Republic"), ("TCA", "W3", "TC", "Turcas Y Caicos ( Islas )", "Turks and Caicos Islands"), ("TCD", "F4", "TD", "Chad ( Republica De )", "Chad (Republic of)"), @@ -216,30 +546,102 @@ seed = [ ("TKM", "TM", "TM", "Turkmenistan (Republica De)", "Turkmenistan (Republic of)"), ("TMP", "TP", "TP", "Timor Oriental", "East Timor"), ("TON", "TO", "TO", "Tonga (Reino De)", "Tonga (Kingdom of)"), - ("TTO", "W1", "TT", "Trinidad Y Tobago ( Republica De )", "Trinidad and Tobago (Republic of)"), + ( + "TTO", + "W1", + "TT", + "Trinidad Y Tobago ( Republica De )", + "Trinidad and Tobago (Republic of)", + ), ("TUN", "W2", "TN", "Tunez ( Republica De )", "Tunisia (Republic of)"), ("TUR", "W4", "TR", "Turquia ( Republica De )", "Turkey (Republic of)"), - ("TUV", "TV", "TV", "Tuvalu (Comunidad Britanica de Naciones)", "Tuvalu (Brithish Community of Nations)"), + ( + "TUV", + "TV", + "TV", + "Tuvalu (Comunidad Britanica de Naciones)", + "Tuvalu (Brithish Community of Nations)", + ), ("TWN", "F7", "TW", "Taiwan (Republica de China)", "Taiwan"), - ("TZA", "V2", "TZ", "Tanzania ( Republica Unida De )", "Tanzania United Republic of"), + ( + "TZA", + "V2", + "TZ", + "Tanzania ( Republica Unida De )", + "Tanzania United Republic of", + ), ("UGA", "W5", "UG", "Uganda ( Republica De )", "Uganda (Republic of)"), ("UKR", "UA", "UA", "Ucrania", "Ukraine"), - ("URY", "W7", "UY", "Uruguay ( Republica Oriental Del )", "Uruguay (Eastern Republic of the)"), + ( + "URY", + "W7", + "UY", + "Uruguay ( Republica Oriental Del )", + "Uruguay (Eastern Republic of the)", + ), ("USA", "G8", "US", "Estados Unidos de America", "United States of America"), ("UZB", "Y4", "UZ", "Uzbejistan (Republica de)", "Uzbekistan (Republic)"), - ("VCT", "T2", "VC", "San Vicente Y Las Granadinas", "St. Vincent and the Grenadines"), + ( + "VCT", + "T2", + "VC", + "San Vicente Y Las Granadinas", + "St. Vincent and the Grenadines", + ), ("VEN", "W8", "VE", "Venezuela ( Republica De )", "Venezuela (Republic of)"), ("VGB", "X2", "VG", "Virgenes Islas ( Britanicas )", "Virgin Islands (British)"), - ("VIR", "X3", "VI", "Virgenes Islas ( Norteamericanas )", "Virgin Islands (American)"), - ("VNM", "W9", "VN", "Vietnam ( Republica Socialista De )", "Vietnam (Socialist Republic of)"), + ( + "VIR", + "X3", + "VI", + "Virgenes Islas ( Norteamericanas )", + "Virgin Islands (American)", + ), + ( + "VNM", + "W9", + "VN", + "Vietnam ( Republica Socialista De )", + "Vietnam (Socialist Republic of)", + ), ("VUT", "Q1", "VU", "Vanuatu", "Vanuatu"), ("WLF", "WF", "WF", "Islas Wallis Y Futuna", "Wallis & Futuna Islands"), - ("WSM", "S8", "WS", "Samoa (Estado Independiente de)", "Western Samoa (Independent State)"), - ("XCH", "V3", "IO", "Territorios Britanicos Del Oceano Indico", "Brithish Territory of the Indic Ocean"), + ( + "WSM", + "S8", + "WS", + "Samoa (Estado Independiente de)", + "Western Samoa (Independent State)", + ), + ( + "XCH", + "V3", + "IO", + "Territorios Britanicos Del Oceano Indico", + "Brithish Territory of the Indic Ocean", + ), ("YEM", "YE", "YE", "Yemen (Republica De)", "Yemen (Republic of)"), - ("YUG", "X8", "YU", "Yugoslavia (Republica Federal de)", "Yugoslavia (Federal Republic of)"), - ("ZAF", "U5", "ZA", "Sudafrica ( Republica De ) Derogado", "South Africa (Republic of)"), + ( + "YUG", + "X8", + "YU", + "Yugoslavia (Republica Federal de)", + "Yugoslavia (Federal Republic of)", + ), + ( + "ZAF", + "U5", + "ZA", + "Sudafrica ( Republica De ) Derogado", + "South Africa (Republic of)", + ), ("ZMB", "Z1", "ZM", "Zambia ( Republica De )", "Zambia (Republic of)"), ("ZWE", "S4", "ZW", "Zimbabwe ( Republica De )", "Zimbabwe (Republic of)"), - ("ZYA", "J4", "NL", "Paises Bajos ( Reino De Los )(Holanda)", "Netherlands (Kingdom of)(Holand)"), -] \ No newline at end of file + ( + "ZYA", + "J4", + "NL", + "Paises Bajos ( Reino De Los )(Holanda)", + "Netherlands (Kingdom of)(Holand)", + ), +] diff --git a/backend/api/v1/modules/public/reference_data/countries/test_countries.py b/backend/api/v1/modules/public/reference_data/countries/test_countries.py new file mode 100644 index 00000000..ab4da9f8 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/countries/test_countries.py @@ -0,0 +1,42 @@ +import pytest +from api.v1.modules.public.reference_data.countries.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_countries(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/countries/", 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_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/countries/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_country_forbidden(): + response = client.post("/countries/", json={"m3_key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_update_country_forbidden(): + response = client.put( + "/countries/TST", json={"m3_key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_country_forbidden(): + response = client.delete("/countries/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/currency_types/dto.py b/backend/api/v1/modules/public/reference_data/currency_types/dto.py index 1e1cae4f..b78be839 100644 --- a/backend/api/v1/modules/public/reference_data/currency_types/dto.py +++ b/backend/api/v1/modules/public/reference_data/currency_types/dto.py @@ -1,10 +1,9 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class CurrencyTypeDTO(BaseModel): code: str = Field(..., min_length=1, max_length=3) currency_name: str country_description: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/currency_types/models.py b/backend/api/v1/modules/public/reference_data/currency_types/models.py index bb0d119f..b4b507ea 100644 --- a/backend/api/v1/modules/public/reference_data/currency_types/models.py +++ b/backend/api/v1/modules/public/reference_data/currency_types/models.py @@ -1,18 +1,24 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column class CurrencyType(Base): - __tablename__ = "currency_types" #GTiposMoneda + __tablename__ = "currency_types" # GTiposMoneda __table_args__ = ( PrimaryKeyConstraint("code", name="currency_types_pkey"), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, ) - code: Mapped[str] = mapped_column(String(3), nullable=False) # código ISO o clave de moneda - currency_name: Mapped[str] = mapped_column(String(15), nullable=False) # nombre de la moneda (por ejemplo: Peso, Dollar) - country_description: Mapped[str] = mapped_column(String(50)) # país asociado o descripción del país + code: Mapped[str] = mapped_column( + String(3), primary_key=True, nullable=False + ) # código ISO o clave de moneda + currency_name: Mapped[str] = mapped_column( + String(15), nullable=False + ) # nombre de la moneda (por ejemplo: Peso, Dollar) + country_description: Mapped[str] = mapped_column( + String(50) + ) # país asociado o descripción del país def __repr__(self): return f"" diff --git a/backend/api/v1/modules/public/reference_data/currency_types/routes.py b/backend/api/v1/modules/public/reference_data/currency_types/routes.py index 627c2fe9..988bf086 100644 --- a/backend/api/v1/modules/public/reference_data/currency_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/currency_types/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import CurrencyType +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import CurrencyTypeDTO +from .models import CurrencyType -router = APIRouter(prefix="/currency-types", tags=["Currency Types"]) +router = APIRouter(prefix="/currency-types") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_currency_types( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(CurrencyType) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [CurrencyTypeDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[CurrencyTypeDTO]) -def list_currency_types(db: Session = Depends(get_core_db)): - return db.query(CurrencyType).all() @router.get("/{code}", response_model=CurrencyTypeDTO) -def get_currency_type(code: str, db: Session = Depends(get_core_db)): +async def get_currency_type( + code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(CurrencyType).filter(CurrencyType.code == code).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=CurrencyTypeDTO, status_code=201) -def create_currency_type( +async def create_currency_type( data: CurrencyTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = CurrencyType(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_currency_type( db.refresh(obj) return obj + @router.put("/{code}", response_model=CurrencyTypeDTO) -def update_currency_type( +async def update_currency_type( code: str, data: CurrencyTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(CurrencyType).filter(CurrencyType.code == code).first() if not obj: @@ -46,11 +71,12 @@ def update_currency_type( db.refresh(obj) return obj + @router.delete("/{code}", status_code=204) -def delete_currency_type( +async def delete_currency_type( code: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(CurrencyType).filter(CurrencyType.code == code).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/currency_types/seed.py b/backend/api/v1/modules/public/reference_data/currency_types/seed.py index ecef1c54..3385ec31 100644 --- a/backend/api/v1/modules/public/reference_data/currency_types/seed.py +++ b/backend/api/v1/modules/public/reference_data/currency_types/seed.py @@ -93,4 +93,4 @@ seed = [ ("YUD", "DINAR", "YUGOSLAVIA"), ("ZAR", "RAND", "UNION SUDAFRICANA"), ("ZRZ", "FRANCO", "REPUBLICA DEMOCRATICA DEL CONGO"), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/currency_types/test_currency_types.py b/backend/api/v1/modules/public/reference_data/currency_types/test_currency_types.py new file mode 100644 index 00000000..6e98d40a --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/currency_types/test_currency_types.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.currency_types.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_currency_types(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/currency-types/", 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_currency_type_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/currency-types/invalid_code", headers=headers) + assert response.status_code == 404 + + +def test_create_currency_type_forbidden(): + response = client.post( + "/currency-types/", json={"code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_currency_type_forbidden(): + response = client.put( + "/currency-types/TST", json={"code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_currency_type_forbidden(): + response = client.delete("/currency-types/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/customs_sections/dto.py b/backend/api/v1/modules/public/reference_data/customs_sections/dto.py index c317ccd6..410d5028 100644 --- a/backend/api/v1/modules/public/reference_data/customs_sections/dto.py +++ b/backend/api/v1/modules/public/reference_data/customs_sections/dto.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class CustomsSectionDTO(BaseModel): customs_code: str = Field(..., min_length=1, max_length=3) section_name: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/customs_sections/models.py b/backend/api/v1/modules/public/reference_data/customs_sections/models.py index 2ee8a3b5..011967b7 100644 --- a/backend/api/v1/modules/public/reference_data/customs_sections/models.py +++ b/backend/api/v1/modules/public/reference_data/customs_sections/models.py @@ -1,16 +1,17 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import mapped_column + class CustomsSection(Base): - __tablename__ = "customs_sections" #GAduanaSec + __tablename__ = "customs_sections" # GAduanaSec __table_args__ = ( PrimaryKeyConstraint("customs_code", name="customs_code_pkey"), - {"schema": "public"} + {"schema": "public", "extend_existing": True}, ) customs_code = mapped_column(String(3), nullable=False) - section_name = mapped_column(String(255), nullable=False) + section_name = mapped_column(String(255), nullable=False) def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/api/v1/modules/public/reference_data/customs_sections/routes.py b/backend/api/v1/modules/public/reference_data/customs_sections/routes.py index 403e8904..62ceac7c 100644 --- a/backend/api/v1/modules/public/reference_data/customs_sections/routes.py +++ b/backend/api/v1/modules/public/reference_data/customs_sections/routes.py @@ -1,28 +1,56 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import CustomsSection +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import CustomsSectionDTO +from .models import CustomsSection -router = APIRouter(prefix="/customs-sections", tags=["Customs Sections"]) +router = APIRouter(prefix="/customs-sections") + + +@router.get("/", response_model=Dict[str, Any]) +def list_customs_sections( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(CustomsSection) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [CustomsSectionDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[CustomsSectionDTO]) -def list_customs_sections(db: Session = Depends(get_core_db)): - return db.query(CustomsSection).all() @router.get("/{customs_code}", response_model=CustomsSectionDTO) -def get_customs_section(customs_code: str, db: Session = Depends(get_core_db)): - obj = db.query(CustomsSection).filter(CustomsSection.customs_code == customs_code).first() +def get_customs_section( + customs_code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + obj = ( + db.query(CustomsSection) + .filter(CustomsSection.customs_code == customs_code) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=CustomsSectionDTO, status_code=201) def create_customs_section( data: CustomsSectionDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = CustomsSection(**data.dict()) db.add(obj) @@ -30,14 +58,19 @@ def create_customs_section( db.refresh(obj) return obj + @router.put("/{customs_code}", response_model=CustomsSectionDTO) def update_customs_section( customs_code: str, data: CustomsSectionDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): - obj = db.query(CustomsSection).filter(CustomsSection.customs_code == customs_code).first() + obj = ( + db.query(CustomsSection) + .filter(CustomsSection.customs_code == customs_code) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") for field, value in data.dict().items(): @@ -46,13 +79,18 @@ def update_customs_section( db.refresh(obj) return obj + @router.delete("/{customs_code}", status_code=204) def delete_customs_section( customs_code: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): - obj = db.query(CustomsSection).filter(CustomsSection.customs_code == customs_code).first() + obj = ( + db.query(CustomsSection) + .filter(CustomsSection.customs_code == customs_code) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") db.delete(obj) diff --git a/backend/api/v1/modules/public/reference_data/customs_sections/seed.py b/backend/api/v1/modules/public/reference_data/customs_sections/seed.py index c7448b43..e649d707 100644 --- a/backend/api/v1/modules/public/reference_data/customs_sections/seed.py +++ b/backend/api/v1/modules/public/reference_data/customs_sections/seed.py @@ -1,10 +1,13 @@ seed = [ - ("01", "AEROPUERTO INTERNACIONAL GENERAL JUAN N. ALVAREZ, ACAPULCO, GUERRERO."), + ("01", "AEROPUERTO INTERNACIONAL GENERAL JUAN N. ALVAREZ, ACAPULCO, GUERRERO."), ("010", "ACAPULCO, ACAPULCO DE JUAREZ, GUERRERO."), ("012", "AEROPUERTO INTERNACIONAL GENERAL JUAN N. ALVAREZ, ACAPULCO, GUERRERO."), ("020", "AGUA PRIETA, AGUA PRIETA, SONORA."), ("050", "SUBTENIENTE LOPEZ, SUBTENIENTE LOPEZ, QUINTANA ROO."), - ("051", "SUBTENIENTE LOPEZ II „CHACTEMAL“, OTHÓN P. BLANCO, CHETUMAL, QUINTANA ROO."), + ( + "051", + "SUBTENIENTE LOPEZ II „CHACTEMAL“, OTHÓN P. BLANCO, CHETUMAL, QUINTANA ROO.", + ), ("060", "CIUDAD DEL CARMEN, CIUDAD DEL CARMEN, CAMPECHE."), ("063", "SEYBAPLAYA, CHAMPOTON, CAMPECHE."), ("070", "CIUDAD JUAREZ, CIUDAD JUAREZ, CHIHUAHUA."), @@ -15,8 +18,14 @@ seed = [ ("080", "COATZACOALCOS, COATZACOALCOS, VERACRUZ."), ("110", "ENSENADA, ENSENADA, BAJA CALIFORNIA."), ("120", "GUAYMAS, GUAYMAS, SONORA."), - ("121", "AEROPUERTO INTERNACIONAL GENERAL IGNACIO PESQUEIRA GARCIA, HERMOSILLO, SONORA."), - ("123", "CIUDAD OBREGON ADYACENTE AL AEROPUERTO DE CIUDAD OBREGON, CAJEME, SONORA."), + ( + "121", + "AEROPUERTO INTERNACIONAL GENERAL IGNACIO PESQUEIRA GARCIA, HERMOSILLO, SONORA.", + ), + ( + "123", + "CIUDAD OBREGON ADYACENTE AL AEROPUERTO DE CIUDAD OBREGON, CAJEME, SONORA.", + ), ("140", "LA PAZ, LA PAZ, BAJA CALIFORNIA SUR."), ("142", "SAN JOSE DEL CABO, LOS CABOS, BAJA CALIFORNIA SUR."), ("143", "CABO SAN LUCAS, LOS CABOS, BAJA CALIFORNIA SUR."), @@ -25,7 +34,7 @@ seed = [ ("147", "PICHILINGÜE, LA PAZ, BAJA CALIFORNIA SUR."), ("160", "MANZANILLO, MANZANILLO, COLIMA."), ("161", "ARMERÍA, ARMERÍA, COLIMA."), - ("17", "AEROPUERTO INTERNACIONAL GENERAL SERVANDO CANALES, MATAMOROS, TAMAULIPAS."), + ("17", "AEROPUERTO INTERNACIONAL GENERAL SERVANDO CANALES, MATAMOROS, TAMAULIPAS."), ("170", "MATAMOROS, MATAMOROS, TAMAULIPAS."), ("171", "LUCIO BLANCO-LOS INDIOS, MATAMOROS, TAMAULIPAS."), ("172", "SECCION ADUANERA FERROVIARIA DE MATAMOROS."), @@ -36,22 +45,31 @@ seed = [ ("192", "LOS ALGODONES, MEXICALI, BAJA CALIFORNIA."), ("193", "SAN FELIPE, MEXICALI, BAJA CALIFORNIA."), ("200", "MÉXICO, CIUDAD DE MÉXICO."), - ("202", "IMPORTACION Y EXPORTACION DE CONTENEDORES, DELEGACION AZCAPOTZALCO, CIUDAD DE MÉXICO."), + ( + "202", + "IMPORTACION Y EXPORTACION DE CONTENEDORES, DELEGACION AZCAPOTZALCO, CIUDAD DE MÉXICO.", + ), ("220", "NACO, NACO, SONORA."), ("230", "NOGALES, NOGALES, SONORA."), ("231", "SASABE, SARIC, SONORA."), - ("24", "AEROPUERTO INTERNACIONAL DE NUEVO LAREDO „QUETZALCOATL“, NUEVO LAREDO, TAMAULIPAS."), + ( + "24", + "AEROPUERTO INTERNACIONAL DE NUEVO LAREDO „QUETZALCOATL“, NUEVO LAREDO, TAMAULIPAS.", + ), ("240", "NUEVO LAREDO, NUEVO LAREDO, TAMAULIPAS."), ("250", "OJINAGA, OJINAGA, CHIHUAHUA."), ("260", "PUERTO PALOMAS, PUERTO PALOMAS, CHIHUAHUA."), - ("27", "RIO ESCONDIDO, NAVA, COAHUILA."), + ("27", "RIO ESCONDIDO, NAVA, COAHUILA."), ("270", "PIEDRAS NEGRAS, PIEDRAS NEGRAS, COAHUILA."), ("271", "AEROPUERTO INTERNACIONAL PLAN DE GUADALUPE, RAMOS ARIZPE, COAHUILA."), ("280", "PROGRESO, PROGRESO, YUCATAN."), ("282", "AEROPUERTO INTERNACIONAL LIC. MANUEL CRESCENCIO REJON, MERIDA, YUCATAN."), ("300", "CIUDAD REYNOSA, CIUDAD REYNOSA, TAMAULIPAS."), ("302", "LAS FLORES, RIO BRAVO, TAMAULIPAS."), - ("304", "AEROPUERTO INTERNACIONAL GENERAL. LUCIO BLANCO, CIUDAD REYNOSA, TAMAULIPAS."), + ( + "304", + "AEROPUERTO INTERNACIONAL GENERAL. LUCIO BLANCO, CIUDAD REYNOSA, TAMAULIPAS.", + ), ("305", "RIO BRAVO-DONNA, RIO BRAVO, TAMAULIPAS."), ("306", "ANZALDUAS, CIUDAD REYNOSA, TAMAULIPAS."), ("310", "SALINA CRUZ, SALINA CRUZ, OAXACA."), @@ -59,7 +77,7 @@ seed = [ ("330", "SAN LUIS RIO COLORADO, SAN LUIS RIO COLORADO, SONORA."), ("340", "CIUDAD MIGUEL ALEMAN, CIUDAD MIGUEL ALEMAN, TAMAULIPAS."), ("342", "GUERRERO, GUERRERO, TAMAULIPAS."), - ("37", "AEROPUERTO INTERNACIONAL DE TAPACHULA, TAPACHULA, CHIAPAS."), + ("37", "AEROPUERTO INTERNACIONAL DE TAPACHULA, TAPACHULA, CHIAPAS."), ("370", "CIUDAD HIDALGO, CIUDAD HIDALGO, CHIAPAS."), ("372", "CIUDAD TALISMAN, TUXTLA CHICO, CHIAPAS."), ("375", "PUERTO CHIAPAS, TAPACHULA, CHIAPAS."), @@ -67,27 +85,42 @@ seed = [ ("380", "TAMPICO, TAMPICO, TAMAULIPAS."), ("390", "TECATE, TECATE, BAJA CALIFORNIA."), ("400", "TIJUANA, TIJUANA, BAJA CALIFORNIA."), - ("402", "AEROPUERTO INTERNACIONAL GENERAL ABELARDO L. RODRIGUEZ, TIJUANA, BAJA CALIFORNIA."), + ( + "402", + "AEROPUERTO INTERNACIONAL GENERAL ABELARDO L. RODRIGUEZ, TIJUANA, BAJA CALIFORNIA.", + ), ("420", "TUXPAN, TUXPAN DE RODRIGUEZ CANO, VERACRUZ."), ("421", "TUXPAN, TUXPAN, VERACRUZ."), ("430", "VERACRUZ, VERACRUZ, VERACRUZ."), - ("432", "AEROPUERTO INTERNACIONAL GENERAL HERIBERTO JARA CORONA, VERACRUZ, VERACRUZ."), + ( + "432", + "AEROPUERTO INTERNACIONAL GENERAL HERIBERTO JARA CORONA, VERACRUZ, VERACRUZ.", + ), ("440", "CIUDAD ACUÑA, CIUDAD ACUÑA, COAHUILA."), ("460", "TORREON, TORREON, COAHUILA."), ("461", "AEROPUERTO DE TORREÓN, COAHUILA DE ZARAGOZA."), ("462", "GOMEZ PALACIO, GOMEZ PALACIO, DURANGO."), ("463", "AEROPUERTO INTERNACIONAL GENERAL GUADALUPE VICTORIA, DURANGO, DURANGO."), ("470", "AEROPUERTO INTERNACIONAL DE LA CIUDAD DE MEXICO."), - ("471", "SATELITE, PARA IMPORTACION Y EXPORTACION POR VIA AEREA, AEROPUERTO INTERNACIONAL BENITO JUAREZ DE LA CIUDAD DE MEXICO."), - ("472", "CENTRO POSTAL MECANIZADO, POR VIA POSTAL Y POR TRAFICO AEREO, AEROPUERTO INTERNACIONAL BENITO JUAREZ DE LA CIUDAD DE MEXICO."), + ( + "471", + "SATELITE, PARA IMPORTACION Y EXPORTACION POR VIA AEREA, AEROPUERTO INTERNACIONAL BENITO JUAREZ DE LA CIUDAD DE MEXICO.", + ), + ( + "472", + "CENTRO POSTAL MECANIZADO, POR VIA POSTAL Y POR TRAFICO AEREO, AEROPUERTO INTERNACIONAL BENITO JUAREZ DE LA CIUDAD DE MEXICO.", + ), ("480", "GUADALAJARA, TLACOMULCO DE ZUÑIGA, JALISCO."), ("481", "PUERTO VALLARTA, PUERTO VALLARTA, JALISCO."), ("484", "TERMINAL INTERMODAL FERROVIARIA, GUADALAJARA, JALISCO."), - ("50", "SONORA, PITIQUITO, SONORA."), + ("50", "SONORA, PITIQUITO, SONORA."), ("500", "SONOYTA, SONOYTA, SONORA."), ("501", "SAN EMETERIO, GENERAL PLUTARCO ELIAS CALLES, SONORA."), ("510", "LAZARO CARDENAS, LAZARO CARDENAS, MICHOACAN."), - ("511", "AEROPUERTO INTERNACIONAL IXTAPA-ZIHUATANEJO, ZIHUATANEJO DE AZUETA, GUERRERO."), + ( + "511", + "AEROPUERTO INTERNACIONAL IXTAPA-ZIHUATANEJO, ZIHUATANEJO DE AZUETA, GUERRERO.", + ), ("520", "MONTERREY, GENERAL MARIANO ESCOBEDO, NUEVO LEON."), ("521", "AEROPUERTO INTERNACIONAL GENERAL MARIANO ESCOBEDO, APODACA, NUEVO LEON."), ("523", "SALINAS VICTORIA A (TERMINAL FERROVIARIA), SALINAS VICTORIA, NUEVO LEON."), @@ -102,14 +135,23 @@ seed = [ ("651", "SAN CAYETANO MORELOS, TOLUCA, ESTADO DE MÉXICO"), ("670", "CHIHUAHUA, CHIHUAHUA, CHIHUAHUA."), ("671", "PARQUE INDUSTRIAL LAS AMERICAS, CHIHUAHUA, CHIHUAHUA."), - ("672", "AEROPUERTO INTERNACIONAL GENERAL ROBERTO FIERRO VILLALOBOS, CHIHUAHUA, CHIHUAHUA."), - ("73", "CHICALOTE, SAN FRANCISCO DE LOS ROMO, AGUASCALIENTES."), + ( + "672", + "AEROPUERTO INTERNACIONAL GENERAL ROBERTO FIERRO VILLALOBOS, CHIHUAHUA, CHIHUAHUA.", + ), + ("73", "CHICALOTE, SAN FRANCISCO DE LOS ROMO, AGUASCALIENTES."), ("730", "AGUASCALIENTES, AGUASCALIENTES, AGUASCALIENTES."), ("731", "PARQUE MULTIMODAL INTERPUERTO, SAN LUIS POTOSI, SAN LUIS POTOSI."), ("732", "AEROPUERTO INTERNACIONAL GENERAL LEOBARDO C. RUIZ, EN CALERA ZACATECAS."), - ("733", "AEROPUERTO INTERNACIONAL PONCIANO ARRIAGA, SOLEDAD DE GRACIANO SANCHEZ, SAN LUIS POTOSI."), + ( + "733", + "AEROPUERTO INTERNACIONAL PONCIANO ARRIAGA, SOLEDAD DE GRACIANO SANCHEZ, SAN LUIS POTOSI.", + ), ("734", "LA PILA-VILLA, VILLA DE REYES, SAN LUIS POTOSI."), - ("735", "AEROPUERTO INTERNACIONAL LIC. JESUS TERAN PEREDO, AGUASCALIENTES, AGUASCALIENTES."), + ( + "735", + "AEROPUERTO INTERNACIONAL LIC. JESUS TERAN PEREDO, AGUASCALIENTES, AGUASCALIENTES.", + ), ("750", "PUEBLA, HEROICA PUEBLA DE ZARAGOZA, PUEBLA."), ("751", "CUERNAVACA, JIUTEPEC, MORELOS."), ("754", "AEROPUERTO INTERNACIONAL HERMANOS SERDAN, HUEJOTZINGO, PUEBLA."), @@ -117,9 +159,12 @@ seed = [ ("810", "ALTAMIRA, ALTAMIRA, TAMAULIPAS."), ("820", "CIUDAD CAMARGO, CIUDAD CAMARGO, TAMAULIPAS."), ("830", "DOS BOCAS, PARAISO, TABASCO."), - ("831", "AEROPUERTO INTERNACIONAL C.P.A. CARLOS ROVIROSA PEREZ, CIUDAD DE VILLAHERMOSA, CENTRO, TABASCO."), + ( + "831", + "AEROPUERTO INTERNACIONAL C.P.A. CARLOS ROVIROSA PEREZ, CIUDAD DE VILLAHERMOSA, CENTRO, TABASCO.", + ), ("834", "EL CEIBO, TENOSIQUE, TABASCO."), ("840", "GUANAJUATO, SILAO, GUANAJUATO."), ("841", "CELAYA, CELAYA, GUANAJUATO."), ("842", "AEROPUERTO INTERNACIONAL DE GUANAJUATO, SILAO, GUANAJUATO."), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/customs_sections/test_customs_sections.py b/backend/api/v1/modules/public/reference_data/customs_sections/test_customs_sections.py new file mode 100644 index 00000000..c32571af --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/customs_sections/test_customs_sections.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.customs_sections.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_customs_sections(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/customs-sections/", 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_customs_section_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/customs-sections/invalid_code", headers=headers) + assert response.status_code == 404 + + +def test_create_customs_section_forbidden(): + response = client.post( + "/customs-sections/", json={"customs_code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_customs_section_forbidden(): + response = client.put( + "/customs-sections/TST", json={"customs_code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_customs_section_forbidden(): + response = client.delete("/customs-sections/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/customs_warehouses/dto.py b/backend/api/v1/modules/public/reference_data/customs_warehouses/dto.py index bbf0f1c7..57bb73db 100644 --- a/backend/api/v1/modules/public/reference_data/customs_warehouses/dto.py +++ b/backend/api/v1/modules/public/reference_data/customs_warehouses/dto.py @@ -1,10 +1,9 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class CustomsWarehouseDTO(BaseModel): key: str = Field(..., min_length=1, max_length=3) customs: str fiscalized_warehouse: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/customs_warehouses/models.py b/backend/api/v1/modules/public/reference_data/customs_warehouses/models.py index 5adcfe94..f6c54d05 100644 --- a/backend/api/v1/modules/public/reference_data/customs_warehouses/models.py +++ b/backend/api/v1/modules/public/reference_data/customs_warehouses/models.py @@ -1,17 +1,22 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class CustomsWarehouse(Base): - __tablename__ = "customs_warehouses" #GRecintos + __tablename__ = "customs_warehouses" # GRecintos __table_args__ = ( PrimaryKeyConstraint("key", "customs", name="pk_customs_warehouse"), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, # opcional ) - key: Mapped[str] = mapped_column(String(3), nullable=False) # clave del recinto - customs: Mapped[str] = mapped_column(String(100), nullable=False) # aduana asociada - fiscalized_warehouse: Mapped[str] = mapped_column(String(1000)) # recintos fiscalizados (valor legal) + key: Mapped[str] = mapped_column( + String(3), nullable=False) # clave del recinto + customs: Mapped[str] = mapped_column( + String(100), nullable=False) # aduana asociada + fiscalized_warehouse: Mapped[str] = mapped_column( + String(1000) + ) # recintos fiscalizados (valor legal) def __repr__(self): return f"" diff --git a/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py b/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py index 417faf33..802e1c9c 100644 --- a/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py +++ b/backend/api/v1/modules/public/reference_data/customs_warehouses/routes.py @@ -1,28 +1,57 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import CustomsWarehouse +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import CustomsWarehouseDTO +from .models import CustomsWarehouse -router = APIRouter(prefix="/customs-warehouses", tags=["Customs Warehouses"]) +router = APIRouter(prefix="/customs-warehouses") + + +@router.get("/", response_model=Dict[str, Any]) +def list_customs_warehouses( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(CustomsWarehouse) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [CustomsWarehouseDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[CustomsWarehouseDTO]) -def list_customs_warehouses(db: Session = Depends(get_core_db)): - return db.query(CustomsWarehouse).all() @router.get("/{key}/{customs}", response_model=CustomsWarehouseDTO) -def get_customs_warehouse(key: str, customs: str, db: Session = Depends(get_core_db)): - obj = db.query(CustomsWarehouse).filter(CustomsWarehouse.key == key, CustomsWarehouse.customs == customs).first() +def get_customs_warehouse( + key: str, + customs: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + obj = ( + db.query(CustomsWarehouse) + .filter(CustomsWarehouse.key == key, CustomsWarehouse.customs == customs) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=CustomsWarehouseDTO, status_code=201) def create_customs_warehouse( data: CustomsWarehouseDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = CustomsWarehouse(**data.dict()) db.add(obj) @@ -30,15 +59,20 @@ def create_customs_warehouse( db.refresh(obj) return obj + @router.put("/{key}/{customs}", response_model=CustomsWarehouseDTO) def update_customs_warehouse( key: str, customs: str, data: CustomsWarehouseDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): - obj = db.query(CustomsWarehouse).filter(CustomsWarehouse.key == key, CustomsWarehouse.customs == customs).first() + obj = ( + db.query(CustomsWarehouse) + .filter(CustomsWarehouse.key == key, CustomsWarehouse.customs == customs) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") for field, value in data.dict().items(): @@ -47,14 +81,19 @@ def update_customs_warehouse( db.refresh(obj) return obj + @router.delete("/{key}/{customs}", status_code=204) def delete_customs_warehouse( key: str, customs: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): - obj = db.query(CustomsWarehouse).filter(CustomsWarehouse.key == key, CustomsWarehouse.customs == customs).first() + obj = ( + db.query(CustomsWarehouse) + .filter(CustomsWarehouse.key == key, CustomsWarehouse.customs == customs) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") db.delete(obj) diff --git a/backend/api/v1/modules/public/reference_data/customs_warehouses/seed.py b/backend/api/v1/modules/public/reference_data/customs_warehouses/seed.py index 802dca3d..af899d8f 100644 --- a/backend/api/v1/modules/public/reference_data/customs_warehouses/seed.py +++ b/backend/api/v1/modules/public/reference_data/customs_warehouses/seed.py @@ -1,28 +1,68 @@ seed = [ ("1 ", "Acapulco", "Administración Portuaria Integral de Acapulco, S.A. de C.V."), - ("10 ", "Aeropuerto Internacional de la Ciudad de México", "Cargo Service Center de México, S.A. de C.V."), - ("12 ", "Aeropuerto Internacional de la Ciudad de México", "DHL Express México, S.A. de C.V."), - ("14 ", "Aeropuerto Internacional de la Ciudad de México", "Lufthansa Cargo Servicios Logísticos de México, S.A. de C.V."), + ( + "10 ", + "Aeropuerto Internacional de la Ciudad de México", + "Cargo Service Center de México, S.A. de C.V.", + ), + ( + "12 ", + "Aeropuerto Internacional de la Ciudad de México", + "DHL Express México, S.A. de C.V.", + ), + ( + "14 ", + "Aeropuerto Internacional de la Ciudad de México", + "Lufthansa Cargo Servicios Logísticos de México, S.A. de C.V.", + ), ("145", "México", "Ferrocarril y Terminal de Valle de México, S.A. de C.V."), ("146", "Veracruz", "Cargill de México, S.A. de C.V."), - ("147", "Aeropuerto Internacional de la Ciudad de México", "Braniff Transport Carga, S.A. de C.V."), - ("148", "Nuevo Laredo", "Inspecciones Fitosanitarias y Aduaneras de Nuevo Laredo, S.A. de C.V."), + ( + "147", + "Aeropuerto Internacional de la Ciudad de México", + "Braniff Transport Carga, S.A. de C.V.", + ), + ( + "148", + "Nuevo Laredo", + "Inspecciones Fitosanitarias y Aduaneras de Nuevo Laredo, S.A. de C.V.", + ), ("149", "Nuevo Laredo", "PG Servicios de Logística, S.C."), - ("15 ", "Aeropuerto Internacional de la Ciudad de México", "Tramitadores Asociados de Aerocarga, S.A. de C.V."), + ( + "15 ", + "Aeropuerto Internacional de la Ciudad de México", + "Tramitadores Asociados de Aerocarga, S.A. de C.V.", + ), ("150", "Piedras Negras", "Mercurio Cargo, S.A. de C.V."), ("151", "Colombia", "S.R. Asesores Aduanales de Nuevo Laredo, S.C."), - ("154", "Monterrey", "Federal Express Holdings (México) y Compañía, S.N.C. de C.V."), + ( + "154", + "Monterrey", + "Federal Express Holdings (México) y Compañía, S.N.C. de C.V.", + ), ("155", "Ciudad Hidalgo", "Corporativo de Servicios del Sureste, S.A. de C.V."), ("158", "Monterrey", "Aeropuerto de Monterrey, S.A. de C.V."), - ("16 ", "Aeropuerto Internacional de la Ciudad de México", "Transportación México Express, S.A. de C.V."), + ( + "16 ", + "Aeropuerto Internacional de la Ciudad de México", + "Transportación México Express, S.A. de C.V.", + ), ("160", "Manzanillo", "Frigorífico de Manzanillo, S.A. de C.V."), ("161", "Colombia", "Santos Esquivel y Compañía, S.C."), ("162", "Guadalajara", "Ferrocarril Mexicano, S.A. de C.V."), ("164", "Monterrey", "United Parcel Service de México, S.A. de C.V."), - ("165", "Aguascalientes", "Centros de Intercambio de Carga Express Estafeta, S.A. de C.V."), + ( + "165", + "Aguascalientes", + "Centros de Intercambio de Carga Express Estafeta, S.A. de C.V.", + ), ("166", "Altamira", "Administración Portuaria Integral de Altamira, S.A. de C.V."), ("167", "Ciudad Juárez", "Accel, Recinto Fiscalizado, S.A. de C.V."), - ("17 ", "Aeropuerto Internacional de la Ciudad de México", "United Parcel Service de México, S.A. de C.V."), + ( + "17 ", + "Aeropuerto Internacional de la Ciudad de México", + "United Parcel Service de México, S.A. de C.V.", + ), ("171", "Chihuahua", "Aeropuerto de Chihuahua, S.A. de C.V."), ("172", "Veracruz", "Servicios Especiales Portuarios, S.A. de C.V."), ("173", "Lázaro Cárdenas", "UTTSA, S.A. de C.V."), @@ -34,7 +74,11 @@ seed = [ ("179", "Altamira", "D.A. Hinojosa Terminal Multiusos, S.A. de C.V."), ("18 ", "Aeropuerto Internacional de la Ciudad de México", "Varig de México, S.A."), ("180", "Altamira", "Inmobiliaria Portuaria de Altamira, S.A. de C.V."), - ("182", "Veracruz", "Servicios, Maniobras y Almacenamientos de Veracruz, S.A. de C.V."), + ( + "182", + "Veracruz", + "Servicios, Maniobras y Almacenamientos de Veracruz, S.A. de C.V.", + ), ("184", "Progreso", "Terminal de Contenedores de Yucatán, S.A. de C.V."), ("186", "Nuevo Laredo", "Logis Servicios Comerciales, S.A. de C.V."), ("187", "Manzanillo", "Tecnoadministración del Pacífico, S.A. de C.V."), @@ -50,8 +94,16 @@ seed = [ ("203", "Altamira", "Grupo Castañeda, S.A. de C.V."), ("204", "Monterrey", "Ferrocarril Mexicano, S.A. de C.V."), ("210", "Querétaro", "Terminal Logistics, S.A. de C.V."), - ("211", "Aeropuerto Internacional de la Ciudad de México", "World Express Cargo de México, S.A. de C.V."), - ("212", "Piedras Negras", "Consultores de Logística en Comercio Exterior, S.A. de C.V."), + ( + "211", + "Aeropuerto Internacional de la Ciudad de México", + "World Express Cargo de México, S.A. de C.V.", + ), + ( + "212", + "Piedras Negras", + "Consultores de Logística en Comercio Exterior, S.A. de C.V.", + ), ("214", "Altamira", "Possehl México, S.A. de C.V."), ("215", "Tampico", "Refitam, S.A. de C.V."), ("217", "Veracruz", "SSA México, S.A. de C.V."), @@ -62,12 +114,20 @@ seed = [ ("222", "Matamoros", "Puerto Los Indios, S.A. de C.V."), ("223", "Monterrey", "DHL Express México, S.A. de C.V."), ("224", "Aguascalientes", "Nafta Rail, S.A. de C.V."), - ("225", "Altamira", "Integradora de Servicios, Transporte y Almacenaje, S.A. de C.V."), + ( + "225", + "Altamira", + "Integradora de Servicios, Transporte y Almacenaje, S.A. de C.V.", + ), ("226", "Nuevo Laredo", "DAF, Delivery After Frontier, S.A. de C.V."), ("227", "Matamoros", "Profesionales Mexicanos del Comercio Exterior, S.C."), ("228", "Guadalajara", "CLA Guadalajara, S.A. de C.V."), ("229", "Manzanillo", "Maniobras Integradas del Puerto, S.A. de C.V."), - ("23 ", "Coatzacoalcos", "Administración Portuaria Integral de Coatzacoalcos, S.A. de C.V."), + ( + "23 ", + "Coatzacoalcos", + "Administración Portuaria Integral de Coatzacoalcos, S.A. de C.V.", + ), ("230", "Querétaro", "Terminal Intermodal Logística de Hidalgo, S.A.P.I. de C.V."), ("231", "Lázaro Cárdenas", "Terminales Portuarias del Pacífico, S.A.P.I. de C.V."), ("232", "Lázaro Cárdenas", "Arcelormittal Portuarios, S.A. de C.V."), @@ -83,34 +143,74 @@ seed = [ ("26 ", "Colombia", "Mex Securit, S.A. de C.V."), ("27 ", "Ensenada", "Ensenada International Terminal, S.A. de C.V."), ("28 ", "Guadalajara", "Almacenadora GWTC, S.A. de C.V."), - ("29 ", "Guadalajara", "Federal Express Holdings (México) y Compañía, S.N.C. de C.V."), - ("3 ", "Aeropuerto Internacional de la Ciudad de México", "Aerovías de México, S.A. de C.V."), + ( + "29 ", + "Guadalajara", + "Federal Express Holdings (México) y Compañía, S.N.C. de C.V.", + ), + ( + "3 ", + "Aeropuerto Internacional de la Ciudad de México", + "Aerovías de México, S.A. de C.V.", + ), ("30 ", "Guaymas", "Administración Portuaria Integral de Guaymas, S.A. de C.V."), - ("31 ", "Lázaro Cárdenas", "Administración Portuaria Integral de Lázaro Cárdenas, S.A. de C.V."), + ( + "31 ", + "Lázaro Cárdenas", + "Administración Portuaria Integral de Lázaro Cárdenas, S.A. de C.V.", + ), ("33 ", "Lázaro Cárdenas", "Aarhuskarlshamn México, S.A. de C.V."), - ("35 ", "Manzanillo", "Administración Portuaria Integral de Manzanillo, S.A. de C.V."), + ( + "35 ", + "Manzanillo", + "Administración Portuaria Integral de Manzanillo, S.A. de C.V.", + ), ("36 ", "Manzanillo", "Comercializadora La Junta, S.A. de C.V."), ("38 ", "Manzanillo", "Operadora de la Cuenca del Pacífico, S.A. de C.V."), ("39 ", "Manzanillo", "SSA México, S.A. de C.V."), - ("4 ", "Aeropuerto Internacional de la Ciudad de México", "AAACESA Almacenes Fiscalizados, S.A. de C.V."), + ( + "4 ", + "Aeropuerto Internacional de la Ciudad de México", + "AAACESA Almacenes Fiscalizados, S.A. de C.V.", + ), ("40 ", "Manzanillo", "Terminal Internacional de Manzanillo, S.A. de C.V."), ("42 ", "Mazatlán", "Administración Portuaria Integral de Mazatlán, S.A. de C.V."), - ("43 ", "Mazatlán", "Administración Portuaria Integral de Topolobampo, S.A. de C.V."), + ( + "43 ", + "Mazatlán", + "Administración Portuaria Integral de Topolobampo, S.A. de C.V.", + ), ("44 ", "Monterrey", "Braniff Air Freight and Company, S.A. de C.V."), ("45 ", "Monterrey", "Kansas City Southern de México, S.A. de C.V."), ("46 ", "Nogales", "Servicios de Almacén Fiscalizado de Nogales, S.A. de C.V."), ("47 ", "Progreso", "Administración Portuaria Integral de Progreso, S.A. de C.V."), ("49 ", "Progreso", "Grupo de Desarrollo del Sureste, S.A. de C.V."), - ("5 ", "Aeropuerto Internacional de la Ciudad de México", "México Cargo Handling, S.A. de C.V."), + ( + "5 ", + "Aeropuerto Internacional de la Ciudad de México", + "México Cargo Handling, S.A. de C.V.", + ), ("50 ", "Progreso", "Multisur, S.A. de C.V."), ("51 ", "Querétaro", "Servicios Integrales y Desarrollo GMG, S.A. de C.V."), ("52 ", "Reynosa", "Recintos Fiscalizados de Noreste, S.A. de C.V."), - ("53 ", "Salina Cruz", "Administración Portuaria Integral de Salina Cruz, S.A. de C.V."), - ("54 ", "Cancún", "Administración Portuaria Integral de Quintana Roo, S.A. de C.V."), + ( + "53 ", + "Salina Cruz", + "Administración Portuaria Integral de Salina Cruz, S.A. de C.V.", + ), + ( + "54 ", + "Cancún", + "Administración Portuaria Integral de Quintana Roo, S.A. de C.V.", + ), ("56 ", "Toluca", "Braniff Air Freight and Company, S.A. de C.V."), ("57 ", "Toluca", "Federal Express Holdings (México) y Compañía, S.N.C. de C.V."), ("59 ", "Tuxpan", "Administración Portuaria Integral de Tuxpan, S.A. de C.V."), - ("6 ", "Aeropuerto Internacional de la Ciudad de México", "American Airlines de México, S.A. de C.V."), + ( + "6 ", + "Aeropuerto Internacional de la Ciudad de México", + "American Airlines de México, S.A. de C.V.", + ), ("60 ", "Tuxpan", "Fenoresinas, S.A. de C.V."), ("61 ", "Tuxpan", "Terminal Marítima de Tuxpan, S.A. de C.V."), ("62 ", "Tuxpan", "Terminales Marítimas Transunisa, S.A. de C.V."), @@ -118,8 +218,16 @@ seed = [ ("64 ", "Veracruz", "Almacenadora Golmex, S.A. de C.V."), ("66 ", "Veracruz", "CIF Almacenajes y Servicios, S.A. de C.V."), ("67 ", "Veracruz", "Corporación Integral de Comercio Exterior, S.A. de C.V."), - ("69 ", "Veracruz", "Internacional de Contenedores Asociados de Veracruz, S.A. de C.V."), - ("7 ", "Aeropuerto Internacional de la Ciudad de México", "Braniff Air Freight and Company, S.A. de C.V."), + ( + "69 ", + "Veracruz", + "Internacional de Contenedores Asociados de Veracruz, S.A. de C.V.", + ), + ( + "7 ", + "Aeropuerto Internacional de la Ciudad de México", + "Braniff Air Freight and Company, S.A. de C.V.", + ), ("71 ", "Veracruz", "Reparación Integral de Contenedores, S.A. de C.V."), ("73 ", "Veracruz", "Terminales de Cargas Especializadas, S.A. de C.V."), ("74 ", "Veracruz", "Vopak Terminals México, S.A. de C.V."), @@ -127,9 +235,17 @@ seed = [ ("76 ", "Manzanillo", "Cemex México, S.A. de C.V."), ("77 ", "Manzanillo", "Corporación Multimodal, S.A. de C.V."), ("78 ", "Ensenada", "Administración Portuaria Integral de Ensenada, S.A. de C.V."), - ("8 ", "Aeropuerto Internacional de la Ciudad de México", "Iberia de México, S.A."), + ( + "8 ", + "Aeropuerto Internacional de la Ciudad de México", + "Iberia de México, S.A.", + ), ("81 ", "Tuxpan", "Frigoríficos Especializados de Tuxpan, S.A. de C.V."), ("82 ", "Veracruz", "SSA México, S.A. de C.V."), - ("9 ", "Aeropuerto Internacional de la Ciudad de México", "Compañía Mexicana de Aviación, S.A. de C.V."), + ( + "9 ", + "Aeropuerto Internacional de la Ciudad de México", + "Compañía Mexicana de Aviación, S.A. de C.V.", + ), ("98 ", "Veracruz", "Corporación Portuaria de Veracruz, S.A. de C.V."), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/customs_warehouses/test_customs_warehouses.py b/backend/api/v1/modules/public/reference_data/customs_warehouses/test_customs_warehouses.py new file mode 100644 index 00000000..9d23e837 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/customs_warehouses/test_customs_warehouses.py @@ -0,0 +1,48 @@ +import pytest +from api.v1.modules.public.reference_data.customs_warehouses.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_customs_warehouses(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/customs-warehouses/", 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_customs_warehouse_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get( + "/customs-warehouses/invalid_key/invalid_customs", headers=headers + ) + assert response.status_code == 404 + + +def test_create_customs_warehouse_forbidden(): + response = client.post( + "/customs-warehouses/", + json={"key": "TST", "customs": "TST", "description": "Test"}, + ) + assert response.status_code in (403, 405, 404) + + +def test_update_customs_warehouse_forbidden(): + response = client.put( + "/customs-warehouses/TST/TST", + json={"key": "TST", "customs": "TST", "description": "Test"}, + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_customs_warehouse_forbidden(): + response = client.delete("/customs-warehouses/TST/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/incoterms/dto.py b/backend/api/v1/modules/public/reference_data/incoterms/dto.py index 963816e9..03c40872 100644 --- a/backend/api/v1/modules/public/reference_data/incoterms/dto.py +++ b/backend/api/v1/modules/public/reference_data/incoterms/dto.py @@ -1,9 +1,9 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class IncotermDTO(BaseModel): code: str = Field(..., min_length=1, max_length=5) description_es: str description_en: str - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/incoterms/models.py b/backend/api/v1/modules/public/reference_data/incoterms/models.py index 94ec4653..65028280 100644 --- a/backend/api/v1/modules/public/reference_data/incoterms/models.py +++ b/backend/api/v1/modules/public/reference_data/incoterms/models.py @@ -1,12 +1,13 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class Incoterm(Base): - __tablename__ = "incoterms" #GIncoterm + __tablename__ = "incoterms" # GIncoterm __table_args__ = ( PrimaryKeyConstraint("code", name="incoterms_pkey"), - {"schema": "public"} + {"schema": "public", "extend_existing": True}, ) code: Mapped[str] = mapped_column(String(5), nullable=False) diff --git a/backend/api/v1/modules/public/reference_data/incoterms/routes.py b/backend/api/v1/modules/public/reference_data/incoterms/routes.py index b873cba8..20a7e315 100644 --- a/backend/api/v1/modules/public/reference_data/incoterms/routes.py +++ b/backend/api/v1/modules/public/reference_data/incoterms/routes.py @@ -1,29 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import Incoterm +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import IncotermDTO +from .models import Incoterm -router = APIRouter(prefix="/incoterms", tags=["Incoterms"]) +router = APIRouter(prefix="/incoterms") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_incoterms( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(Incoterm) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [IncotermDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[IncotermDTO]) -def list_incoterms(db: Session = Depends(get_core_db)): - objs = db.query(Incoterm).all() - return [IncotermDTO.model_validate(obj) for obj in objs] @router.get("/{key}", response_model=IncotermDTO) -def get_incoterm(key: str, db: Session = Depends(get_core_db)): +async def get_incoterm( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(Incoterm).filter(Incoterm.code == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return IncotermDTO.model_validate(obj) + @router.post("/", response_model=IncotermDTO, status_code=201) -def create_incoterm( +async def create_incoterm( data: IncotermDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = Incoterm(**data.model_dump()) db.add(obj) @@ -31,12 +54,13 @@ def create_incoterm( db.refresh(obj) return IncotermDTO.model_validate(obj) + @router.put("/{key}", response_model=IncotermDTO) -def update_incoterm( +async def update_incoterm( key: str, data: IncotermDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Incoterm).filter(Incoterm.code == key).first() if not obj: @@ -47,11 +71,12 @@ def update_incoterm( db.refresh(obj) return IncotermDTO.model_validate(obj) + @router.delete("/{key}", status_code=204) -def delete_incoterm( +async def delete_incoterm( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Incoterm).filter(Incoterm.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/incoterms/seed.py b/backend/api/v1/modules/public/reference_data/incoterms/seed.py index b7f9dbc6..0b9f87ce 100644 --- a/backend/api/v1/modules/public/reference_data/incoterms/seed.py +++ b/backend/api/v1/modules/public/reference_data/incoterms/seed.py @@ -10,4 +10,4 @@ seed = [ ("FOB", "PUERTO DE EMBARQUE CONVENIDO", "FREE ON BOARD"), ("CFR", "COSTO Y FLETE", "COST AND FREIGHT"), ("CIF", "COSTO, SEGURO Y FLETE", "COST, INSURANCE AND FREIGHT"), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/incoterms/test_incoterms.py b/backend/api/v1/modules/public/reference_data/incoterms/test_incoterms.py new file mode 100644 index 00000000..a8394a68 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/incoterms/test_incoterms.py @@ -0,0 +1,40 @@ +import pytest +from api.v1.modules.public.reference_data.incoterms.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_incoterms(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/incoterms/", 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_incoterm_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/incoterms/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_incoterm_forbidden(): + response = client.post("/incoterms/", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_update_incoterm_forbidden(): + response = client.put("/incoterms/TST", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_delete_incoterm_forbidden(): + response = client.delete("/incoterms/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/invoice_types/dto.py b/backend/api/v1/modules/public/reference_data/invoice_types/dto.py index a71b6a88..0488a2a2 100644 --- a/backend/api/v1/modules/public/reference_data/invoice_types/dto.py +++ b/backend/api/v1/modules/public/reference_data/invoice_types/dto.py @@ -1,11 +1,13 @@ -from pydantic import BaseModel, Field from typing import Optional +from pydantic import BaseModel, ConfigDict, Field + + class InvoiceTypeDTO(BaseModel): key: str = Field(..., min_length=1, max_length=5) description: str note: Optional[str] = None type: Optional[str] = None + operation: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/invoice_types/models.py b/backend/api/v1/modules/public/reference_data/invoice_types/models.py index 214aae00..792cda91 100644 --- a/backend/api/v1/modules/public/reference_data/invoice_types/models.py +++ b/backend/api/v1/modules/public/reference_data/invoice_types/models.py @@ -1,19 +1,20 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column class InvoiceType(Base): - __tablename__ = "invoice_types" #GTiposFactura + __tablename__ = "invoice_types" # GTiposFactura __table_args__ = ( PrimaryKeyConstraint("key", name="invoice_types_pkey"), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, # opcional ) - key: Mapped[str] = mapped_column(String(5), nullable=False) # clave del tipo de factura - description: Mapped[str] = mapped_column(String(50), nullable=False) # descripción oficial (en español) - note: Mapped[str] = mapped_column(String(500)) # observación o comentario adicional - type: Mapped[str] = mapped_column(String(15)) # tipo + key: Mapped[str] = mapped_column(String(5), nullable=False) # clave del tipo de factura + description: Mapped[str] = mapped_column(String(50), nullable=False) # descripción oficial (en español) + note: Mapped[str] = mapped_column(String(500))# observación o comentario adicional + type: Mapped[str] = mapped_column(String(15))# tipo (MATERIAL, fixed asset, both) + operation: Mapped[str] = mapped_column(String(5)) # operación (imp, exp, both) def __repr__(self): - return f"" + return f"" diff --git a/backend/api/v1/modules/public/reference_data/invoice_types/routes.py b/backend/api/v1/modules/public/reference_data/invoice_types/routes.py index 01bdda44..e0ce0866 100644 --- a/backend/api/v1/modules/public/reference_data/invoice_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/invoice_types/routes.py @@ -1,29 +1,63 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict, Optional + from core.database import get_core_db -from core.security import has_role -from .models import InvoiceType +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import InvoiceTypeDTO +from .models import InvoiceType -router = APIRouter(prefix="/invoice-types", tags=["Invoice Types"]) +router = APIRouter(prefix="/invoice-types") + + +@router.get("/", response_model=dict) +def list_invoice_types( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + type: Optional[str] = Query(None, description="Filter by type"), + operation: Optional[str] = Query(None, description="Filter by operation type (imp, exp, both)"), + db: Session = Depends(get_core_db), +): + query = db.query(InvoiceType) + + # Filter by operation if provided + if operation: + query = query.filter( + (InvoiceType.operation == operation) | ( + InvoiceType.operation == "both") + ) + + if type == "imp" and operation == "CR": + query = query.filter(InvoiceType.operation != "exp") + + total = query.count() + items = query.offset((page - 1) * page_size).limit(page_size).all() + return { + "items": [InvoiceTypeDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[InvoiceTypeDTO]) -def list_invoice_types(db: Session = Depends(get_core_db)): - objs = db.query(InvoiceType).all() - return [InvoiceTypeDTO.model_validate(obj) for obj in objs] @router.get("/{key}", response_model=InvoiceTypeDTO) -def get_invoice_type(key: str, db: Session = Depends(get_core_db)): +def get_invoice_type( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(InvoiceType).filter(InvoiceType.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return InvoiceTypeDTO.model_validate(obj) + @router.post("/", response_model=InvoiceTypeDTO, status_code=201) def create_invoice_type( data: InvoiceTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = InvoiceType(**data.model_dump()) db.add(obj) @@ -31,12 +65,13 @@ def create_invoice_type( db.refresh(obj) return InvoiceTypeDTO.model_validate(obj) + @router.put("/{key}", response_model=InvoiceTypeDTO) def update_invoice_type( key: str, data: InvoiceTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(InvoiceType).filter(InvoiceType.key == key).first() if not obj: @@ -47,11 +82,12 @@ def update_invoice_type( db.refresh(obj) return InvoiceTypeDTO.model_validate(obj) + @router.delete("/{key}", status_code=204) def delete_invoice_type( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(InvoiceType).filter(InvoiceType.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/invoice_types/seed.py b/backend/api/v1/modules/public/reference_data/invoice_types/seed.py index 54d17b7a..151163dd 100644 --- a/backend/api/v1/modules/public/reference_data/invoice_types/seed.py +++ b/backend/api/v1/modules/public/reference_data/invoice_types/seed.py @@ -1,13 +1,76 @@ seed = [ - ("DONAC", "DONACION", "", "AMBOS"), - ("EXDEF", "EXPORTACION DEFINITIVA", "", "MATERIAL"), - ("MATDE", "MATERIA PRIMA O MATERIAL DEVUELTO", "ESTE PROCESO CONSISTE EN SOLO DESCARGAR LAS PARTES DADAS DE ALTA EN MATERIALES QUE SON RETORNADAS SIN NINGUNA MODIFICACION (A1)", "MATERIAL"), - ("NODES", "NO HACE DESCARGA", "ESTE PROCESO DE ACTUALIZACION CONSISTE EN EXPORTAR UNA MERCANCIA Y NO DESCARGAR, POR LO TANTO NO EXISTE REPORTE DE DESCARGAS Y NO AFECTA SALDOS.", "AMBOS"), - ("PTERM", "PRODUCTO TERMINADO Y VIRTUALES", "EL PRODUCTO TERMINADO Y VIRTUALES DESCARGARAN: 1) APARTIR DE LOS COMPONENTES DE CADA PRODUCTO TERMINADO REGISTRADO EN LAS PARTIDAS DE EXPORTACION. 2) POR PARTE, CON LAS OPCIONES DE PODER DESCARGAR POR SUSTITUTO Y POR CLASE EN CASO DE INSUFICIENCIAS DEL COMPONENTE.", "MATERIAL"), - ("REPAR", "REPARACION", "PROCESO QUE CONSISTE EN DOS ETAPAS: 1) DESCARGA EL PRODUCTO DE REPARACION QUE SE IMPORTO PARA REPARA, 2) DESCARGA EL LISTADO DE COMPONENTES QUE SE AGREGO AL PRODUCTO DE REPARACION", "MATERIAL"), - ("SCRAP", "SCRAP", "", "AMBOS"), - ("VEMEX", "VENTAS EN MEXICO", "ESTE PROCESO CONSISTE EN LA VENTA EN EL MERCADO NACIONAL DE LOS PRODUCTOS.", "AMBOS"), - ("VIRTU", "VIRTUALES", "", "MATERIAL"), - ("AFIJO", "ACTIVO FIJO", "", "ACTIVO FIJO"), - ("REEXP", "REEXPEDICION", "", "ACTIVO FIJO"), + # === TIPOS DE IMPORTACION === + ( + "TEM", + "IMPORTACION TEMPORAL", + "IMPORTACION TEMPORAL DE MATERIA PRIMA, COMPONENTES O MATERIALES PARA SER PROCESADOS Y POSTERIORMENTE EXPORTADOS.", + "both", + "imp", + ), + ( + "DEF", + "IMPORTACION DEFINITIVA", + "IMPORTACION DEFINITIVA PARA NACIONALIZACION DE MERCANCIA QUE PERMANECE EN TERRITORIO NACIONAL.", + "both", + "imp", + ), + ( + "MEX", + "COMPRAS MEXICANAS", + "IMPORTACION DE MERCANCIA NACIONAL ADQUIRIDA DE PROVEEDORES MEXICANOS PARA INCORPORAR A PROCESO PRODUCTIVO.", + "both", + "imp", + ), + ( + "CR", + "CAMBIO DE REGIMEN", + "IMPORTACION POR CAMBIO DE REGIMEN DE MERCANCIA TEMPORAL QUE SE NACIONALIZA O RETORNA.", + "both", + "imp", + ), + + # === TIPOS DE EXPORTACION === + ("DONAC", "DONACION", "", "both", "exp"), + ("EXDEF", "EXPORTACION DEFINITIVA", "", "material", "exp"), + ( + "MATDE", + "MATERIA PRIMA O MATERIAL DEVUELTO", + "ESTE PROCESO CONSISTE EN SOLO DESCARGAR LAS PARTES DADAS DE ALTA EN MATERIALES QUE SON RETORNADAS SIN NINGUNA MODIFICACION (A1)", + "material", + "exp", + ), + ( + "NODES", + "NO HACE DESCARGA", + "ESTE PROCESO DE ACTUALIZACION CONSISTE EN EXPORTAR UNA MERCANCIA Y NO DESCARGAR, POR LO TANTO NO EXISTE REPORTE DE DESCARGAS Y NO AFECTA SALDOS.", + "both", + "exp", + ), + ( + "PTERM", + "PRODUCTO TERMINADO Y VIRTUALES", + "EL PRODUCTO TERMINADO Y VIRTUALES DESCARGARAN: 1) APARTIR DE LOS COMPONENTES DE CADA PRODUCTO TERMINADO REGISTRADO EN LAS PARTIDAS DE EXPORTACION. 2) POR PARTE, CON LAS OPCIONES DE PODER DESCARGAR POR SUSTITUTO Y POR CLASE EN CASO DE INSUFICIENCIAS DEL COMPONENTE.", + "material", + "exp", + ), + ( + "REPAR", + "REPARACION", + "PROCESO QUE CONSISTE EN DOS ETAPAS: 1) DESCARGA EL PRODUCTO DE REPARACION QUE SE IMPORTO PARA REPARA, 2) DESCARGA EL LISTADO DE COMPONENTES QUE SE AGREGO AL PRODUCTO DE REPARACION", + "material", + "exp", + ), + ("SCRAP", "SCRAP", "", "both", "exp"), + ( + "VEMEX", + "VENTAS EN MEXICO", + "ESTE PROCESO CONSISTE EN LA VENTA EN EL MERCADO NACIONAL DE LOS PRODUCTOS.", + "both", + "exp", + ), + ("VIRTU", "VIRTUALES", "", "material", "exp"), + + # === ACTIVOS FIJOS (AMBAS OPERACIONES) === + ("AFIJO", "ACTIVO FIJO", "", "fixed asset", "exp"), + ("REEXP", "REEXPEDICION", "", "fixed asset", "exp"), ] diff --git a/backend/api/v1/modules/public/reference_data/invoice_types/test_invoice_types.py b/backend/api/v1/modules/public/reference_data/invoice_types/test_invoice_types.py new file mode 100644 index 00000000..5a314b93 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/invoice_types/test_invoice_types.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.invoice_types.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_invoice_types(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/invoice-types/", 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_invoice_type_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/invoice-types/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_invoice_type_forbidden(): + response = client.post( + "/invoice-types/", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_invoice_type_forbidden(): + response = client.put( + "/invoice-types/TST", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_invoice_type_forbidden(): + response = client.delete("/invoice-types/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/material_types/dto.py b/backend/api/v1/modules/public/reference_data/material_types/dto.py index 22c83fa5..1bcdc3ee 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/dto.py +++ b/backend/api/v1/modules/public/reference_data/material_types/dto.py @@ -1,10 +1,9 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class MaterialTypeDTO(BaseModel): key: str = Field(..., min_length=1, max_length=10) type: str = Field(..., min_length=1, max_length=15) description: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/material_types/models.py b/backend/api/v1/modules/public/reference_data/material_types/models.py index 5fc11239..04fa9478 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/models.py +++ b/backend/api/v1/modules/public/reference_data/material_types/models.py @@ -1,17 +1,21 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class MaterialType(Base): - __tablename__ = "material_types" #STipoMat QTipoActFijo + __tablename__ = "material_types" # STipoMat QTipoActFijo __table_args__ = ( PrimaryKeyConstraint("key", name="material_types_pkey"), - {"schema": "public"} + {"schema": "public", "extend_existing": True}, ) - key: Mapped[str] = mapped_column(String(10), nullable=False) # clave del material - type: Mapped[str] = mapped_column(String(15), nullable=False) # tipo - description: Mapped[str] = mapped_column(String(256), nullable=False) # descripción oficial (en español) + key: Mapped[str] = mapped_column( + String(10), nullable=False) # clave del material + type: Mapped[str] = mapped_column(String(15), nullable=False) # tipo + description: Mapped[str] = mapped_column( + String(256), nullable=False + ) # descripción oficial (en español) def __repr__(self): return f"" diff --git a/backend/api/v1/modules/public/reference_data/material_types/routes.py b/backend/api/v1/modules/public/reference_data/material_types/routes.py index 367b2cae..a25d3fb9 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/material_types/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import MaterialType +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import MaterialTypeDTO +from .models import MaterialType -router = APIRouter(prefix="/material-types", tags=["Material Types"]) +router = APIRouter(prefix="/material-types") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_material_types( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(MaterialType) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [MaterialTypeDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[MaterialTypeDTO]) -def list_material_types(db: Session = Depends(get_core_db)): - return db.query(MaterialType).all() @router.get("/{key}", response_model=MaterialTypeDTO) -def get_material_type(key: str, db: Session = Depends(get_core_db)): +async def get_material_type( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(MaterialType).filter(MaterialType.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=MaterialTypeDTO, status_code=201) -def create_material_type( +async def create_material_type( data: MaterialTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = MaterialType(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_material_type( db.refresh(obj) return obj + @router.put("/{key}", response_model=MaterialTypeDTO) -def update_material_type( +async def update_material_type( key: str, data: MaterialTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(MaterialType).filter(MaterialType.key == key).first() if not obj: @@ -46,11 +71,12 @@ def update_material_type( db.refresh(obj) return obj + @router.delete("/{key}", status_code=204) -def delete_material_type( +async def delete_material_type( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(MaterialType).filter(MaterialType.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/material_types/seed.py b/backend/api/v1/modules/public/reference_data/material_types/seed.py index 3f8699d8..55c85356 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/seed.py +++ b/backend/api/v1/modules/public/reference_data/material_types/seed.py @@ -32,5 +32,5 @@ seed = [ ("MAQEQ", "MAQUINARIA Y EQUIPO", "ACTIVO FIJO"), ("MAQUI", "MAQUINARIA", "ACTIVO FIJO"), ("REFAC", "REFACCIONES", "ACTIVO FIJO"), - ("TERR", "TERRRENOS" , "ACTIVO FIJO"), -] \ No newline at end of file + ("TERR", "TERRRENOS", "ACTIVO FIJO"), +] diff --git a/backend/api/v1/modules/public/reference_data/material_types/test_material_types.py b/backend/api/v1/modules/public/reference_data/material_types/test_material_types.py new file mode 100644 index 00000000..1c5d950c --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/material_types/test_material_types.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.material_types.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_material_types(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/material-types/", 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_material_type_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/material-types/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_material_type_forbidden(): + response = client.post( + "/material-types/", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_material_type_forbidden(): + response = client.put( + "/material-types/TST", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_material_type_forbidden(): + response = client.delete("/material-types/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/payment_methods/dto.py b/backend/api/v1/modules/public/reference_data/payment_methods/dto.py index 214a2087..b65ec3b2 100644 --- a/backend/api/v1/modules/public/reference_data/payment_methods/dto.py +++ b/backend/api/v1/modules/public/reference_data/payment_methods/dto.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class PaymentMethodDTO(BaseModel): key: str = Field(..., min_length=1, max_length=2) description: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/payment_methods/models.py b/backend/api/v1/modules/public/reference_data/payment_methods/models.py index 9f50a00f..35870726 100644 --- a/backend/api/v1/modules/public/reference_data/payment_methods/models.py +++ b/backend/api/v1/modules/public/reference_data/payment_methods/models.py @@ -1,12 +1,13 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class PaymentMethod(Base): - __tablename__ = "payment_methods" #GFormaPago + __tablename__ = "payment_methods" # GFormaPago __table_args__ = ( PrimaryKeyConstraint("key", name="payment_methods_pkey"), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, # opcional ) key: Mapped[str] = mapped_column(String(2), nullable=False) diff --git a/backend/api/v1/modules/public/reference_data/payment_methods/routes.py b/backend/api/v1/modules/public/reference_data/payment_methods/routes.py index 3e989198..465630fc 100644 --- a/backend/api/v1/modules/public/reference_data/payment_methods/routes.py +++ b/backend/api/v1/modules/public/reference_data/payment_methods/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import PaymentMethod +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import PaymentMethodDTO +from .models import PaymentMethod -router = APIRouter(prefix="/payment-methods", tags=["Payment Methods"]) +router = APIRouter(prefix="/payment-methods") + + +@router.get("/", response_model=Dict[str, Any]) +def list_payment_methods( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(PaymentMethod) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [PaymentMethodDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[PaymentMethodDTO]) -def list_payment_methods(db: Session = Depends(get_core_db)): - return db.query(PaymentMethod).all() @router.get("/{key}", response_model=PaymentMethodDTO) -def get_payment_method(key: str, db: Session = Depends(get_core_db)): +def get_payment_method( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(PaymentMethod).filter(PaymentMethod.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=PaymentMethodDTO, status_code=201) def create_payment_method( data: PaymentMethodDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = PaymentMethod(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_payment_method( db.refresh(obj) return obj + @router.put("/{key}", response_model=PaymentMethodDTO) def update_payment_method( key: str, data: PaymentMethodDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(PaymentMethod).filter(PaymentMethod.key == key).first() if not obj: @@ -46,11 +71,12 @@ def update_payment_method( db.refresh(obj) return obj + @router.delete("/{key}", status_code=204) def delete_payment_method( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(PaymentMethod).filter(PaymentMethod.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/payment_methods/seed.py b/backend/api/v1/modules/public/reference_data/payment_methods/seed.py index b385f170..6ca6b29d 100644 --- a/backend/api/v1/modules/public/reference_data/payment_methods/seed.py +++ b/backend/api/v1/modules/public/reference_data/payment_methods/seed.py @@ -10,7 +10,10 @@ seed = [ ("18", "ESTIMULO FISCAL."), ("19", "OTROS MEDIOS DE GARANTIA."), ("2", "FIANZA."), - ("20", "DEROGADA. --- (PAGO CONFORME AL ARTICULO 7 DE LA LEY DE INGRESOS DE LA FEDERACION, VIGENTE)"), + ( + "20", + "DEROGADA. --- (PAGO CONFORME AL ARTICULO 7 DE LA LEY DE INGRESOS DE LA FEDERACION, VIGENTE)", + ), ("21", "CRÉDITO EN IVA E IEPS."), ("22", "GARANTÍA EN IVA E IEPS."), ("4", "DEPOSITO EN CUENTA ADUANERA."), @@ -19,4 +22,4 @@ seed = [ ("7", "CARGO A PARTIDA PRESUPUESTAL GOBIERNO FEDERAL."), ("8", "FRANQUICIA."), ("9", "EXENTO DE PAGO."), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/payment_methods/test_payment_methods.py b/backend/api/v1/modules/public/reference_data/payment_methods/test_payment_methods.py new file mode 100644 index 00000000..4d80a60e --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/payment_methods/test_payment_methods.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.payment_methods.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_payment_methods(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/payment-methods/", 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_payment_method_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/payment-methods/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_payment_method_forbidden(): + response = client.post( + "/payment-methods/", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_payment_method_forbidden(): + response = client.put( + "/payment-methods/TST", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_payment_method_forbidden(): + response = client.delete("/payment-methods/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/dto.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/dto.py index f44cb77b..ac7dc60c 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_codes/dto.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/dto.py @@ -1,10 +1,8 @@ -from pydantic import BaseModel, Field -from typing import Optional +from pydantic import BaseModel, ConfigDict, Field + class PedimentoCodeDTO(BaseModel): code: str = Field(..., min_length=1, max_length=3) description: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/models.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/models.py index ee816aeb..691f5117 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_codes/models.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/models.py @@ -1,25 +1,28 @@ -from typing import List -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, List + from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from ..code_pedimento_regimens.models import CodePedimentoRegimen + class PedimentoCode(Base): __tablename__ = "pedimento_codes" # GClavePed __table_args__ = ( PrimaryKeyConstraint("code", name="pedimento_codes_pkey"), - {"schema": "public"} # esquema del anexo 22 + {"schema": "public", "extend_existing": True}, # esquema del anexo 22 ) - code: Mapped[str] = mapped_column(String(3), nullable=False) + code: Mapped[str] = mapped_column(String(3), nullable=False) description: Mapped[str] = mapped_column(String(250), nullable=False) # Relación con los regímenes asociados - #GClavePedRegimen - regimens: Mapped[List['CodePedimentoRegimen']] = relationship( - "CodePedimentoRegimen", - uselist=True, - back_populates="pedimento" + # GClavePedRegimen + regimens: Mapped[List["CodePedimentoRegimen"]] = relationship( + "CodePedimentoRegimen", uselist=True, back_populates="pedimento" ) def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py index 954a566b..d7d69e4e 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import PedimentoCode +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import PedimentoCodeDTO +from .models import PedimentoCode -router = APIRouter(prefix="/pedimento-codes", tags=["Pedimento Codes"]) +router = APIRouter(prefix="/pedimento-codes") + + +@router.get("/", response_model=Dict[str, Any]) +def list_pedimento_codes( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(PedimentoCode) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [PedimentoCodeDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[PedimentoCodeDTO]) -def list_pedimento_codes(db: Session = Depends(get_core_db)): - return db.query(PedimentoCode).all() @router.get("/{code}", response_model=PedimentoCodeDTO) -def get_pedimento_code(code: str, db: Session = Depends(get_core_db)): +def get_pedimento_code( + code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(PedimentoCode).filter(PedimentoCode.code == code).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=PedimentoCodeDTO, status_code=201) def create_pedimento_code( data: PedimentoCodeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = PedimentoCode(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_pedimento_code( db.refresh(obj) return obj + @router.put("/{code}", response_model=PedimentoCodeDTO) def update_pedimento_code( code: str, data: PedimentoCodeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(PedimentoCode).filter(PedimentoCode.code == code).first() if not obj: @@ -46,11 +71,12 @@ def update_pedimento_code( db.refresh(obj) return obj + @router.delete("/{code}", status_code=204) def delete_pedimento_code( code: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(PedimentoCode).filter(PedimentoCode.code == code).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/seed.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/seed.py index dace16cd..1afbcea6 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_codes/seed.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/seed.py @@ -3,27 +3,69 @@ seed = [ ("A3", "REGULARIZACION DE MERCANCIAS (IMPORTACION DEFINITIVA)."), ("A4", "INTRODUCCION PARA DEPOSITO FISCAL (AGD)."), ("A5", "INTRODUCCION A DEPOSITO FISCAL EN LOCAL AUTORIZADO."), - ("A6", "IMPORTACIÓN TEMPORAL DE BIENES DE ACTIVO FIJO POR PARTE DE EMPRESAS CON PITEX."), - ("AD", "IMPORTACIÓN TEMPORAL DE MERCANCIAS DESTINADAS A CONVENCIONES Y CONGRESOS INTERNACIONALES (ARTICULO 106, FRACCION III, INCISO A) DE LA LEY)."), + ( + "A6", + "IMPORTACIÓN TEMPORAL DE BIENES DE ACTIVO FIJO POR PARTE DE EMPRESAS CON PITEX.", + ), + ( + "AD", + "IMPORTACIÓN TEMPORAL DE MERCANCIAS DESTINADAS A CONVENCIONES Y CONGRESOS INTERNACIONALES (ARTICULO 106, FRACCION III, INCISO A) DE LA LEY).", + ), ("AF", "IMPORTACION TEMPORAL DE BIENES DE ACTIVO FIJO (IMMEX)."), - ("AJ", "IMPORTACION Y EXPORTACION TEMPORAL DE ENVASES DE MERCANCIAS (ARTICULOS 106, FRACCION II, INCISO B) Y 116, FRACCION II, INCISO A) DE LA LEY)."), - ("BA", "IMPORTACION Y EXPORTACION TEMPORAL DE BIENES PARA SER RETORNADOS EN SU MISMO ESTADO. (ARTICULO 106, FRACCIONES II, INCISOS A) Y C), Y IV, INCISO B) DE LA LEY)."), + ( + "AJ", + "IMPORTACION Y EXPORTACION TEMPORAL DE ENVASES DE MERCANCIAS (ARTICULOS 106, FRACCION II, INCISO B) Y 116, FRACCION II, INCISO A) DE LA LEY).", + ), + ( + "BA", + "IMPORTACION Y EXPORTACION TEMPORAL DE BIENES PARA SER RETORNADOS EN SU MISMO ESTADO. (ARTICULO 106, FRACCIONES II, INCISOS A) Y C), Y IV, INCISO B) DE LA LEY).", + ), ("BB", "EXPORTACION, IMPORTACION Y RETORNOS VIRTUALES."), - ("BC", "IMPORTACION Y EXPORTACION TEMPORAL DE MERCANCIAS DESTINADAS A EVENTOS CULTURALES O DEPORTIVOS (ARTICULO 106, FRACCION III, INCISO B DE LA LEY)."), - ("BD", "IMPORTACION Y EXPORTACION TEMPORAL DE EQUIPO PARA FILMACION (ARTICULOS 106, FRACCION III, INCISO C) Y 116, FRACCION II INCISO D) DE LA LEY)."), - ("BE", "IMPORTACION Y EXPORTACION TEMPORAL DE VEHICULOS DE PRUEBA (ARTICULO 106, FRACCION III, INCISO D) DE LA LEY)."), - ("BF", "EXPORTACION TEMPORAL DE MERCANCIAS DESTINADAS A EXPOSICIONES, CONVENCIONES O EVENTOS CULTURALES O DEPORTIVOS (ARTICULO 116, FRACCION III DE LA LEY)."), - ("BH", "IMPORTACION TEMPORAL DE CONTENEDORES, AVIONES, HELICOPTEROS, EMBARCACIONES Y CARROS DE FERROCARRIL (ARTICULO 106, FRACCION V, INCISOS A), B) Y E) DE LA LEY)."), + ( + "BC", + "IMPORTACION Y EXPORTACION TEMPORAL DE MERCANCIAS DESTINADAS A EVENTOS CULTURALES O DEPORTIVOS (ARTICULO 106, FRACCION III, INCISO B DE LA LEY).", + ), + ( + "BD", + "IMPORTACION Y EXPORTACION TEMPORAL DE EQUIPO PARA FILMACION (ARTICULOS 106, FRACCION III, INCISO C) Y 116, FRACCION II INCISO D) DE LA LEY).", + ), + ( + "BE", + "IMPORTACION Y EXPORTACION TEMPORAL DE VEHICULOS DE PRUEBA (ARTICULO 106, FRACCION III, INCISO D) DE LA LEY).", + ), + ( + "BF", + "EXPORTACION TEMPORAL DE MERCANCIAS DESTINADAS A EXPOSICIONES, CONVENCIONES O EVENTOS CULTURALES O DEPORTIVOS (ARTICULO 116, FRACCION III DE LA LEY).", + ), + ( + "BH", + "IMPORTACION TEMPORAL DE CONTENEDORES, AVIONES, HELICOPTEROS, EMBARCACIONES Y CARROS DE FERROCARRIL (ARTICULO 106, FRACCION V, INCISOS A), B) Y E) DE LA LEY).", + ), ("BI", "IMPORTACION TEMPORAL (ARTICULO 106, FRACCION III, INCISO E) DE LA LEY)."), - ("BM", "EXPORTACION TEMPORAL DE MERCANCIAS PARA SU TRANSFORMACION, ELABORACION O REPARACION (ARTICULO 117 DE LA LEY)."), - ("BO", "EXPORTACION TEMPORAL PARA REPARACION O SUSTITUCION Y RETORNO AL PAIS (IMMEX, RFE U OPERADOR ECONOMICO AUTORIZADO."), - ("BP", "IMPORTACION Y EXPORTACION TEMPORAL DE MUESTRAS O MUESTRARIOS (ARTICULOS 106, FRACCION II, INCISO D) Y 116, FRACCION II, INCISO C) DE LA LEY)."), + ( + "BM", + "EXPORTACION TEMPORAL DE MERCANCIAS PARA SU TRANSFORMACION, ELABORACION O REPARACION (ARTICULO 117 DE LA LEY).", + ), + ( + "BO", + "EXPORTACION TEMPORAL PARA REPARACION O SUSTITUCION Y RETORNO AL PAIS (IMMEX, RFE U OPERADOR ECONOMICO AUTORIZADO.", + ), + ( + "BP", + "IMPORTACION Y EXPORTACION TEMPORAL DE MUESTRAS O MUESTRARIOS (ARTICULOS 106, FRACCION II, INCISO D) Y 116, FRACCION II, INCISO C) DE LA LEY).", + ), ("BR", "EXPORTACION TEMPORAL Y RETORNO DE MERCANCIAS FUNGIBLES."), - ("C1", "IMPORTACION DEFINITIVA A LA FRANJA FRONTERIZA NORTE Y REGION FRONTERIZA AL AMPARO DEL „DECRETO DE LA FRANJA O REGION FRONTERIZA“ (DOF 24/12/2008 Y SUS POSTERIORES MODIFICACIONES)."), + ( + "C1", + "IMPORTACION DEFINITIVA A LA FRANJA FRONTERIZA NORTE Y REGION FRONTERIZA AL AMPARO DEL „DECRETO DE LA FRANJA O REGION FRONTERIZA“ (DOF 24/12/2008 Y SUS POSTERIORES MODIFICACIONES).", + ), ("C3", "EXTRACCION DE DEPOSITO FISCAL DE FRANJA O REGION FRONTERIZA (AGD)."), ("CT", "PEDIMENTO COMPLEMENTARIO."), ("D1", "RETORNO POR SUSTITUCION."), - ("E1", "EXTRACCION DE DEPOSITO FISCAL DE BIENES QUE SERAN SUJETOS A TRANSFORMACION, ELABORACION O REPARACION (AGD)."), + ( + "E1", + "EXTRACCION DE DEPOSITO FISCAL DE BIENES QUE SERAN SUJETOS A TRANSFORMACION, ELABORACION O REPARACION (AGD).", + ), ("E2", "EXTRACCION DE DEPOSITO FISCAL DE BIENES DE ACTIVO FIJO (AGD)."), ("E3", "EXTRACCION DE DEPOSITO FISCAL EN LOCAL AUTORIZADO (INSUMOS)."), ("E4", "EXTRACCION DE DEPOSITO FISCAL EN LOCAL AUTORIZADO (ACTIVO FIJO)."), @@ -31,49 +73,112 @@ seed = [ ("F3", "EXTRACCION DE DEPOSITO FISCAL (IA)."), ("F4", "CAMBIO DE REGIMEN DE INSUMOS O DE MERCANCIA EXPORTADA TEMPORALMENTE."), ("F5", "CAMBIO DE REGIMEN DE MERCANCÍAS DE IMPORTACIÓN TEMPORAL A DEFINITIVA."), - ("F8", "INTRODUCCION Y EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS NACIONALES O NACIONALIZADAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE)."), - ("F9", "INTRODUCCION Y EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS EXTRANJERAS PARA EXPOSICION Y VENTA DE MERCANCIAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE)."), + ( + "F8", + "INTRODUCCION Y EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS NACIONALES O NACIONALIZADAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE).", + ), + ( + "F9", + "INTRODUCCION Y EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS EXTRANJERAS PARA EXPOSICION Y VENTA DE MERCANCIAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE).", + ), ("G1", "EXTRACCION DE DEPOSITO FISCAL (AGD)."), - ("G2", "EXTRACCION DE DEPOSITO FISCAL EN LOCAL AUTORIZADO PARA SU IMPORTACION DEFINITIVA."), - ("G6", "INFORME DE EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS NACIONALES O NACIONALIZADAS VENDIDAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE)."), - ("G7", "INFORME DE EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS EXTRANJERAS VENDIDAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE)."), + ( + "G2", + "EXTRACCION DE DEPOSITO FISCAL EN LOCAL AUTORIZADO PARA SU IMPORTACION DEFINITIVA.", + ), + ( + "G6", + "INFORME DE EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS NACIONALES O NACIONALIZADAS VENDIDAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE).", + ), + ( + "G7", + "INFORME DE EXTRACCION DE DEPOSITO FISCAL DE MERCANCIAS EXTRANJERAS VENDIDAS EN TIENDAS LIBRES DE IMPUESTOS (DUTY FREE).", + ), ("G8", "REINCORPORAR AL MERCADO NACIONAL (RFE)."), - ("G9", "TRANSFERENCIA DE MERCANCÍAS DE RECINTO FISCALIZADO ESTRATEGICO NO COLINDANTE CON LA ADUANA (RETIRO VIRTUAL PARA IMPORTACIÓN DEFINTIVA POR RESIDENTES EN TERRITORIO NACIONAL)."), + ( + "G9", + "TRANSFERENCIA DE MERCANCÍAS DE RECINTO FISCALIZADO ESTRATEGICO NO COLINDANTE CON LA ADUANA (RETIRO VIRTUAL PARA IMPORTACIÓN DEFINTIVA POR RESIDENTES EN TERRITORIO NACIONAL).", + ), ("GC", "GLOBAL COMPLEMENTARIO."), ("H1", "RETORNO DE MERCANCIAS EN SU MISMO ESTADO."), ("H8", "RETORNO DE ENVASES."), - ("I1", "IMPORTACION, EXPORTACION Y RETORNO DE MERCANCIAS ELABORADAS, TRANSFORMADAS O REPARADAS."), - ("IN", "IMPORTACION TEMPORAL DE BIENES QUE SERAN SUJETOS A TRANSFORMACION, ELABORACION O REPARACION (IMMEX)."), - ("J3", "RETORNO Y EXPORTACION DE INSUMOS ELABORADOS O TRANSFORMADOS EN RECINTO FISCALIZADO."), + ( + "I1", + "IMPORTACION, EXPORTACION Y RETORNO DE MERCANCIAS ELABORADAS, TRANSFORMADAS O REPARADAS.", + ), + ( + "IN", + "IMPORTACION TEMPORAL DE BIENES QUE SERAN SUJETOS A TRANSFORMACION, ELABORACION O REPARACION (IMMEX).", + ), + ( + "J3", + "RETORNO Y EXPORTACION DE INSUMOS ELABORADOS O TRANSFORMADOS EN RECINTO FISCALIZADO.", + ), ("J4", "RETORNO DE MERCANCIAS EXTRANJERAS (RFE)."), ("K1", "DESISTIMIENTO DE REGIMEN Y RETORNO DE MERCANCIAS POR DEVOLUCION."), ("K2", "EXTRACCION DE DEPOSITO FISCAL POR DESISTIMIENTO O TRANSFERENCIAS (AGD)."), - ("K3", "EXTRACCION DE DEPOSITO FISCAL EN LOCAL AUTORIZADO PARA RETORNO O TRANSFERENCIA."), + ( + "K3", + "EXTRACCION DE DEPOSITO FISCAL EN LOCAL AUTORIZADO PARA RETORNO O TRANSFERENCIA.", + ), ("L1", "PEQUEÑA IMPORTACION DEFINITIVA."), ("M1", "INTRODUCCION Y EXPORTACION DE INSUMOS."), ("M2", "INTRODUCCION Y EXPORTACION DE MAQUINARIA Y EQUIPO."), ("M3", "INTRODUCCION DE MERCANCIAS (RFE)."), ("M4", "INTRODUCCION DE ACTIVO FIJO (RFE)."), ("M5", "INTRODUCCION DE MERCANCIA NACIONAL O NACIONALIZADA (RFE)."), - ("P1", "REEXPEDICION DE MERCANCIAS DE FRANJA FRONTERIZA O REGION FRONTERIZA AL INTERIOR DEL PAIS."), + ( + "P1", + "REEXPEDICION DE MERCANCIAS DE FRANJA FRONTERIZA O REGION FRONTERIZA AL INTERIOR DEL PAIS.", + ), ("R1", "RECTIFICACION DE PEDIMENTOS."), ("RT", "RETORNO DE MERCANCIAS (IMMEX)."), - ("S2", "IMPORTACION Y EXPORTACION DE MERCANCIAS PARA RETORNAR EN SU MISMO ESTADO (ARTICULO 86 DE LA LEY)."), + ( + "S2", + "IMPORTACION Y EXPORTACION DE MERCANCIAS PARA RETORNAR EN SU MISMO ESTADO (ARTICULO 86 DE LA LEY).", + ), ("T1", "IMPORTACION Y EXPORTACION POR EMPRESAS DE MENSAJERIA."), ("T3", "TRANSITO INTERNO."), ("T6", "TRANSITO INTERNACIONAL POR TERRITORIO EXTRANJERO."), ("T7", "TRANSITO INTERNACIONAL POR TERRITORIO NACIONAL."), ("T9", "TRANSITO INTERNACIONAL DE TRANSMIGRANTES."), - ("V1", "TRANSFERENCIAS DE MERCANCIAS (IMPORTACION TEMPORAL VIRTUAL; INTRODUCCION VIRTUAL A DEPOSITO FISCAL O A RECINTO FISCALIZADO ESTRATEGICO; RETORNO VIRTUAL; EXPORTACION VIRTUAL DE PROVEEDORES NACIONALES)."), - ("V2", "TRANSFERENCIAS DE MERCANCIAS IMPORTADAS CON CUENTA ADUANERA (EXPORTACION E IMPORTACION VIRTUAL)."), - ("V3", "EXTRACCION DE DEPOSITO FISCAL DE BIENES PARA SU RETORNO O EXPORTACION VIRTUAL (IA)."), - ("V4", "RETORNO VIRTUAL DERIVADO DE LA CONSTANCIA DE TRANSFERENCIA DE MERCANCIAS (IA)."), - ("V5", "TRANSFERENCIAS DE MERCANCIAS DE EMPRESAS CERTIFICADAS (RETORNO VIRTUAL PARA IMPORTACION DEFINITIVA)."), - ("V6", "TRANSFERENCIAS DE MERCANCIAS SUJETAS A CUPO (IMPORTACION DEFINITIVA Y RETORNO VIRTUAL)."), - ("V7", "TRANSFERENCIAS DEL SECTOR AZUCARERO (EXPORTACION VIRTUAL E IMPORTACION TEMPORAL VIRTUAL)."), - ("V8", "TRANSFERENCIA DE MERCANCIAS EN DEPOSITO FISCAL PARA LA EXPOSICION Y VENTA DE MERCANCIAS EXTRANJERAS, NACIONALES Y NACIONALIZADAS DE TIENDAS LIBRES DE IMPUESTOS (DUTY FREE)."), + ( + "V1", + "TRANSFERENCIAS DE MERCANCIAS (IMPORTACION TEMPORAL VIRTUAL; INTRODUCCION VIRTUAL A DEPOSITO FISCAL O A RECINTO FISCALIZADO ESTRATEGICO; RETORNO VIRTUAL; EXPORTACION VIRTUAL DE PROVEEDORES NACIONALES).", + ), + ( + "V2", + "TRANSFERENCIAS DE MERCANCIAS IMPORTADAS CON CUENTA ADUANERA (EXPORTACION E IMPORTACION VIRTUAL).", + ), + ( + "V3", + "EXTRACCION DE DEPOSITO FISCAL DE BIENES PARA SU RETORNO O EXPORTACION VIRTUAL (IA).", + ), + ( + "V4", + "RETORNO VIRTUAL DERIVADO DE LA CONSTANCIA DE TRANSFERENCIA DE MERCANCIAS (IA).", + ), + ( + "V5", + "TRANSFERENCIAS DE MERCANCIAS DE EMPRESAS CERTIFICADAS (RETORNO VIRTUAL PARA IMPORTACION DEFINITIVA).", + ), + ( + "V6", + "TRANSFERENCIAS DE MERCANCIAS SUJETAS A CUPO (IMPORTACION DEFINITIVA Y RETORNO VIRTUAL).", + ), + ( + "V7", + "TRANSFERENCIAS DEL SECTOR AZUCARERO (EXPORTACION VIRTUAL E IMPORTACION TEMPORAL VIRTUAL).", + ), + ( + "V8", + "TRANSFERENCIA DE MERCANCIAS EN DEPOSITO FISCAL PARA LA EXPOSICION Y VENTA DE MERCANCIAS EXTRANJERAS, NACIONALES Y NACIONALIZADAS DE TIENDAS LIBRES DE IMPUESTOS (DUTY FREE).", + ), ("V9", "TRANSFERENCIAS DE MERCANCIAS POR DONACION"), ("VD", "VIRTUALES DIVERSOS."), - ("VF", "IMPORTACION DEFINITIVA DE VEHICULOS USADOS A LA FRANJA O REGION FRONTERIZA NORTE."), - ("VU", "IMPORTACION DEFINITIVA DE VEHICULOS USADOS.") -] \ No newline at end of file + ( + "VF", + "IMPORTACION DEFINITIVA DE VEHICULOS USADOS A LA FRANJA O REGION FRONTERIZA NORTE.", + ), + ("VU", "IMPORTACION DEFINITIVA DE VEHICULOS USADOS."), +] diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/test_pedimento_codes.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/test_pedimento_codes.py new file mode 100644 index 00000000..4c337331 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/test_pedimento_codes.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.pedimento_codes.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_pedimento_codes(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/pedimento-codes/", 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_pedimento_code_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/pedimento-codes/invalid_code", headers=headers) + assert response.status_code == 404 + + +def test_create_pedimento_code_forbidden(): + response = client.post( + "/pedimento-codes/", json={"code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_pedimento_code_forbidden(): + response = client.put( + "/pedimento-codes/TST", json={"code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_pedimento_code_forbidden(): + response = client.delete("/pedimento-codes/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/pedimento_regimens/dto.py b/backend/api/v1/modules/public/reference_data/pedimento_regimens/dto.py index 578aef9c..7e116920 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_regimens/dto.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_regimens/dto.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, Field -from typing import List +from pydantic import BaseModel, ConfigDict, Field + class RegimenPedimentoDTO(BaseModel): code: str = Field(..., min_length=1, max_length=3) description: str - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/pedimento_regimens/models.py b/backend/api/v1/modules/public/reference_data/pedimento_regimens/models.py index 0ddf57b9..a881a7ca 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_regimens/models.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_regimens/models.py @@ -1,24 +1,31 @@ -from typing import List -from sqlalchemy import String, PrimaryKeyConstraint, ForeignKey -from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, List + from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from ..code_pedimento_regimens.models import CodePedimentoRegimen + class RegimenPedimento(Base): - __tablename__ = "pedimento_regimens" #GRegimenPed + __tablename__ = "pedimento_regimens" # GRegimenPed __table_args__ = ( PrimaryKeyConstraint("code", name="pedimento_regimens_pkey"), - {"schema": "public"} + {"schema": "public", "extend_existing": True}, ) - code: Mapped[str] = mapped_column(String(3), nullable=False) # código tipo "01", "31" - description: Mapped[str] = mapped_column(String(100), nullable=False) # nombre legal en español + code: Mapped[str] = mapped_column( + String(3), nullable=False + ) # código tipo "01", "31" + description: Mapped[str] = mapped_column( + String(100), nullable=False + ) # nombre legal en español # Relación con Claves de Pedimento - #GClavePedRegimen - claves_pedimento: Mapped[List['CodePedimentoRegimen']] = relationship( - "CodePedimentoRegimen", - uselist=True, - back_populates="regimen" + # GClavePedRegimen + claves_pedimento: Mapped[List["CodePedimentoRegimen"]] = relationship( + "CodePedimentoRegimen", uselist=True, back_populates="regimen" ) def __repr__(self): diff --git a/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py index cf435fe7..d788510d 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_regimens/routes.py @@ -1,29 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import RegimenPedimento +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import RegimenPedimentoDTO +from .models import RegimenPedimento -router = APIRouter(prefix="/pedimento-regimens", tags=["Pedimento Regimens"]) +router = APIRouter(prefix="/pedimento-regimens") + + +@router.get("/", response_model=Dict[str, Any]) +def list_pedimento_regimens( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(RegimenPedimento) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [RegimenPedimentoDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[RegimenPedimentoDTO]) -def list_pedimento_regimens(db: Session = Depends(get_core_db)): - objs = db.query(RegimenPedimento).all() - return [RegimenPedimentoDTO.model_validate(obj) for obj in objs] @router.get("/{key}", response_model=RegimenPedimentoDTO) -def get_pedimento_regimen(key: str, db: Session = Depends(get_core_db)): +def get_pedimento_regimen( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return RegimenPedimentoDTO.model_validate(obj) + @router.post("/", response_model=RegimenPedimentoDTO, status_code=201) def create_pedimento_regimen( data: RegimenPedimentoDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = RegimenPedimento(**data.model_dump()) db.add(obj) @@ -31,12 +54,13 @@ def create_pedimento_regimen( db.refresh(obj) return RegimenPedimentoDTO.model_validate(obj) + @router.put("/{key}", response_model=RegimenPedimentoDTO) def update_pedimento_regimen( key: str, data: RegimenPedimentoDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first() if not obj: @@ -47,11 +71,12 @@ def update_pedimento_regimen( db.refresh(obj) return RegimenPedimentoDTO.model_validate(obj) + @router.delete("/{key}", status_code=204) def delete_pedimento_regimen( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/pedimento_regimens/seed.py b/backend/api/v1/modules/public/reference_data/pedimento_regimens/seed.py index f154928e..386ea1d1 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_regimens/seed.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_regimens/seed.py @@ -1,12 +1,18 @@ seed = [ - ("DFI", "DEPOSITO FISCAL."), - ("ETE", "TEMPORALES DE EXPORTACION PARA ELABORACION, TRANSFORMACION O REPARACION."), - ("ETR", "TEMPORALES DE EXPORTACION PARA RETORNAR AL PAIS EN EL MISMO ESTADO."), - ("EXD", "DEFINITIVO DE EXPORTACIÓN."), - ("IMD", "DEFINITIVO DE IMPORTACIÓN."), - ("ITE", "TEMPORALES DE IMPORTACION PARA ELABORACION, TRANSFORMACION O REPARACION PARA EMPRESAS CON PROGRAMA I"), - ("ITR", "TEMPORALES DE IMPORTACION PARA RETORNAR AL EXTRANJERO EN EL MISMO ESTADO."), - ("RFE", "ELABORACION, TRANSFORMACION O REPARACION EN RECINTO FISCALIZADO."), - ("RFS", "RECINTO FISCALIZADO ESTRATEGICO."), - ("TRA", "TRANSITOS.") -] \ No newline at end of file + ("DFI", "DEPOSITO FISCAL."), + ("ETE", "TEMPORALES DE EXPORTACION PARA ELABORACION, TRANSFORMACION O REPARACION."), + ("ETR", "TEMPORALES DE EXPORTACION PARA RETORNAR AL PAIS EN EL MISMO ESTADO."), + ("EXD", "DEFINITIVO DE EXPORTACIÓN."), + ("IMD", "DEFINITIVO DE IMPORTACIÓN."), + ( + "ITE", + "TEMPORALES DE IMPORTACION PARA ELABORACION, TRANSFORMACION O REPARACION PARA EMPRESAS CON PROGRAMA I", + ), + ( + "ITR", + "TEMPORALES DE IMPORTACION PARA RETORNAR AL EXTRANJERO EN EL MISMO ESTADO.", + ), + ("RFE", "ELABORACION, TRANSFORMACION O REPARACION EN RECINTO FISCALIZADO."), + ("RFS", "RECINTO FISCALIZADO ESTRATEGICO."), + ("TRA", "TRANSITOS."), +] diff --git a/backend/api/v1/modules/public/reference_data/pedimento_regimens/test_pedimento_regimens.py b/backend/api/v1/modules/public/reference_data/pedimento_regimens/test_pedimento_regimens.py new file mode 100644 index 00000000..0120ddd3 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/pedimento_regimens/test_pedimento_regimens.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.pedimento_regimens.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_pedimento_regimens(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/pedimento-regimens/", 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_pedimento_regimen_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/pedimento-regimens/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_pedimento_regimen_forbidden(): + response = client.post( + "/pedimento-regimens/", json={"code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_pedimento_regimen_forbidden(): + response = client.put( + "/pedimento-regimens/TST", json={"code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_pedimento_regimen_forbidden(): + response = client.delete("/pedimento-regimens/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/router.py b/backend/api/v1/modules/public/reference_data/router.py new file mode 100644 index 00000000..6d5756f1 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/router.py @@ -0,0 +1,118 @@ +""" +Router principal de API v1 +Agrega todos los módulos de la aplicación +""" + +from fastapi import APIRouter + +from .code_pedimento_regimens.routes import router as code_pedimento_regimens_router +from .containers.routes import router as containers_router +from .countries.routes import router as countries_router +from .currency_types.routes import router as currency_types_router +from .customs_sections.routes import router as customs_sections_router +from .customs_warehouses.routes import router as customs_warehouses_router +from .incoterms.routes import router as incoterms_router +from .invoice_types.routes import router as invoice_types_router +from .material_types.routes import router as material_types_router +from .payment_methods.routes import router as payment_methods_router +from .pedimento_codes.routes import router as pedimento_codes_router +from .pedimento_regimens.routes import router as pedimento_regimens_router +from .sectors.routes import router as sectors_router +from .states.routes import router as states_router +from .trailer_types.routes import router as trailer_types_router +from .transport_modes.routes import router as transport_modes_router +from .transport_types.routes import router as transport_types_router +from .valuation_methods.routes import router as valuation_methods_router + +# Router principal +router = APIRouter() + +# Registrar módulos +router.include_router( + pedimento_codes_router, + prefix="/refrence_data", + tags=["public / refrence_data / pedimento_codes"], +) +router.include_router( + payment_methods_router, + prefix="/refrence_data", + tags=["public / refrence_data / payment_methods"], +) +router.include_router( + containers_router, + prefix="/refrence_data", + tags=["public / refrence_data / containers"], +) +router.include_router( + countries_router, + prefix="/refrence_data", + tags=["public / refrence_data / countries"], +) +router.include_router( + material_types_router, + prefix="/refrence_data", + tags=["public / refrence_data / material_types"], +) +router.include_router( + currency_types_router, + prefix="/refrence_data", + tags=["public / refrence_data / currency_types"], +) +router.include_router( + states_router, prefix="/refrence_data", tags=["public / refrence_data / states"] +) +router.include_router( + trailer_types_router, + prefix="/refrence_data", + tags=["public / refrence_data / trailer_types"], +) +router.include_router( + transport_types_router, + prefix="/refrence_data", + tags=["public / refrence_data / transport_types"], +) +router.include_router( + customs_warehouses_router, + prefix="/refrence_data", + tags=["public / refrence_data / customs_warehouses"], +) +router.include_router( + valuation_methods_router, + prefix="/refrence_data", + tags=["public / refrence_data / valuation_methods"], +) +router.include_router( + sectors_router, + prefix="/refrence_data", + tags=["public / public / refrence_data / sectors"], +) +router.include_router( + transport_modes_router, + prefix="/refrence_data", + tags=["public / refrence_data / transport_modes"], +) +router.include_router( + customs_sections_router, + prefix="/refrence_data", + tags=["public / refrence_data / customs_sections"], +) +router.include_router( + invoice_types_router, + prefix="/refrence_data", + tags=["public / refrence_data / invoice_types"], +) +router.include_router( + code_pedimento_regimens_router, + prefix="/refrence_data", + tags=["public / refrence_data / code_pedimento_regimens"], +) +router.include_router( + pedimento_regimens_router, + prefix="/refrence_data", + tags=["public / refrence_data / pedimento_regimens"], +) +router.include_router( + incoterms_router, + prefix="/refrence_data", + tags=["public / refrence_data / incoterms"], +) diff --git a/backend/api/v1/modules/public/reference_data/sectors/dto.py b/backend/api/v1/modules/public/reference_data/sectors/dto.py index 0bfd002d..f25ca5dd 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/dto.py +++ b/backend/api/v1/modules/public/reference_data/sectors/dto.py @@ -1,10 +1,9 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class SectorDTO(BaseModel): key: str = Field(..., min_length=1, max_length=8) description: str authorized: int - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/sectors/models.py b/backend/api/v1/modules/public/reference_data/sectors/models.py index 6a3666f0..f87fa0ef 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/models.py +++ b/backend/api/v1/modules/public/reference_data/sectors/models.py @@ -1,17 +1,23 @@ -from sqlalchemy import String, SmallInteger, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, SmallInteger, String +from sqlalchemy.orm import Mapped, mapped_column + class Sector(Base): - __tablename__ = "sectors" #GSectores + __tablename__ = "sectors" # GSectores __table_args__ = ( PrimaryKeyConstraint("key", name="sectors_pkey"), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, # opcional ) - key: Mapped[str] = mapped_column(String(8), nullable=False) # clave del sector - description: Mapped[str] = mapped_column(String(150), nullable=False) # descripción oficial (en español) - authorized: Mapped[SmallInteger] = mapped_column(SmallInteger) # 1 = autorizado, 0 = no autorizado + key: Mapped[str] = mapped_column( + String(8), nullable=False) # clave del sector + description: Mapped[str] = mapped_column( + String(150), nullable=False + ) # descripción oficial (en español) + authorized: Mapped[SmallInteger] = mapped_column( + SmallInteger + ) # 1 = autorizado, 0 = no autorizado def __repr__(self): return f"" diff --git a/backend/api/v1/modules/public/reference_data/sectors/routes.py b/backend/api/v1/modules/public/reference_data/sectors/routes.py index 59e21912..05aa0f72 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/routes.py +++ b/backend/api/v1/modules/public/reference_data/sectors/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import Sector +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import SectorDTO +from .models import Sector -router = APIRouter(prefix="/sectors", tags=["Sectors"]) +router = APIRouter(prefix="/sectors") + + +@router.get("/", response_model=Dict[str, Any]) +def list_sectors( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(Sector) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [SectorDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[SectorDTO]) -def list_sectors(db: Session = Depends(get_core_db)): - return db.query(Sector).all() @router.get("/{key}", response_model=SectorDTO) -def get_sector(key: str, db: Session = Depends(get_core_db)): +def get_sector( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(Sector).filter(Sector.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=SectorDTO, status_code=201) def create_sector( data: SectorDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = Sector(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_sector( db.refresh(obj) return obj + @router.put("/{key}", response_model=SectorDTO) def update_sector( key: str, data: SectorDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Sector).filter(Sector.key == key).first() if not obj: @@ -46,11 +71,12 @@ def update_sector( db.refresh(obj) return obj + @router.delete("/{key}", status_code=204) def delete_sector( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(Sector).filter(Sector.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/sectors/seed.py b/backend/api/v1/modules/public/reference_data/sectors/seed.py index d1165734..9eb39401 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/seed.py +++ b/backend/api/v1/modules/public/reference_data/sectors/seed.py @@ -1,8 +1,16 @@ seed = [ ("I", "INDUSTRIA ELECTRICA", "0"), ("II", "INDUSTRIA ELECTRONICA", "0"), - ("IIa", "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO a) o b), DE ARTICULO 4to DE ESTE DECRETO.", "0"), - ("IIb", "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO b), DE ARTICULO 4to DE ESTE DECRETO.", "0"), + ( + "IIa", + "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO a) o b), DE ARTICULO 4to DE ESTE DECRETO.", + "0", + ), + ( + "IIb", + "PARA LOS BIENES A QUE SE REFIERE LA FRACCION II, INCISO b), DE ARTICULO 4to DE ESTE DECRETO.", + "0", + ), ("III", "INDUSTRIA DEL MUEBLE", "0"), ("IV", "INDUSTRIA DEL JUGUETE, JUEGOS DE RECREO Y ARTICULOS DEPORTIVOS", "0"), ("IX", "INDUSTRIA DE MAQUINARIA AGRICOLA", "0"), @@ -18,9 +26,21 @@ seed = [ ("XIX", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", "0"), ("XIXa", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", "0"), ("XIXb", "INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", "0"), - ("XV", "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", "0"), - ("XVa", "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", "0"), - ("XVb", "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", "0"), + ( + "XV", + "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES", + "0", + ), + ( + "XVa", + "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", + "0", + ), + ( + "XVb", + "INDUSTRIA DEL TRANSPORTE, EXCEPTO EL SECTOR DE LA INDUSTRIA AUTOMOTRIZ Y DE AUTOPARTES.", + "0", + ), ("XVI", "INDUSTRIA DEL PAPEL Y CARTON", "0"), ("XVII", "INDUSTRIA DE LA MADERA", "0"), ("XVIII", "INDUSTRIA DEL CUERO Y PIELES", "0"), @@ -32,4 +52,4 @@ seed = [ ("XXe", "INDUSTRIA TEXTIL Y DE LA CONFECCION", "0"), ("XXI", "INDUSTRIA DE CHOCOLATES, DULCES Y SIMILARES", "0"), ("XXII", "INDUSTRIA DEL CAFE", "0"), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/sectors/test_sectors.py b/backend/api/v1/modules/public/reference_data/sectors/test_sectors.py new file mode 100644 index 00000000..872ea295 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/sectors/test_sectors.py @@ -0,0 +1,40 @@ +import pytest +from api.v1.modules.public.reference_data.sectors.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_sectors(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/sectors/", 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_sector_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/sectors/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_sector_forbidden(): + response = client.post("/sectors/", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_update_sector_forbidden(): + response = client.put("/sectors/TST", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_delete_sector_forbidden(): + response = client.delete("/sectors/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/states/dto.py b/backend/api/v1/modules/public/reference_data/states/dto.py index 8070c740..8262e875 100644 --- a/backend/api/v1/modules/public/reference_data/states/dto.py +++ b/backend/api/v1/modules/public/reference_data/states/dto.py @@ -1,12 +1,12 @@ -from pydantic import BaseModel, Field from typing import Optional +from pydantic import BaseModel, ConfigDict, Field + + class StateDTO(BaseModel): m3_key: str = Field(..., min_length=1, max_length=3) description: str mex_key: Optional[str] = None ame_key: Optional[str] = None - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/states/models.py b/backend/api/v1/modules/public/reference_data/states/models.py index 64729fcb..ce832c98 100644 --- a/backend/api/v1/modules/public/reference_data/states/models.py +++ b/backend/api/v1/modules/public/reference_data/states/models.py @@ -1,17 +1,21 @@ from typing import Optional -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped + from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class State(Base): - __tablename__ = "states" #GEstados + __tablename__ = "states" # GEstados __table_args__ = ( - PrimaryKeyConstraint('m3_key', 'description', name='states_pkey'), - {"schema": "public"} + PrimaryKeyConstraint("m3_key", "description", name="states_pkey"), + {"schema": "public", "extend_existing": True}, ) m3_key: Mapped[str] = mapped_column(String(3), nullable=False) - description: Mapped[str] = mapped_column(String(50), nullable=False) # valor legal en español + description: Mapped[str] = mapped_column( + String(50), nullable=False + ) # valor legal en español mex_key: Mapped[Optional[str]] = mapped_column(String(3)) ame_key: Mapped[Optional[str]] = mapped_column(String(2)) diff --git a/backend/api/v1/modules/public/reference_data/states/routes.py b/backend/api/v1/modules/public/reference_data/states/routes.py index 1835c9d9..151c1172 100644 --- a/backend/api/v1/modules/public/reference_data/states/routes.py +++ b/backend/api/v1/modules/public/reference_data/states/routes.py @@ -1,42 +1,69 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import State +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import StateDTO +from .models import State -router = APIRouter(prefix="/states", tags=["States"]) +router = APIRouter(prefix="/states") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_states( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(State) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [StateDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[StateDTO]) -def list_states(db: Session = Depends(get_core_db)): - return db.query(State).all() @router.get("/{m3_key}", response_model=StateDTO) -def get_state(m3_key: str, db: Session = Depends(get_core_db)): +async def get_state( + m3_key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(State).filter(State.m3_key == m3_key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=StateDTO, status_code=201) -def create_state( +async def create_state( data: StateDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): + obj = State(**data.dict()) db.add(obj) db.commit() db.refresh(obj) return obj + @router.put("/{m3_key}", response_model=StateDTO) -def update_state( +async def update_state( m3_key: str, data: StateDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): + obj = db.query(State).filter(State.m3_key == m3_key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -46,12 +73,14 @@ def update_state( db.refresh(obj) return obj + @router.delete("/{m3_key}", status_code=204) -def delete_state( +async def delete_state( m3_key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): + obj = db.query(State).filter(State.m3_key == m3_key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") diff --git a/backend/api/v1/modules/public/reference_data/states/seed.py b/backend/api/v1/modules/public/reference_data/states/seed.py index c7513c89..a8eefa57 100644 --- a/backend/api/v1/modules/public/reference_data/states/seed.py +++ b/backend/api/v1/modules/public/reference_data/states/seed.py @@ -1,3 +1 @@ -seed = [ - -] \ No newline at end of file +seed = [] diff --git a/backend/api/v1/modules/public/reference_data/states/test_states.py b/backend/api/v1/modules/public/reference_data/states/test_states.py new file mode 100644 index 00000000..b38b5202 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/states/test_states.py @@ -0,0 +1,40 @@ +import pytest +from api.v1.modules.public.reference_data.states.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_states(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/states/", 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_state_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/states/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_state_forbidden(): + response = client.post("/states/", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_update_state_forbidden(): + response = client.put("/states/TST", json={"key": "TST", "description": "Test"}) + assert response.status_code in (403, 405, 404) + + +def test_delete_state_forbidden(): + response = client.delete("/states/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/trailer_types/dto.py b/backend/api/v1/modules/public/reference_data/trailer_types/dto.py new file mode 100644 index 00000000..13a3f3fe --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/trailer_types/dto.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import BaseModel + + +class TrailerTypeBaseDTO(BaseModel): + trailer_type_key: str + description: Optional[str] + company_id: str + tenant_id: str + + +class TrailerTypeCreateDTO(TrailerTypeBaseDTO): + pass + + +class TrailerTypeResponseDTO(TrailerTypeBaseDTO): + class Config: + from_attributes = True diff --git a/backend/api/v1/modules/public/reference_data/trailer_types/models.py b/backend/api/v1/modules/public/reference_data/trailer_types/models.py new file mode 100644 index 00000000..f4cb7e8a --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/trailer_types/models.py @@ -0,0 +1,13 @@ +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import Column, ForeignKeyConstraint, String + + +class TrailerType(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "trailer_type" + __table_args__ = ( + {"schema": "a76"}, + ) + + trailer_type_key = Column(String(2), primary_key=True, nullable=False) + description = Column(String(100), nullable=True) diff --git a/backend/api/v1/modules/public/reference_data/trailer_types/routes.py b/backend/api/v1/modules/public/reference_data/trailer_types/routes.py new file mode 100644 index 00000000..079585fc --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/trailer_types/routes.py @@ -0,0 +1,36 @@ +from core.database import get_core_db +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from . import dto, services + +router = APIRouter() + + +@router.get( + "/trailer-types/{trailer_type_key}", response_model=dto.TrailerTypeResponseDTO +) +def get_trailer_type(trailer_type_key: str, db: Session = Depends(get_core_db)): + trailer_type = services.TrailerTypeService.get_trailer_type_by_key( + db, trailer_type_key + ) + if not trailer_type: + raise HTTPException(status_code=404, detail="Trailer type not found") + return trailer_type + + +@router.post("/trailer-types", response_model=dto.TrailerTypeResponseDTO) +def create_trailer_type( + trailer_type_data: dto.TrailerTypeCreateDTO, db: Session = Depends(get_core_db) +): + return services.TrailerTypeService.create_trailer_type(db, trailer_type_data) + + +@router.delete( + "/trailer-types/{trailer_type_key}", response_model=dto.TrailerTypeResponseDTO +) +def delete_trailer_type(trailer_type_key: str, db: Session = Depends(get_core_db)): + trailer_type = services.TrailerTypeService.delete_trailer_type(db, trailer_type_key) + if not trailer_type: + raise HTTPException(status_code=404, detail="Trailer type not found") + return trailer_type diff --git a/backend/api/v1/modules/public/reference_data/trailer_types/services.py b/backend/api/v1/modules/public/reference_data/trailer_types/services.py new file mode 100644 index 00000000..7e671c52 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/trailer_types/services.py @@ -0,0 +1,29 @@ +from sqlalchemy.orm import Session + +from . import dto, models + + +class TrailerTypeService: + @staticmethod + def get_trailer_type_by_key(db: Session, trailer_type_key: str): + return ( + db.query(models.TrailerType) + .filter(models.TrailerType.trailer_type_key == trailer_type_key) + .first() + ) + + @staticmethod + def create_trailer_type(db: Session, trailer_type_data: dto.TrailerTypeCreateDTO): + new_trailer_type = models.TrailerType(**trailer_type_data.dict()) + db.add(new_trailer_type) + db.commit() + db.refresh(new_trailer_type) + return new_trailer_type + + @staticmethod + def delete_trailer_type(db: Session, trailer_type_key: str): + trailer_type = TrailerTypeService.get_trailer_type_by_key(db, trailer_type_key) + if trailer_type: + db.delete(trailer_type) + db.commit() + return trailer_type diff --git a/backend/api/v1/modules/public/reference_data/transport_modes/dto.py b/backend/api/v1/modules/public/reference_data/transport_modes/dto.py index 5b85d32b..0923d4fb 100644 --- a/backend/api/v1/modules/public/reference_data/transport_modes/dto.py +++ b/backend/api/v1/modules/public/reference_data/transport_modes/dto.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class TransportModeDTO(BaseModel): key: str = Field(..., min_length=1, max_length=3) name: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/transport_modes/models.py b/backend/api/v1/modules/public/reference_data/transport_modes/models.py index 0158224a..8eecc40e 100644 --- a/backend/api/v1/modules/public/reference_data/transport_modes/models.py +++ b/backend/api/v1/modules/public/reference_data/transport_modes/models.py @@ -1,15 +1,16 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class TransportMode(Base): - __tablename__ = "transport_modes" #GModTransporte + __tablename__ = "transport_modes" # GModTransporte __table_args__ = ( PrimaryKeyConstraint("key", name="transport_modes_pkey"), - {"schema": "public"} # opcional + {"schema": "public", "extend_existing": True}, # opcional ) - key: Mapped[str] = mapped_column(String(3), nullable=False) + key: Mapped[str] = mapped_column(String(3), nullable=False) name: Mapped[str] = mapped_column(String(30), nullable=False) def __repr__(self): diff --git a/backend/api/v1/modules/public/reference_data/transport_modes/routes.py b/backend/api/v1/modules/public/reference_data/transport_modes/routes.py index 224dc779..25a22036 100644 --- a/backend/api/v1/modules/public/reference_data/transport_modes/routes.py +++ b/backend/api/v1/modules/public/reference_data/transport_modes/routes.py @@ -1,28 +1,47 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import TransportMode +from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import TransportModeDTO +from .models import TransportMode -router = APIRouter(prefix="/transport-modes", tags=["Transport Modes"]) +router = APIRouter(prefix="/transport-modes") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_transport_modes( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), +): + skip = (page - 1) * page_size + query = db.query(TransportMode) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [TransportModeDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[TransportModeDTO]) -def list_transport_modes(db: Session = Depends(get_core_db)): - return db.query(TransportMode).all() @router.get("/{key}", response_model=TransportModeDTO) -def get_transport_mode(key: str, db: Session = Depends(get_core_db)): +async def get_transport_mode(key: str, db: Session = Depends(get_core_db)): obj = db.query(TransportMode).filter(TransportMode.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=TransportModeDTO, status_code=201) -def create_transport_mode( +async def create_transport_mode( data: TransportModeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + user=Depends(get_current_user), ): obj = TransportMode(**data.dict()) db.add(obj) @@ -30,12 +49,13 @@ def create_transport_mode( db.refresh(obj) return obj + @router.put("/{key}", response_model=TransportModeDTO) -def update_transport_mode( +async def update_transport_mode( key: str, data: TransportModeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + user=Depends(get_current_user), ): obj = db.query(TransportMode).filter(TransportMode.key == key).first() if not obj: @@ -46,11 +66,10 @@ def update_transport_mode( db.refresh(obj) return obj + @router.delete("/{key}", status_code=204) -def delete_transport_mode( - key: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) +async def delete_transport_mode( + key: str, db: Session = Depends(get_core_db), user=Depends(get_current_user) ): obj = db.query(TransportMode).filter(TransportMode.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/transport_modes/seed.py b/backend/api/v1/modules/public/reference_data/transport_modes/seed.py index 55b396bd..6790d279 100644 --- a/backend/api/v1/modules/public/reference_data/transport_modes/seed.py +++ b/backend/api/v1/modules/public/reference_data/transport_modes/seed.py @@ -9,4 +9,4 @@ seed = [ ("40", "AIR"), ("41", "AIR CONTAINER"), ("50", "MAIL"), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/transport_modes/test_transport_modes.py b/backend/api/v1/modules/public/reference_data/transport_modes/test_transport_modes.py new file mode 100644 index 00000000..f88d414c --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/transport_modes/test_transport_modes.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.transport_modes.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_transport_modes(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/transport-modes/", 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_transport_mode_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/transport-modes/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_transport_mode_forbidden(): + response = client.post( + "/transport-modes/", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_transport_mode_forbidden(): + response = client.put( + "/transport-modes/TST", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_transport_mode_forbidden(): + response = client.delete("/transport-modes/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/transport_types/dto.py b/backend/api/v1/modules/public/reference_data/transport_types/dto.py index f19c9608..a026e496 100644 --- a/backend/api/v1/modules/public/reference_data/transport_types/dto.py +++ b/backend/api/v1/modules/public/reference_data/transport_types/dto.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class TransportTypeDTO(BaseModel): transport_code: str = Field(..., min_length=1, max_length=2) description: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/transport_types/models.py b/backend/api/v1/modules/public/reference_data/transport_types/models.py index 97072835..0838f66b 100644 --- a/backend/api/v1/modules/public/reference_data/transport_types/models.py +++ b/backend/api/v1/modules/public/reference_data/transport_types/models.py @@ -1,17 +1,21 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column class TransportType(Base): - __tablename__ = "transport_types" #GTiposTransporte + __tablename__ = "transport_types" # GTiposTransporte __table_args__ = ( PrimaryKeyConstraint("transport_code", name="transport_types_pkey"), - {"schema": "public"} + {"schema": "public", "extend_existing": True}, ) - transport_code: Mapped[str] = mapped_column(String(2), nullable=False) # código SAT o interno - description: Mapped[str] = mapped_column(String(100), nullable=False) # descripción del medio de transporte + transport_code: Mapped[str] = mapped_column( + String(2), nullable=False + ) # código SAT o interno + description: Mapped[str] = mapped_column( + String(100), nullable=False + ) # descripción del medio de transporte def __repr__(self): return f"" diff --git a/backend/api/v1/modules/public/reference_data/transport_types/routes.py b/backend/api/v1/modules/public/reference_data/transport_types/routes.py index 7716fbf8..69bf624e 100644 --- a/backend/api/v1/modules/public/reference_data/transport_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/transport_types/routes.py @@ -1,28 +1,51 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import TransportType +from core.security import get_current_user +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import TransportTypeDTO +from .models import TransportType -router = APIRouter(prefix="/transport-types", tags=["Transport Types"]) +router = APIRouter(prefix="/transport-types") + + +@router.get("/", response_model=Dict[str, Any]) +def list_transport_types( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), +): + skip = (page - 1) * page_size + query = db.query(TransportType) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [TransportTypeDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[TransportTypeDTO]) -def list_transport_types(db: Session = Depends(get_core_db)): - return db.query(TransportType).all() @router.get("/{transport_code}", response_model=TransportTypeDTO) def get_transport_type(transport_code: str, db: Session = Depends(get_core_db)): - obj = db.query(TransportType).filter(TransportType.transport_code == transport_code).first() + obj = ( + db.query(TransportType) + .filter(TransportType.transport_code == transport_code) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=TransportTypeDTO, status_code=201) def create_transport_type( data: TransportTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + user=Depends(get_current_user), ): obj = TransportType(**data.dict()) db.add(obj) @@ -30,14 +53,19 @@ def create_transport_type( db.refresh(obj) return obj + @router.put("/{transport_code}", response_model=TransportTypeDTO) def update_transport_type( transport_code: str, data: TransportTypeDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + user=Depends(get_current_user), ): - obj = db.query(TransportType).filter(TransportType.transport_code == transport_code).first() + obj = ( + db.query(TransportType) + .filter(TransportType.transport_code == transport_code) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") for field, value in data.dict().items(): @@ -46,13 +74,18 @@ def update_transport_type( db.refresh(obj) return obj + @router.delete("/{transport_code}", status_code=204) def delete_transport_type( transport_code: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + user=Depends(get_current_user), ): - obj = db.query(TransportType).filter(TransportType.transport_code == transport_code).first() + obj = ( + db.query(TransportType) + .filter(TransportType.transport_code == transport_code) + .first() + ) if not obj: raise HTTPException(status_code=404, detail="Not found") db.delete(obj) diff --git a/backend/api/v1/modules/public/reference_data/transport_types/seed.py b/backend/api/v1/modules/public/reference_data/transport_types/seed.py index d59b3d33..5868b5c6 100644 --- a/backend/api/v1/modules/public/reference_data/transport_types/seed.py +++ b/backend/api/v1/modules/public/reference_data/transport_types/seed.py @@ -19,4 +19,4 @@ seed = [ ("RV", "Recreation Vehicle (RV)"), ("TR", "Semi Tracker"), ("TV", "Van"), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/transport_types/test_transport_types.py b/backend/api/v1/modules/public/reference_data/transport_types/test_transport_types.py new file mode 100644 index 00000000..c2215df0 --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/transport_types/test_transport_types.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.transport_types.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_transport_types(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/transport-types/", 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_transport_type_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/transport-types/invalid_code", headers=headers) + assert response.status_code == 404 + + +def test_create_transport_type_forbidden(): + response = client.post( + "/transport-types/", json={"transport_code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_transport_type_forbidden(): + response = client.put( + "/transport-types/TST", json={"transport_code": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_transport_type_forbidden(): + response = client.delete("/transport-types/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/reference_data/valuation_methods/dto.py b/backend/api/v1/modules/public/reference_data/valuation_methods/dto.py index 0ecbca53..00978582 100644 --- a/backend/api/v1/modules/public/reference_data/valuation_methods/dto.py +++ b/backend/api/v1/modules/public/reference_data/valuation_methods/dto.py @@ -1,9 +1,8 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + class ValuationMethodDTO(BaseModel): key: str = Field(..., min_length=1, max_length=2) description: str - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/valuation_methods/models.py b/backend/api/v1/modules/public/reference_data/valuation_methods/models.py index 7a6aa20d..cdfe6588 100644 --- a/backend/api/v1/modules/public/reference_data/valuation_methods/models.py +++ b/backend/api/v1/modules/public/reference_data/valuation_methods/models.py @@ -1,15 +1,16 @@ -from sqlalchemy import String, PrimaryKeyConstraint -from sqlalchemy.orm import mapped_column, Mapped from core.database import Base +from sqlalchemy import PrimaryKeyConstraint, String +from sqlalchemy.orm import Mapped, mapped_column + class ValuationMethod(Base): - __tablename__ = "valuation_methods" #GMetValor + __tablename__ = "valuation_methods" # GMetValor __table_args__ = ( PrimaryKeyConstraint("key", name="valuation_methods_pkey"), - {"schema": "public"} + {"schema": "public", "extend_existing": True}, ) - key: Mapped[str] = mapped_column(String(2), nullable=False) + key: Mapped[str] = mapped_column(String(2), nullable=False) description: Mapped[str] = mapped_column(String(200), nullable=False) def __repr__(self): diff --git a/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py b/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py index 0fff8021..4742609d 100644 --- a/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py +++ b/backend/api/v1/modules/public/reference_data/valuation_methods/routes.py @@ -1,28 +1,52 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from typing import Any, Dict + from core.database import get_core_db -from core.security import has_role -from .models import ValuationMethod +from core.security import get_current_user, has_role +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + from .dto import ValuationMethodDTO +from .models import ValuationMethod -router = APIRouter(prefix="/valuation-methods", tags=["Valuation Methods"]) +router = APIRouter(prefix="/valuation-methods") + + +@router.get("/", response_model=Dict[str, Any]) +async def list_valuation_methods( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + skip = (page - 1) * page_size + query = db.query(ValuationMethod) + items = query.offset(skip).limit(page_size).all() + total = query.count() + return { + "items": [ValuationMethodDTO.model_validate(obj) for obj in items], + "total": total, + "page": page, + "page_size": page_size, + } -@router.get("/", response_model=list[ValuationMethodDTO]) -def list_valuation_methods(db: Session = Depends(get_core_db)): - return db.query(ValuationMethod).all() @router.get("/{key}", response_model=ValuationMethodDTO) -def get_valuation_method(key: str, db: Session = Depends(get_core_db)): +async def get_valuation_method( + key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): obj = db.query(ValuationMethod).filter(ValuationMethod.key == key).first() if not obj: raise HTTPException(status_code=404, detail="Not found") return obj + @router.post("/", response_model=ValuationMethodDTO, status_code=201) -def create_valuation_method( +async def create_valuation_method( data: ValuationMethodDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = ValuationMethod(**data.dict()) db.add(obj) @@ -30,12 +54,13 @@ def create_valuation_method( db.refresh(obj) return obj + @router.put("/{key}", response_model=ValuationMethodDTO) -def update_valuation_method( +async def update_valuation_method( key: str, data: ValuationMethodDTO, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(ValuationMethod).filter(ValuationMethod.key == key).first() if not obj: @@ -46,11 +71,12 @@ def update_valuation_method( db.refresh(obj) return obj + @router.delete("/{key}", status_code=204) -def delete_valuation_method( +async def delete_valuation_method( key: str, db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")) + current_user: dict = Depends(has_role("admin")), ): obj = db.query(ValuationMethod).filter(ValuationMethod.key == key).first() if not obj: diff --git a/backend/api/v1/modules/public/reference_data/valuation_methods/seed.py b/backend/api/v1/modules/public/reference_data/valuation_methods/seed.py index 3f861c6b..1542540a 100644 --- a/backend/api/v1/modules/public/reference_data/valuation_methods/seed.py +++ b/backend/api/v1/modules/public/reference_data/valuation_methods/seed.py @@ -6,4 +6,4 @@ seed = [ ("4", "VALOR DE PRECIO UNITARIO DE VENTA."), ("5", "VALOR RECONSTRUIDO."), ("6", "ULTIMO RECURSO"), -] \ No newline at end of file +] diff --git a/backend/api/v1/modules/public/reference_data/valuation_methods/test_valuation_methods.py b/backend/api/v1/modules/public/reference_data/valuation_methods/test_valuation_methods.py new file mode 100644 index 00000000..9fb8384b --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/valuation_methods/test_valuation_methods.py @@ -0,0 +1,44 @@ +import pytest +from api.v1.modules.public.reference_data.valuation_methods.routes import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@pytest.mark.usefixtures("client", "access_token") +def test_list_valuation_methods(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/valuation-methods/", 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_valuation_method_not_found(client, access_token): + headers = {"Authorization": f"Bearer {access_token}"} + response = client.get("/valuation-methods/invalid_key", headers=headers) + assert response.status_code == 404 + + +def test_create_valuation_method_forbidden(): + response = client.post( + "/valuation-methods/", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_update_valuation_method_forbidden(): + response = client.put( + "/valuation-methods/TST", json={"key": "TST", "description": "Test"} + ) + assert response.status_code in (403, 405, 404) + + +def test_delete_valuation_method_forbidden(): + response = client.delete("/valuation-methods/TST") + assert response.status_code in (403, 405, 404) diff --git a/backend/api/v1/modules/public/router.py b/backend/api/v1/modules/public/router.py new file mode 100644 index 00000000..22589835 --- /dev/null +++ b/backend/api/v1/modules/public/router.py @@ -0,0 +1,14 @@ +""" +Router principal de API v1 +Agrega todos los módulos de la aplicación +""" + +from fastapi import APIRouter + +from .reference_data.router import router as reference_data_router + +# Router principal +router = APIRouter() + +# Registrar módulos +router.include_router(reference_data_router, prefix="/public") diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index ffb93465..9fd2618e 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -2,64 +2,25 @@ Router principal de API v1 Agrega todos los módulos de la aplicación """ + from fastapi import APIRouter # Importar routers de módulos -from .modules.a76.auth import router as auth_router -from .modules.a76.tenants import router as tenants_router - -from .modules.public.reference_data.pedimento_codes.routes import router as pedimento_codes_router -from .modules.public.reference_data.payment_methods.routes import router as payment_methods_router -from .modules.public.reference_data.containers.routes import router as containers_router -from .modules.public.reference_data.countries.routes import router as countries_router -from .modules.public.reference_data.material_types.routes import router as material_types_router -from .modules.public.reference_data.currency_types.routes import router as currency_types_router -from .modules.public.reference_data.states.routes import router as states_router -from .modules.public.reference_data.transport_types.routes import router as transport_types_router -from .modules.public.reference_data.customs_warehouses.routes import router as customs_warehouses_router -from .modules.public.reference_data.valuation_methods.routes import router as valuation_methods_router -from .modules.public.reference_data.sectors.routes import router as sectors_router -from .modules.public.reference_data.transport_modes.routes import router as transport_modes_router -from .modules.public.reference_data.customs_sections.routes import router as customs_sections_router -from .modules.public.reference_data.invoice_types.routes import router as invoice_types_router -from .modules.public.reference_data.code_pedimento_regimens.routes import router as code_pedimento_regimens_router -from .modules.public.reference_data.pedimento_regimens.routes import router as pedimento_regimens_router -from .modules.public.reference_data.incoterms.routes import router as incoterms_router -from .modules.a76.licenses import router as licenses_router +from .modules.core.router import router as core_router +from .modules.a76.router import router as a76_router +from .modules.public.router import router as public_router # Router principal router = APIRouter() # Registrar módulos -router.include_router(auth_router) -router.include_router(tenants_router) -router.include_router(licenses_router) -router.include_router(pedimento_codes_router) -router.include_router(payment_methods_router) -router.include_router(containers_router) -router.include_router(countries_router) -router.include_router(material_types_router) -router.include_router(currency_types_router) -router.include_router(states_router) -router.include_router(transport_types_router) -router.include_router(customs_warehouses_router) -router.include_router(valuation_methods_router) -router.include_router(sectors_router) -router.include_router(transport_modes_router) -router.include_router(customs_sections_router) -router.include_router(invoice_types_router) -router.include_router(code_pedimento_regimens_router) -router.include_router(pedimento_regimens_router) -router.include_router(incoterms_router) +router.include_router(core_router) +router.include_router(a76_router) +router.include_router(public_router) + # Health check - - @router.get("/status") def status(): """Health check de la API""" - return { - "status": "ok", - "version": "1.0.0", - "api": "v1" - } + return {"status": "ok", "version": "1.0.0", "api": "v1"} diff --git a/backend/core/__init__.py b/backend/core/__init__.py index c882a1d3..233883c3 100644 --- a/backend/core/__init__.py +++ b/backend/core/__init__.py @@ -1,21 +1,22 @@ """ Core module - Configuración y utilidades centrales de la aplicación """ + from .config import settings from .database import ( Base, - get_core_db, get_async_core_db, + get_core_db, get_tenant_db, + init_async_db, init_db, - init_async_db ) from .security import ( - verify_token, - get_current_user, get_current_active_user, + get_current_user, + get_tenant_from_token, has_role, - get_tenant_from_token + verify_token, ) __all__ = [ diff --git a/backend/core/config.py b/backend/core/config.py index cfc509b6..c27954eb 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -1,61 +1,61 @@ """ Configuración centralizada de la aplicación usando Pydantic Settings """ -from pydantic_settings import BaseSettings, SettingsConfigDict + from typing import List +from pydantic_settings import BaseSettings, SettingsConfigDict + class Settings(BaseSettings): """Configuración de la aplicación""" - + # Application APP_NAME: str = "Anexo76" APP_VERSION: str = "1.0.0" DEBUG: bool = True ENVIRONMENT: str = "development" - + # Database - Core (Shared) - CORE_DB_HOST: str = "localhost" + CORE_DB_HOST: str = "postgres-a76" CORE_DB_PORT: int = 5432 CORE_DB_NAME: str = "anexo76_core" CORE_DB_USER: str = "postgres" CORE_DB_PASSWORD: str = "postgres" - + # Keycloak - KEYCLOAK_SERVER_URL: str = "http://localhost:8080" + KEYCLOAK_SERVER_URL: str = "http://localhost:8080/kcauth" KEYCLOAK_REALM: str = "master" KEYCLOAK_CLIENT_ID: str = "anexo76-backend" KEYCLOAK_CLIENT_SECRET: str = "" KEYCLOAK_ADMIN_USERNAME: str = "admin" KEYCLOAK_ADMIN_PASSWORD: str = "admin" - + # Security SECRET_KEY: str = "change-this-secret-key-in-production" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - + # CORS CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" - + # License LICENSE_CHECK_ENABLED: bool = True - + model_config = SettingsConfigDict( - env_file=".env", - case_sensitive=True, - extra="ignore" + env_file=".env", case_sensitive=True, extra="ignore", env_file_encoding="utf-8" ) - + @property def core_database_url(self) -> str: """URL de conexión a la base de datos core""" return f"postgresql://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}" - + @property def async_core_database_url(self) -> str: """URL de conexión asíncrona a la base de datos core""" return f"postgresql+asyncpg://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}" - + @property def cors_origins_list(self) -> List[str]: """Lista de orígenes CORS permitidos""" diff --git a/backend/core/database.py b/backend/core/database.py index 1caa6c64..2bd4ba4c 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -3,14 +3,20 @@ Configuración de base de datos con soporte multi-tenant - Base de datos compartida (core_db) para tenants pequeños/medianos - Bases de datos dedicadas para clientes enterprise """ -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, Session -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from typing import Generator, Dict, Optional, AsyncGenerator + +import logging from contextlib import contextmanager +from typing import AsyncGenerator, Dict, Generator, Optional + +from sqlalchemy import create_engine +from sqlalchemy.exc import ProgrammingError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import Session, declarative_base, sessionmaker + from .config import settings +logger = logging.getLogger(__name__) + # Base declarativa para modelos ORM Base = declarative_base() @@ -20,14 +26,11 @@ core_engine = create_engine( pool_pre_ping=True, pool_size=10, max_overflow=20, - echo=settings.DEBUG + echo=False, ) CoreSessionLocal = sessionmaker( - autocommit=False, - autoflush=False, - bind=core_engine -) + autocommit=False, autoflush=False, bind=core_engine) # Engine asíncrono para operaciones async async_core_engine = create_async_engine( @@ -35,13 +38,11 @@ async_core_engine = create_async_engine( pool_pre_ping=True, pool_size=10, max_overflow=20, - echo=settings.DEBUG + echo=settings.DEBUG, ) AsyncCoreSessionLocal = async_sessionmaker( - async_core_engine, - class_=AsyncSession, - expire_on_commit=False + async_core_engine, class_=AsyncSession, expire_on_commit=False ) # Cache de engines para tenants con BD dedicada @@ -74,33 +75,32 @@ async def get_async_core_db() -> AsyncGenerator[AsyncSession, None]: def get_tenant_engine(tenant_id: int, db_config: dict): """ Obtiene o crea un engine para un tenant con BD dedicada - + Args: tenant_id: ID del tenant db_config: Configuración de BD {host, port, name, user, password} - + Returns: Engine de SQLAlchemy para el tenant """ if tenant_id not in _tenant_engines: db_url = f"postgresql://{db_config['user']}:{db_config['password']}@{db_config['host']}:{db_config['port']}/{db_config['name']}" _tenant_engines[tenant_id] = create_engine( - db_url, - pool_pre_ping=True, - pool_size=5, - max_overflow=10 + db_url, pool_pre_ping=True, pool_size=5, max_overflow=10 ) return _tenant_engines[tenant_id] @contextmanager -def get_tenant_db(tenant_id: int, db_config: Optional[dict] = None) -> Generator[Session, None, None]: +def get_tenant_db( + tenant_id: int, db_config: Optional[dict] = None +) -> Generator[Session, None, None]: """ Context manager para obtener sesión de BD de un tenant específico - + Si db_config es None, usa la BD core (compartida) Si db_config está presente, usa la BD dedicada del tenant - + Uso: with get_tenant_db(tenant_id, config) as db: # operaciones con db @@ -111,9 +111,10 @@ def get_tenant_db(tenant_id: int, db_config: Optional[dict] = None) -> Generator else: # Tenant con BD dedicada engine = get_tenant_engine(tenant_id, db_config) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + SessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=engine) db = SessionLocal() - + try: yield db finally: @@ -124,7 +125,15 @@ def init_db(): """ Inicializa las tablas de la base de datos core """ - Base.metadata.create_all(bind=core_engine) + try: + Base.metadata.create_all(bind=core_engine, checkfirst=True) + except ProgrammingError as e: + # Si la tabla ya existe, es seguro continuar + if "already exists" in str(e): + logger.warning( + f"Algunas tablas ya existen en la base de datos: {e}") + else: + raise async def init_async_db(): diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 6d8839ff..f5d0df8a 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -4,16 +4,17 @@ Middleware personalizado para Anexo76 - Gestión de multi-tenancy - Logging de requests """ -from fastapi import Request, HTTPException -from starlette.middleware.base import BaseHTTPMiddleware -from typing import Callable + import logging import time -from datetime import datetime -from sqlalchemy.orm import Session -from .database import CoreSessionLocal -from .security import verify_token, get_tenant_from_token +from typing import Callable + +from fastapi import HTTPException, Request +from starlette.middleware.base import BaseHTTPMiddleware + from .config import settings +from .database import CoreSessionLocal +from .security import get_tenant_from_token, verify_token logger = logging.getLogger(__name__) @@ -22,60 +23,59 @@ class TenantMiddleware(BaseHTTPMiddleware): """ Middleware para identificar y validar el tenant en cada request """ - + async def dispatch(self, request: Request, call_next: Callable): # Rutas públicas que no requieren tenant - public_paths = [ - "/api/docs", - "/api/redoc", - "/api/openapi.json", - "/api/v1/auth", - "/api/v1/auth", - "/api/v1/status", - "/api/v1/status", - "/api/health", - "/api/" - ] - - # Verificar si la ruta es pública (comparación exacta o prefijo) - is_public = False - for path in public_paths: - if request.url.path == path or (path != "/" and request.url.path.startswith(path)): - is_public = True - break - - if is_public: + # Permitir acceso sin autenticación a rutas de documentación y salud + doc_prefixes = ["/api/redoc", "/api/openapi.json"] + public_prefixes = ["/api/v1/auth", "/api/v1/status", "/api/health", "/api/"] + + path = request.url.path + # Permitir cualquier subruta de docs/redoc/openapi.json (por ejemplo, /api/docs, /api/docs/, /api/docs/oauth2-redirect) + if any( + path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes + ): return await call_next(request) - + # Permitir rutas públicas exactas o con prefijo + if any( + path == prefix or (prefix != "/" and path.startswith(prefix)) + for prefix in public_prefixes + ): + return await call_next(request) + # Extraer token y obtener tenant auth_header = request.headers.get("Authorization") - + if not auth_header or not auth_header.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Missing or invalid authorization header") - + raise HTTPException( + status_code=401, detail="Missing or invalid authorization header" + ) + token = auth_header.split(" ")[1] - + try: user_info = verify_token(token) tenant_id = get_tenant_from_token(user_info) - + # ⚠️ NOTA: tenant_id puede ser None para usuarios SSO que aún no tienen tenant asignado # En ese caso, el endpoint específico deberá manejarlo if not tenant_id: - logger.warning(f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}") + logger.warning( + f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}" + ) # No lanzamos error aquí, dejamos que el endpoint decida qué hacer - + # Agregar tenant_id al state del request (puede ser None) request.state.tenant_id = tenant_id request.state.user_info = user_info - + except HTTPException: # Re-lanzar HTTPException directamente raise except Exception as e: logger.error(f"❌ Tenant validation error: {str(e)}") raise HTTPException(status_code=401, detail="Invalid authentication") - + response = await call_next(request) return response @@ -84,58 +84,60 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): """ Middleware para validar la licencia del tenant antes de procesar requests """ - + async def dispatch(self, request: Request, call_next: Callable): if not settings.LICENSE_CHECK_ENABLED: return await call_next(request) - + # Rutas que no requieren validación de licencia exempt_paths = [ - "/api/docs", + "/api/docs", "/api/redoc", - "/openapi.json", - "/api/v1/auth", - "/api/v1/auth", + "/openapi.json", + "/api/v1/auth", + "/api/v1/auth", "/api/v1/status", "/api/v1/status", "/api/health", - "/api/" + "/api/", ] - + # Verificar si la ruta está exenta (comparación exacta o prefijo) is_exempt = False for path in exempt_paths: - if request.url.path == path or (path != "/" and request.url.path.startswith(path)): + if request.url.path == path or ( + path != "/" and request.url.path.startswith(path) + ): is_exempt = True break - + if is_exempt: return await call_next(request) - + # Obtener tenant_id del request state (debe ser seteado por TenantMiddleware) tenant_id = getattr(request.state, "tenant_id", None) - + if not tenant_id: return await call_next(request) # Dejamos que TenantMiddleware maneje esto - + # Validar licencia db = CoreSessionLocal() try: # Importar aquí para evitar imports circulares - from api.v1.modules.a76.licenses.service import LicenseService - + from api.v1.modules.core.licenses.service import LicenseService + license_service = LicenseService(db) license_info = license_service.validate_license(tenant_id) - + if not license_info["is_valid"]: raise HTTPException( status_code=402, - detail=f"License validation failed: {license_info['reason']}" + detail=f"License validation failed: {license_info['reason']}", ) - + # Agregar info de licencia al request state request.state.license_info = license_info - + except HTTPException: raise except Exception as e: @@ -143,7 +145,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): raise HTTPException(status_code=500, detail="License validation error") finally: db.close() - + response = await call_next(request) return response @@ -152,15 +154,28 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): """ Middleware para logging de requests """ - + async def dispatch(self, request: Request, call_next: Callable): start_time = time.time() - + + excluded_paths = [ + "/api/docs", + "/api/redoc", + "/openapi.json", + "/api/v1/status", + "/api/health", + ] + if any( + request.url.path == path or request.url.path.startswith(path + "/") + for path in excluded_paths + ): + return await call_next(request) + # Log request logger.info(f"Request: {request.method} {request.url.path}") - + response = await call_next(request) - + # Log response process_time = time.time() - start_time logger.info( @@ -168,8 +183,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): f"Status: {response.status_code} " f"Duration: {process_time:.3f}s" ) - + # Agregar header con tiempo de procesamiento response.headers["X-Process-Time"] = str(process_time) - + return response diff --git a/backend/core/security.py b/backend/core/security.py index 9b3d5659..9bb89b8b 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -1,22 +1,26 @@ """ Utilidades de seguridad y autenticación con Keycloak """ -from fastapi import HTTPException, Security, Depends -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from keycloak import KeycloakOpenID -from jose import jwt, JWTError -from typing import Optional, Dict, Any -from .config import settings + import logging +from typing import Any, Dict, Optional + +from fastapi import Depends, HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from keycloak import KeycloakOpenID +from sqlalchemy.orm import Session + +from .config import settings logger = logging.getLogger(__name__) # Configuración de Keycloak keycloak_openid = KeycloakOpenID( - server_url=settings.KEYCLOAK_SERVER_URL, + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", client_id=settings.KEYCLOAK_CLIENT_ID, realm_name=settings.KEYCLOAK_REALM, - client_secret_key=settings.KEYCLOAK_CLIENT_SECRET + client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, ) # Security scheme @@ -26,13 +30,13 @@ security = HTTPBearer() def verify_token(token: str) -> Dict[str, Any]: """ Verifica y decodifica un token JWT de Keycloak - + Args: token: Token JWT - + Returns: Payload del token decodificado - + Raises: HTTPException: Si el token es inválido """ @@ -43,43 +47,30 @@ def verify_token(token: str) -> Dict[str, Any]: + keycloak_openid.public_key() + "\n-----END PUBLIC KEY-----" ) - + # Decodificar y verificar token - options = { - "verify_signature": True, - "verify_aud": False, - "verify_exp": True - } - + options = {"verify_signature": True, "verify_aud": False, "verify_exp": True} + decoded_token = jwt.decode( - token, - KEYCLOAK_PUBLIC_KEY, - algorithms=["RS256"], - options=options + token, KEYCLOAK_PUBLIC_KEY, algorithms=["RS256"], options=options ) - + return decoded_token - + except JWTError as e: logger.error(f"Token verification failed: {str(e)}") - raise HTTPException( - status_code=401, - detail="Could not validate credentials" - ) + raise HTTPException(status_code=401, detail="Could not validate credentials") except Exception as e: logger.error(f"Unexpected error during token verification: {str(e)}") - raise HTTPException( - status_code=401, - detail="Authentication error" - ) + raise HTTPException(status_code=401, detail="Authentication error") async def get_current_user( - credentials: HTTPAuthorizationCredentials = Security(security) + credentials: HTTPAuthorizationCredentials = Security(security), ) -> Dict[str, Any]: """ Dependency para obtener el usuario actual desde el token JWT - + Uso en FastAPI: current_user: dict = Depends(get_current_user) """ @@ -89,7 +80,7 @@ async def get_current_user( async def get_current_active_user( - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: """ Dependency para obtener usuario activo (puede incluir validaciones adicionales) @@ -102,32 +93,33 @@ async def get_current_active_user( def has_role(required_role: str): """ Decorator/Dependency para verificar roles de usuario - + Uso: @router.get("/admin") async def admin_endpoint(user = Depends(has_role("admin"))): ... """ + async def role_checker( - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), ) -> Dict[str, Any]: user_roles = current_user.get("realm_access", {}).get("roles", []) - + if required_role not in user_roles: raise HTTPException( status_code=403, - detail=f"User does not have required role: {required_role}" + detail=f"User does not have required role: {required_role}", ) - + return current_user - + return role_checker def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]: """ Extrae el tenant_id del token JWT - + El tenant_id puede estar en diferentes lugares según configuración de Keycloak: - En claims personalizados - En el realm @@ -135,32 +127,78 @@ def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]: """ # Intentar obtener de claims personalizados tenant_id = user_info.get("tenant_id") - if not tenant_id: # Intentar obtener de atributos tenant_id = user_info.get("attributes", {}).get("tenant_id") - + if tenant_id: return int(tenant_id) - + return None -class KeycloakClient: - """Cliente para interactuar con Keycloak Admin API""" - - def __init__(self): - self.openid = keycloak_openid - - def create_user(self, email: str, password: str, tenant_id: int, **kwargs): - """Crea un usuario en Keycloak""" - # Implementar lógica para crear usuario usando keycloak admin - pass - - def assign_role(self, user_id: str, role: str): - """Asigna un rol a un usuario""" - pass - - def create_tenant_realm(self, tenant_name: str): - """Crea un realm para un nuevo tenant""" - pass +def validate_company_access( + db: Session, company_id: int, current_user: Dict[str, Any] +) -> bool: + """ + Valida que el usuario tenga acceso a la compañía solicitada + + Args: + company_id: ID de la compañía a la que se quiere acceder + current_user: Información del usuario actual desde el token + + Returns: + True si el usuario tiene acceso, False en caso contrario + + Nota: + Verifica que la compañía pertenezca al tenant del usuario consultando la BD. + """ + + tenant_id = get_tenant_from_token(current_user) + + # Si no hay tenant_id en el token, denegar acceso + if not tenant_id: + return False + + # Consultar si la compañía pertenece al tenant + try: + from api.v1.modules.a76.general_catalogs.company.models import Company + + company = ( + db.query(Company) + .filter(Company.id == company_id, Company.tenant_id == tenant_id) + .first() + ) + + return company is not None + finally: + db.close() + + +def validate_access_to_resource( + db: Session, company_id: int, current_user: Dict[str, Any] +) -> int: + """ + Valida que el usuario tenga acceso a un recurso específico basado en company_id + y regresa el tenant_id + + Args: + company_id: company_id asociado al recurso + current_user: Información del usuario actual desde el token + + Returns: + tenant_id si el usuario tiene acceso + + Raises: + HTTPException: Si no hay tenant_id o no tiene acceso + """ + + tenant_id = get_tenant_from_token(current_user) + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant ID not found in token") + + if not validate_company_access(db, company_id, current_user): + raise HTTPException(status_code=403, detail="Access denied to this company") + + # Validar que el tenant_id del usuario coincida con el del recurso + return tenant_id diff --git a/backend/main.py b/backend/main.py index 3932471a..0959a83d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,24 +2,27 @@ Anexo76 - Aplicación SaaS para gestión de comercio exterior Backend API con FastAPI + Keycloak + SQLAlchemy """ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager + import logging +from api.v1.router import router as api_v1_router from core.config import settings from core.database import init_db from core.middleware import ( - TenantMiddleware, LicenseValidationMiddleware, - RequestLoggingMiddleware + RequestLoggingMiddleware, + TenantMiddleware, ) -from api.v1.router import router as api_v1_router +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api.v1.modules.a76.items.models import Item # Importar rutas para registrar con el router +from api.v1.modules.a76.items.series.models import Serie # Importar modelos para registrar con SQLAlchemy # Configurar logging logging.basicConfig( level=logging.INFO if not settings.DEBUG else logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) @@ -28,12 +31,22 @@ logger = logging.getLogger(__name__) app = FastAPI( title="Anexo76 API", version=settings.APP_VERSION, - description="Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 31 y 22 del SAT", + description="Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT", docs_url="/api/docs" if settings.DEBUG else None, redoc_url="/api/redoc" if settings.DEBUG else None, openapi_url="/api/openapi.json" if settings.DEBUG else None, ) + +# Inicializar la base de datos +@app.on_event("startup") +async def on_startup(): + """Evento de inicio de la aplicación""" + logger.info("Iniciando la aplicación Anexo76...") + init_db() + logger.info("Base de datos inicializada correctamente.") + + # Configurar CORS app.add_middleware( CORSMiddleware, @@ -44,7 +57,9 @@ app.add_middleware( ) # Agregar middlewares personalizados -app.add_middleware(RequestLoggingMiddleware) +if settings.DEBUG: + app.add_middleware(RequestLoggingMiddleware) + app.add_middleware(LicenseValidationMiddleware) app.add_middleware(TenantMiddleware) @@ -59,14 +74,11 @@ async def root(): "name": "Anexo76 API", "version": settings.APP_VERSION, "status": "running", - "docs": "/api/docs" if settings.DEBUG else "disabled in production" + "docs": "/api/docs" if settings.DEBUG else "disabled in production", } @app.get("/api/health") async def health_check(): """Health check endpoint""" - return { - "status": "healthy", - "environment": settings.ENVIRONMENT - } + return {"status": "healthy", "environment": settings.ENVIRONMENT} diff --git a/backend/requirements.txt b/backend/requirements.txt index 61753420..9f13d289 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -36,3 +36,4 @@ pytest-cov==7.0.0 black==25.9.0 flake8==7.3.0 mypy==1.18.2 +pylint==4.0.2 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..4339e0a8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,266 @@ +services: + # PostgreSQL - Base de datos core (app) + postgres-a76: + image: postgres:18-alpine + container_name: anexo76-postgres-a76 + environment: + POSTGRES_DB: anexo76_core + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-postgres} + POSTGRES_INITDB_ARGS: "--encoding=UTF8" + ports: + - "5939:5432" + volumes: + - postgres_app_data:/var/lib/postgresql/data + - ./scripts/postgres-app-entrypoint.sh:/docker-entrypoint-initdb.d/init-app.sh:ro + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d anexo76_core || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 20s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + shm_size: 128mb + + # PostgreSQL - Base de datos Keycloak + postgres-keycloak: + image: postgres:18-alpine + container_name: anexo76-postgres-keycloak + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} + POSTGRES_INITDB_ARGS: "--encoding=UTF8" + ports: + - "5233:5432" + volumes: + - postgres_keycloak_data:/var/lib/postgresql/data + - ./scripts/postgres-keycloak-entrypoint.sh:/docker-entrypoint-initdb.d/init-keycloak.sh:ro + networks: + - auth-net + - backend-net + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 20s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + shm_size: 128mb + + # Keycloak - Servidor de autenticación + keycloak: + image: quay.io/keycloak/keycloak:26.4 + container_name: anexo76-keycloak + environment: + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KC_DB: postgres + KC_DB_URL_HOST: postgres-keycloak + KC_DB_URL_PORT: "5432" + KC_DB_URL_DATABASE: keycloak + KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak + KC_DB_USERNAME: postgres + KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} + KC_DB_SCHEMA: public + KC_HOSTNAME: localhost + KC_HTTP_ENABLED: "true" + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + KC_PROXY_HEADERS: "xforwarded" + KC_HEALTH_ENABLED: "true" + KC_METRICS_ENABLED: "true" + KC_HOSTNAME_PATH: /kcauth + KC_LOG_LEVEL: INFO + JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true" + command: + - start-dev + - --db=postgres + - --db-url-host=postgres-keycloak + - --http-relative-path=/kcauth + - --db-url-port=5432 + - --db-url-database=keycloak + - --db-username=postgres + - --db-password=${POSTGRES_KEYCLOAK_PASSWORD:-postgres} + - --http-enabled=true + - --hostname-strict=false + - --proxy-headers=xforwarded + ports: + - "8880:8080" + - "9000:9000" + depends_on: + postgres-keycloak: + condition: service_healthy + volumes: + - keycloak_data:/opt/keycloak/data + networks: + - auth-net + - backend-net + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r\nhost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 768M + reservations: + memory: 512M + + # Backend - FastAPI + backend: + image: dev.aduanasoft.com/anexo76/backend:latest + container_name: anexo76-backend + environment: + - DEBUG=${DEBUG:-True} + - ENVIRONMENT=${ENVIRONMENT:-development} + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} + - CORS_ORIGINS=${CORS_ORIGINS:-https://anexo76-dev.aduanasoft.com,http://localhost:3000} + ports: + - "3467:8000" + depends_on: + postgres-a76: + condition: service_healthy + keycloak: + condition: service_healthy + volumes: + - ./backend:/app + - backend_cache:/app/__pycache__ + - ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro + networks: + - backend-net + - frontend-net + restart: unless-stopped + entrypoint: ["/entrypoint.sh"] + command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--log-level", "info"] + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + # Frontend - SvelteKit + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + args: + - BUILDKIT_INLINE_CACHE=1 + - VITE_API_URL=https://anexo76-dev.aduanasoft.com/api/ + - VITE_KEYCLOAK_URL=https://anexo76-dev.aduanasoft.com/kcauth/ + container_name: anexo76-frontend + environment: + - NODE_ENV=${NODE_ENV:-development} + - VITE_API_URL=${VITE_API_URL:-https://anexo76-dev.aduanasoft.com/api} + - INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/} + - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-https://anexo76-dev.aduanasoft.com/kcauth/} + - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} + - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} + - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9} + ports: + - "5111:5173" + depends_on: + backend: + condition: service_healthy + entrypoint: ["/frontend-entrypoint.sh"] + volumes: + - ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro + networks: + - frontend-net + - auth-net + restart: unless-stopped + command: ["node", "build"] + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 45s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M + +volumes: + postgres_app_data: + driver: local + postgres_keycloak_data: + driver: local + keycloak_data: + driver: local + frontend_node_modules: + driver: local + backend_cache: + driver: local + +networks: + backend-net: + driver: bridge + auth-net: + driver: bridge + frontend-net: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 3d54d692..655340d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,13 @@ services: # PostgreSQL - Base de datos core (app) postgres-a76: - image: postgres:16-alpine + image: postgres:18-alpine container_name: anexo76-postgres-a76 environment: POSTGRES_DB: anexo76_core POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-postgres} POSTGRES_INITDB_ARGS: "--encoding=UTF8" - PGDATA: /var/lib/postgresql/data/pgdata ports: - "5432:5432" volumes: @@ -38,14 +37,13 @@ services: # PostgreSQL - Base de datos Keycloak postgres-keycloak: - image: postgres:16-alpine + image: postgres:18-alpine container_name: anexo76-postgres-keycloak environment: POSTGRES_DB: keycloak POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} POSTGRES_INITDB_ARGS: "--encoding=UTF8" - PGDATA: /var/lib/postgresql/data/pgdata ports: - "5433:5432" volumes: @@ -96,10 +94,12 @@ services: KC_PROXY_HEADERS: "xforwarded" KC_HEALTH_ENABLED: "true" KC_METRICS_ENABLED: "true" + KC_HOSTNAME_PATH: /kcauth KC_LOG_LEVEL: INFO JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true" command: - start-dev + - --http-relative-path=/kcauth - --db=postgres - --db-url-host=postgres-keycloak - --db-url-port=5432 @@ -122,7 +122,7 @@ services: - backend-net restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /health/ready HTTP/1.1\r\nhost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"] + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r\nhost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"] interval: 10s timeout: 5s retries: 30 @@ -158,11 +158,11 @@ services: - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} - CORE_DB_USER=${CORE_DB_USER:-postgres} - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} - - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - - CORS_ORIGINS=http://localhost:5180,http://localhost:3000 + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000} ports: - "8000:8000" depends_on: @@ -211,15 +211,15 @@ services: - NODE_ENV=${NODE_ENV:-development} - VITE_API_URL=${VITE_API_URL:-http://localhost:8000/api/} - INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/} - - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8080} + - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8080/kcauth} - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} - - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} + - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080/kcauth} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9} ports: - - "5180:5180" + - "5173:5173" depends_on: backend: condition: service_healthy @@ -234,7 +234,7 @@ services: restart: unless-stopped command: ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"] healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5180/ || exit 1"] + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1"] interval: 15s timeout: 5s retries: 5 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cc983c94..6e4332ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,11 +4,12 @@ 1. [Visión General](#visión-general) 2. [Stack Tecnológico](#stack-tecnológico) 3. [Arquitectura del Sistema](#arquitectura-del-sistema) -4. [Estructura del Proyecto](#estructura-del-proyecto) -5. [Flujos Principales](#flujos-principales) -6. [Seguridad](#seguridad) -7. [Base de Datos](#base-de-datos) -8. [API Reference](#api-reference) +4. [Arquitectura de Schemas y Módulos](#arquitectura-de-schemas-y-módulos) +5. [Estructura del Proyecto](#estructura-del-proyecto) +6. [Flujos Principales](#flujos-principales) +7. [Seguridad](#seguridad) +8. [Base de Datos](#base-de-datos) +9. [API Reference](#api-reference) --- @@ -78,12 +79,12 @@ Anexo76 es una aplicación SaaS multi-tenant para gestión de comercio exterior │ ┌───────▼──────────────────────────┐ │ DATABASE LAYER (Multi-tenant) │ -│ │ -│ ┌──────────┐ ┌──────────────┐ │ -│ │ Core DB │ │ Tenant 1 DB │ │ -│ │ (shared) │ │ (dedicated) │ │ -│ └──────────┘ └──────────────┘ │ -└───────────────────────────────────┘ +│ │ +│ ┌──────────┐ ┌──────────────┐ │ +│ │ Core DB │ │ Tenant 1 DB │ │ +│ │ (shared) │ │ (dedicated) │ │ +│ └──────────┘ └──────────────┘ │ +└──────────────────────────────────┘ ``` ### Estructura Modular (por módulo) @@ -123,6 +124,104 @@ modules/{module_name}/ --- +## Arquitectura de Schemas y Módulos + +### Estructura de Schemas en Base de Datos + +La aplicación utiliza una arquitectura de schemas para organizar lógicamente las tablas según su funcionalidad y alcance: + +#### **Schema `a24` (Anexo 24)** +Contiene todas las tablas relacionadas con el **Anexo 24 del SAT** (control de inventarios para empresas IMMEX): +- Gestión de inventarios +- Control de entradas y salidas de mercancías +- Reportes de existencias +- Cumplimiento de obligaciones fiscales del Anexo 24 + +#### **Schema `a76` (Anexo 76)** +Contiene todas las tablas relacionadas con el **Anexo 76 del SAT** (comercio exterior): +- Pedimentos aduanales +- Facturas de importación/exportación +- Documentación de comercio exterior +- Cumplimiento normativo de comercio exterior + +#### **Schema `public` (Catálogos Fijos)** +Contiene **catálogos compartidos** y datos de referencia que no cambian frecuentemente: +- Catálogos del SAT (tipos de material, unidades de medida, etc.) +- Códigos de país +- Catálogos de aduanas +- Tipos de documento +- Datos maestros compartidos entre módulos + +### Convención de Prefijos de Tablas + +Para mantener claridad y trazabilidad, las tablas utilizan prefijos que identifican su módulo funcional: + +#### **Prefijo `inv_` (Inventarios)** +Tablas relacionadas con el **control de inventarios**: +- `inv_products`: Productos en inventario +- `inv_movements`: Movimientos de entrada/salida +- `inv_warehouses`: Almacenes +- `inv_balances`: Saldos de inventario + +**Nota histórica**: Anteriormente se utilizaba el prefijo `s` (SCAII - Sistema de aduanas e Inventarios). + +#### **Prefijo `fa_` (Fixed Assets / Activos Fijos)** +Tablas relacionadas con la **gestión de activos fijos**: +- `fa_assets`: Registro de activos fijos +- `fa_depreciation`: Depreciación de activos +- `fa_maintenance`: Mantenimiento de activos +- `fa_transfers`: Transferencias de activos + +**Nota histórica**: Anteriormente se utilizaba el prefijo `q` (SCAF - Sistema de Control de Activos Fijos). + +### Diagrama de Arquitectura de Schemas + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DATABASE: anexo76_db │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────┐ ┌────────────────┐ ┌───────────────┐ │ +│ │ Schema: a24 │ │ Schema: a76 │ │Schema: public │ │ +│ │ (Anexo 24) │ │ (Anexo 76) │ │ (Catálogos) │ │ +│ ├────────────────┤ ├────────────────┤ ├───────────────┤ │ +│ │ │ │ │ │ │ │ +│ │ inv_products │ │ pedimentos │ │ material_types│ │ +│ │ inv_movements │ │ facturas │ │ uom_codes │ │ +│ │ inv_warehouses │ │ customs_docs │ │ countries │ │ +│ │ inv_balances │ │ export_ops │ │ customs_list │ │ +│ │ │ │ │ │ document_types│ │ +│ │ fa_assets │ │ │ │ │ │ +│ │ fa_depreciation│ │ │ │ │ │ +│ │ fa_maintenance │ │ │ │ │ │ +│ │ fa_transfers │ │ │ │ │ │ +│ │ │ │ │ │ │ │ +│ └────────────────┘ └────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Ventajas de esta Arquitectura + +1. **Separación Lógica**: Cada schema representa un dominio específico del negocio +2. **Escalabilidad**: Facilita la adición de nuevos módulos sin afectar los existentes +3. **Seguridad**: Permite aplicar permisos a nivel de schema +4. **Mantenibilidad**: Código y migraciones organizados por dominio +5. **Claridad**: Los prefijos hacen evidente la funcionalidad de cada tabla +6. **Migración Gradual**: Permite actualizar sistemas legados (SCAII/SCAF) sin interrupciones + +### Mapeo de Sistemas Legados + +| Sistema Legacy | Prefijo Antiguo | Sistema Nuevo | Prefijo Nuevo | Schema | +|----------------------|-----------------|-------------------|---------------|----------| +| SCAII (Inventarios) | `s` | Inventarios | `inv_` | `a24` | +| SCAF (Activos Fijos) | `q` | Fixed Assets | `fa_` | `a24` | +| Winsaii (Pedimentos) | `w` | - | - | `a22` | +| - | `g` | Comercio Exterior | - | `a76` | +| - | `g` | Catálogos SAT | - | `public` | + +--- + ## Estructura del Proyecto ``` @@ -143,11 +242,25 @@ anexo76/ │ └── api/ │ └── v1/ │ ├── router.py # Router principal v1 -│ └── modules/ # Módulos de negocio -│ ├── auth/ # Autenticación -│ ├── tenants/ # Gestión de tenants -│ ├── licenses/ # Control de licencias -│ └── ... # Futuros módulos +│ ├── common/ # Utilidades compartidas +│ │ ├── base_models.py +│ │ ├── crud_routes.py +│ │ ├── dto_mixins.py +│ │ └── tenant_crud_routes.py +│ │ +│ └── modules/ # Módulos de negocio por schema +│ ├── a24/ # Módulo Anexo 24 (Inventarios) +│ │ ├── inventarios/ +│ │ └── activos_fijos/ +│ │ +│ ├── a76/ # Módulo Anexo 76 (Comercio Exterior) +│ │ ├── pedimentos/ +│ │ └── facturas/ +│ │ +│ └── public/ # Catálogos compartidos +│ ├── material_types/ +│ ├── uom_codes/ +│ └── countries/ │ ├── frontend/ │ ├── src/ diff --git a/docs/KEYCLOAK_SETUP.md b/docs/KEYCLOAK_SETUP.md index d1ba4aec..d08d7451 100644 --- a/docs/KEYCLOAK_SETUP.md +++ b/docs/KEYCLOAK_SETUP.md @@ -2,6 +2,15 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. +# Script auto initialize + +Te genera toda la configruracion inicial de keycloack que se ve en este documento, +aparte de esto te genera un primer usuario configurado con su tenant y una company + +``` +scripts/init_first_time.sh +``` + ## 1. Acceder a Keycloak Admin Console 1. Abrir http://localhost:8080 @@ -11,6 +20,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. ## 2. Configurar Cliente Backend ### Crear Cliente Backend + 1. En el menú izquierdo, ir a **Clients** 2. Clic en **Create client** 3. Configurar: @@ -29,6 +39,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. - Clic en **Save** ### Obtener Client Secret + 1. Ir a la pestaña **Credentials** 2. Copiar el **Client secret** 3. Agregar al archivo `backend/.env`: @@ -39,6 +50,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. ## 3. Configurar Cliente Frontend ### Crear Cliente Frontend + 1. En **Clients**, clic en **Create client** 2. Configurar: - **Client ID**: `anexo76-frontend` @@ -51,13 +63,13 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. - Clic en **Next** 4. En "Login settings": - **Root URL**: `http://localhost:5173` - - **Valid redirect URIs**: + - **Valid redirect URIs**: - `http://localhost:5173/*` - `http://localhost:3000/*` - - **Valid post logout redirect URIs**: + - **Valid post logout redirect URIs**: - `http://localhost:5173/*` - `http://localhost:3000/*` - - **Web origins**: + - **Web origins**: - `http://localhost:5173` - `http://localhost:3000` - Clic en **Save** @@ -65,6 +77,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. ## 4. Crear Usuario de Prueba ### Crear Usuario + 1. En el menú izquierdo, ir a **Users** 2. Clic en **Add user** 3. Configurar: @@ -76,6 +89,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. - Clic en **Create** ### Establecer Contraseña + 1. Ir a la pestaña **Credentials** 2. Clic en **Set password** 3. Configurar: @@ -85,6 +99,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. 4. Clic en **Save** ### Agregar Atributo tenant_id + 1. En el mismo usuario, ir a la pestaña **Attributes** 2. Clic en **Add an attribute** 3. Configurar: @@ -93,6 +108,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76. 4. Clic en **Save** ### Asignar Roles + 1. Ir a la pestaña **Role mappings** 2. En "Available roles", buscar y asignar: - `admin` (si existe) @@ -126,6 +142,7 @@ Repetir para el cliente `anexo76-frontend` si es necesario. ## 6. Verificar Configuración ### Probar desde el Frontend + 1. Abrir http://localhost:5173 2. Hacer clic en "Iniciar Sesión" 3. Ingresar credenciales: @@ -134,6 +151,7 @@ Repetir para el cliente `anexo76-frontend` si es necesario. 4. Deberías ver el dashboard con información del usuario y licencia ### Probar desde el API + ```bash # Obtener token curl -X POST http://localhost:8080/realms/master/protocol/openid-connect/token \ @@ -152,11 +170,13 @@ curl -X GET http://localhost:8000/v1/auth/me \ ## 7. Configuración Adicional (Opcional) ### Personalizar Tema de Login + 1. Ir a **Realm settings** → **Themes** 2. Seleccionar tema de login deseado 3. Guardar cambios ### Configurar Timeout de Sesión + 1. Ir a **Realm settings** → **Sessions** 2. Ajustar: - **SSO Session Idle**: Tiempo de inactividad antes de expirar (ej: 30 minutos) @@ -164,6 +184,7 @@ curl -X GET http://localhost:8000/v1/auth/me \ 3. Guardar cambios ### Habilitar Registro de Usuarios (Opcional) + 1. Ir a **Realm settings** → **Login** 2. Activar **User registration** 3. Guardar cambios @@ -171,20 +192,24 @@ curl -X GET http://localhost:8000/v1/auth/me \ ## Troubleshooting ### Error: "Invalid redirect URI" + - Verificar que las URIs en el cliente coincidan exactamente - Incluir el protocolo (http:// o https://) - Incluir el puerto si es necesario ### Error: "Client not found" + - Verificar que el Client ID sea exacto - Verificar que el realm sea correcto ### Token no incluye tenant_id + - Verificar que el usuario tenga el atributo configurado - Verificar que el mapper esté configurado correctamente - Probar obteniendo un nuevo token ### Usuario no puede hacer login + - Verificar que el usuario esté habilitado (User enabled: ON) - Verificar que el email esté verificado (Email verified: ON) - Verificar que la contraseña no sea temporal diff --git a/docs/a76.json b/docs/a76.json index 331f38fc..9d62e2bf 100644 --- a/docs/a76.json +++ b/docs/a76.json @@ -1,7 +1,7 @@ { "context": { "project_name": "Anexo76", - "description": "Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 31 y 22 del SAT.", + "description": "Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT.", "business_goal": "Ofrecer una plataforma multi-tenant para maquilas, IMMEX y agentes aduanales que permita manejar inventarios, pedimentos y facturas de importación/exportación con control de licencias y cumplimiento normativo." }, "architecture": { diff --git a/frontend/.gitignore b/frontend/.gitignore index 11ad1db9..96a2ef4a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -25,3 +25,4 @@ vite.config.ts.timestamp-* # Paraglide src/lib/paraglide +frontend/project.inlang/cache/ \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 478712b4..bd5ede6d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -24,7 +24,7 @@ COPY . . # RUN pnpm run build # Exponer puerto -EXPOSE 5180 +EXPOSE 5173 # Comando por defecto (desarrollo) CMD ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 00000000..754f6fed --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,59 @@ +# ========================== +# Etapa de build +# ========================== +FROM node:22-alpine AS build + +# Directorio de trabajo +WORKDIR /app + +# Configurar npm para trabajar con certificados autofirmados e instalar pnpm +RUN npm config set strict-ssl false && \ + npm install -g pnpm + +# Copiar archivos de dependencias +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +# Instalar dependencias con pnpm +RUN pnpm install --frozen-lockfile + +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} + +# Copiar el resto del código +COPY . . + +# Construir el proyecto +RUN pnpm run build + + +# ========================== +# Etapa de ejecución con Node.js +# ========================== +FROM node:22-alpine AS runtime + +WORKDIR /app + +RUN npm config set strict-ssl false && \ + npm install -g pnpm + +# Crear usuario no-root para seguridad antes de copiar con --chown +RUN addgroup -g 1001 -S nodejs +RUN adduser -S svelte -u 1001 + +# Copiar solo archivos necesarios para producción y aplicar propietario en la copia +COPY --from=build --chown=svelte:nodejs /app/build ./build +COPY --from=build --chown=svelte:nodejs /app/package.json ./ +COPY --from=build --chown=svelte:nodejs /app/node_modules ./node_modules + +USER svelte + +# Puerto para SvelteKit con adapter-node +EXPOSE 5173 + +# Variables de entorno +ENV NODE_ENV=production +ENV PORT=5173 +ENV HOST=0.0.0.0 + +# Ejecutar aplicación con Node.js +CMD ["node", "build"] diff --git a/frontend/README.md b/frontend/README.md index 75842c40..47da320e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -12,6 +12,9 @@ npx sv create # create a new project in my-app npx sv create my-app + +# compile paraglide +cd frontend && sudo rm -rf src/lib/paraglide && pnpm paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide ``` ## Developing diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 37a98944..d4274cd5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,4 +1,95 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from en!" + "hello_world": "Hello, {name} from en!", + "sidebar": { + "reference_data": { + "title": "Fixed Catalogs", + "codes_pedimento_regimen": "Pedimento and Regime Codes", + "containers": "Containers", + "countries": "Countries", + "currency_types": "Currency Types", + "customs_sections": "Customs Sections", + "customs_warehouses": "Customs Warehouses", + "incoterms": "Incoterms", + "invoice_types": "Invoice Types", + "material_types": "Material Types", + "payment_methods": "Payment Methods", + "pedimento_codes": "Pedimento Codes", + "pedimento_regimes": "Pedimento Regimes", + "sectors": "Sectors", + "states": "States", + "transportation_modes": "Transportation Modes", + "transportation_types": "Transportation Types", + "valuation_methods": "Valuation Methods", + "configuracion": "Settings", + "general": "General", + "licencia": "License", + "usuarios": "Users", + "ayuda": "Help" + }, + "general_catalogs": { + "title": "General Catalogs", + "company_information": "Company Information", + "packages": "Packages", + "concepts": "Concepts", + "classification": "Classification", + "identifiers": "Identifiers", + "incoterms": "Incoterms", + "inpc": "I.N.P.C", + "fixed_legends": "Fixed Legends", + "seals": "Seals", + "valuation_methods": "Valuation Methods", + "countries": "Countries", + "ports": "Ports", + "unit_measures": "Units of Measure - General", + "um_customs_mex": "Units of Measure - Mexican Customs", + "um_customs_ame": "Units of Measure - American Customs", + "um_ace": "Units of Measure - ACE", + "um_oma": "Units of Measure - OMA", + "conversions": "Conversions", + "equivalences": "Equivalences", + "exchange_rates": "Exchange Rates", + "currency_types": "Currency Types", + "multi_currency": "Multi Currency", + "invoice_types": "Invoice Types", + "electronic_signatures": "Electronic Signatures", + "billing_errors": "Billing Errors", + "customs_warehouses": "Customs Warehouses", + "locations": "Locations", + "doda": "DODA", + "packing_list": "Packing List", + "prevalidators": "Prevalidators", + "electronic_notices": "Electronic Notices", + "back_flush": "Back Flush", + "crossing_notice": "Crossing Notice" + }, + "pedimentos": { + "title": "Pedimentos", + "pedimento_management": "Pedimento Management", + "pedimento_codes": "Pedimento Codes", + "customs_regimes": "Customs Regimes", + "payment_methods": "Payment Methods", + "customs_sections": "Customs Sections", + "anexo_22_app_31": "Anexo 22 App 3" + }, + "import_invoices":{ + "title": "Import Invoices", + "temporary": "Temporary", + "definitive": "Definitive", + "mexican_purchases": "Mexican Purchases", + "regime_change": "Regime Change" + }, + "export_invoices": { + "title": "Export Invoices", + "exportation": "Exportation", + "repair": "Repair" + }, + "clients_and_providers": "Clients and Providers", + "customs_brokers": "Customs Brokers", + "nav_user": { + "profile": "Profile", + "settings": "Settings", + "logout": "Logout" + } + } } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 176345c1..e4b9728e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,4 +1,94 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format", - "hello_world": "Hello, {name} from es!" -} + "$schema": "https://inlang.com/schema/inlang-message-format", + "hello_world": "Hello, {name} from es!", + "sidebar": { + "reference_data": { + "title": "Catálogos Fijos", + "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", + "containers": "Contenedores", + "countries": "Países", + "currency_types": "Tipos de moneda", + "customs_sections": "Secciones de aduanas", + "customs_warehouses": "Recintos", + "incoterms": "Incoterms", + "invoice_types": "Tipos de factura", + "material_types": "Tipos de material", + "payment_methods": "Métodos de pago", + "pedimento_codes": "Códigos de pedimento", + "pedimento_regimes": "Regímenes de pedimentos", + "sectors": "Sectores", + "states": "Estados", + "transportation_modes": "Métodos de transporte", + "transportation_types": "Tipos de transporte", + "valuation_methods": "Métodos de valoración", + "configuracion": "Configuración", + "general": "General", + "licencia": "Licencia", + "usuarios": "Usuarios", + "ayuda": "Ayuda" + }, + "general_catalogs": { + "title": "Catalogos Generales", + "company_information": "Información de la empresa", + "packages": "Bultos", + "concepts": "Conceptos", + "classification": "Clasificación", + "identifiers": "Identificadores", + "incoterms": "Incoterms", + "inpc": "I.N.P.C", + "fixed_legends": "Leyendas fijas", + "seals": "Precintos", + "valuation_methods": "Metódos de valoración", + "countries": "Países", + "ports": "Puertos", + "unit_measures": "Unidades de medida general", + "um_customs_mex": "Unidades de medida - Aduanas Mexicanas", + "um_customs_ame": "Unidades de medida - Aduanas Americanas", + "um_ace": "Unidades de medida - ACE", + "um_oma": "Unidades de medida - OMA", + "conversions": "Conversiones", + "equivalences": "Equivalencias", + "exchange_rates": "Tipos de cambio", + "currency_types": "Tipos de moneda", + "multi_currency": "Multi Moneda", + "invoice_types": "Tipos de factura", + "electronic_signatures": "Firmas electrónicas", + "billing_errors": "Errores de facturación", + "customs_warehouses": "Recintos", + "locations": "Localizaciones", + "doda": "DODA", + "packing_list": "Packing List", + "prevalidators": "Prevalidadores", + "electronic_notices": "Avisos electrónicos", + "back_flush": "Back Flush", + "crossing_notice": "Aviso de cruce" + }, + "pedimentos": { + "title": "Pedimentos", + "pedimento_management": "Gestión de Pedimentos", + "pedimento_codes": "Claves de Pedimento", + "customs_regimes": "Regímenes Aduaneros", + "payment_methods": "Formas de Pago", + "customs_sections": "Secciones Aduaneras", + "anexo_22_app_31": "Anexo 22 App 3" + }, + "import_invoices":{ + "title": "Facturas de importación", + "temporary": "Temporal", + "definitive": "Definitiva", + "mexican_purchases": "Compras mexicanas", + "regime_change": "Cambio de régimen" + }, + "export_invoices": { + "title": "Facturas de exportación", + "exportation": "Exportación", + "repair": "Reparación" + }, + "clients_and_providers": "Clientes y Proveedores", + "customs_brokers": "Agentes Aduanales", + "nav_user": { + "profile": "Perfil", + "settings": "Configuración" + } + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 53787930..af6b62ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ "@eslint/js": "^9.36.0", "@inlang/paraglide-js": "^2.3.2", "@internationalized/date": "^3.10.0", - "@lucide/svelte": "^0.544.0", + "@lucide/svelte": "^0.561.0", "@playwright/test": "^1.55.1", "@sveltejs/adapter-node": "^5.3.2", "@sveltejs/kit": "^2.43.2", @@ -29,9 +29,10 @@ "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.14", + "@tanstack/table-core": "^8.21.3", "@types/node": "^20", "@vitest/browser": "^3.2.4", - "bits-ui": "^2.14.2", + "bits-ui": "^2.14.4", "clsx": "^2.1.1", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", @@ -54,6 +55,8 @@ "vitest-browser-svelte": "^1.1.0" }, "dependencies": { - "keycloak-js": "^26.2.1" + "keycloak-js": "^26.2.1", + "lucide-svelte": "^0.553.0", + "svelte-sonner": "^1.0.7" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index eca68e3c..bfafc5e5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: keycloak-js: specifier: ^26.2.1 version: 26.2.1 + lucide-svelte: + specifier: ^0.553.0 + version: 0.553.0(svelte@5.40.2) + svelte-sonner: + specifier: ^1.0.7 + version: 1.0.7(svelte@5.40.2) devDependencies: '@eslint/compat': specifier: ^1.4.0 @@ -25,8 +31,8 @@ importers: specifier: ^3.10.0 version: 3.10.0 '@lucide/svelte': - specifier: ^0.544.0 - version: 0.544.0(svelte@5.40.2) + specifier: ^0.561.0 + version: 0.561.0(svelte@5.40.2) '@playwright/test': specifier: ^1.55.1 version: 1.56.1 @@ -48,6 +54,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.14 version: 4.1.14(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + '@tanstack/table-core': + specifier: ^8.21.3 + version: 8.21.3 '@types/node': specifier: ^20 version: 20.19.22 @@ -55,8 +64,8 @@ importers: specifier: ^3.2.4 version: 3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4) bits-ui: - specifier: ^2.14.2 - version: 2.14.2(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + specifier: ^2.14.4 + version: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -401,8 +410,8 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} - '@lucide/svelte@0.544.0': - resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==} + '@lucide/svelte@0.561.0': + resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} peerDependencies: svelte: ^5 @@ -723,6 +732,10 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -909,8 +922,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bits-ui@2.14.2: - resolution: {integrity: sha512-YqpAJj/nRTZjf7IlgUC3QlepVZ7YFiAQWpZaYUOAZFW5Py+g5DYkhEDTdNFI5SReo7l1rct/nRpMK4pfL9Xffw==} + bits-ui@2.14.4: + resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==} engines: {node: '>=20'} peerDependencies: '@internationalized/date': ^3.8.1 @@ -1407,6 +1420,11 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lucide-svelte@0.553.0: + resolution: {integrity: sha512-pOqzFX+RfcNyvjF0+nGVnSmprd+4NQ6mvpLOLEmhTyZGOad8+OtCl65822E7Rx9qE7rfKw84ODKI2v318JZ/7g==} + peerDependencies: + svelte: ^3 || ^4 || ^5.0.0-next.42 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1660,6 +1678,11 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + runed@0.28.0: + resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==} + peerDependencies: + svelte: ^5.7.0 + runed@0.35.1: resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} peerDependencies: @@ -1746,6 +1769,11 @@ packages: svelte: optional: true + svelte-sonner@1.0.7: + resolution: {integrity: sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==} + peerDependencies: + svelte: ^5.0.0 + svelte-toolbelt@0.10.6: resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} engines: {node: '>=18', pnpm: '>=8.7.0'} @@ -2225,7 +2253,7 @@ snapshots: '@lix-js/server-protocol-schema@0.1.1': {} - '@lucide/svelte@0.544.0(svelte@5.40.2)': + '@lucide/svelte@0.561.0(svelte@5.40.2)': dependencies: svelte: 5.40.2 @@ -2492,6 +2520,8 @@ snapshots: tailwindcss: 4.1.14 vite: 7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1) + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -2718,7 +2748,7 @@ snapshots: balanced-match@1.0.2: {} - bits-ui@2.14.2(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 @@ -3172,6 +3202,10 @@ snapshots: loupe@3.2.1: {} + lucide-svelte@0.553.0(svelte@5.40.2): + dependencies: + svelte: 5.40.2 + lz-string@1.5.0: {} magic-string@0.30.19: @@ -3358,6 +3392,11 @@ snapshots: dependencies: queue-microtask: 1.2.3 + runed@0.28.0(svelte@5.40.2): + dependencies: + esm-env: 1.2.2 + svelte: 5.40.2 + runed@0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): dependencies: dequal: 2.0.3 @@ -3439,6 +3478,11 @@ snapshots: optionalDependencies: svelte: 5.40.2 + svelte-sonner@1.0.7(svelte@5.40.2): + dependencies: + runed: 0.28.0(svelte@5.40.2) + svelte: 5.40.2 + svelte-toolbelt@0.10.6(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): dependencies: clsx: 2.1.1 diff --git a/frontend/project.inlang/cache/plugins/2sy648wh9sugi b/frontend/project.inlang/cache/plugins/2sy648wh9sugi deleted file mode 100644 index 5b07e0dd..00000000 --- a/frontend/project.inlang/cache/plugins/2sy648wh9sugi +++ /dev/null @@ -1 +0,0 @@ -var Un=Object.create;var Xe=Object.defineProperty;var Pn=Object.getOwnPropertyDescriptor;var vn=Object.getOwnPropertyNames;var Nn=Object.getPrototypeOf,Sn=Object.prototype.hasOwnProperty;var Rn=(s,e)=>()=>(e||s((e={exports:{}}).exports,e),e.exports);var xn=(s,e,i,u)=>{if(e&&typeof e=="object"||typeof e=="function")for(let p of vn(e))!Sn.call(s,p)&&p!==i&&Xe(s,p,{get:()=>e[p],enumerable:!(u=Pn(e,p))||u.enumerable});return s};var jn=(s,e,i)=>(i=s!=null?Un(Nn(s)):{},xn(e||!s||!s.__esModule?Xe(i,"default",{value:s,enumerable:!0}):i,s));var he=Rn(o=>{"use strict";Object.defineProperty(o,"__esModule",{value:!0});o.Type=o.JsonType=o.JavaScriptTypeBuilder=o.JsonTypeBuilder=o.TypeBuilder=o.TypeBuilderError=o.TransformEncodeBuilder=o.TransformDecodeBuilder=o.TemplateLiteralDslParser=o.TemplateLiteralGenerator=o.TemplateLiteralGeneratorError=o.TemplateLiteralFinite=o.TemplateLiteralFiniteError=o.TemplateLiteralParser=o.TemplateLiteralParserError=o.TemplateLiteralResolver=o.TemplateLiteralPattern=o.TemplateLiteralPatternError=o.UnionResolver=o.KeyArrayResolver=o.KeyArrayResolverError=o.KeyResolver=o.ObjectMap=o.Intrinsic=o.IndexedAccessor=o.TypeClone=o.TypeExtends=o.TypeExtendsResult=o.TypeExtendsError=o.ExtendsUndefined=o.TypeGuard=o.TypeGuardUnknownTypeError=o.ValueGuard=o.FormatRegistry=o.TypeBoxError=o.TypeRegistry=o.PatternStringExact=o.PatternNumberExact=o.PatternBooleanExact=o.PatternString=o.PatternNumber=o.PatternBoolean=o.Kind=o.Hint=o.Optional=o.Readonly=o.Transform=void 0;o.Transform=Symbol.for("TypeBox.Transform");o.Readonly=Symbol.for("TypeBox.Readonly");o.Optional=Symbol.for("TypeBox.Optional");o.Hint=Symbol.for("TypeBox.Hint");o.Kind=Symbol.for("TypeBox.Kind");o.PatternBoolean="(true|false)";o.PatternNumber="(0|[1-9][0-9]*)";o.PatternString="(.*)";o.PatternBooleanExact=`^${o.PatternBoolean}$`;o.PatternNumberExact=`^${o.PatternNumber}$`;o.PatternStringExact=`^${o.PatternString}$`;var Ve;(function(s){let e=new Map;function i(){return new Map(e)}s.Entries=i;function u(){return e.clear()}s.Clear=u;function p(y){return e.delete(y)}s.Delete=p;function l(y){return e.has(y)}s.Has=l;function c(y,b){e.set(y,b)}s.Set=c;function T(y){return e.get(y)}s.Get=T})(Ve||(o.TypeRegistry=Ve={}));var D=class extends Error{constructor(e){super(e)}};o.TypeBoxError=D;var Ze;(function(s){let e=new Map;function i(){return new Map(e)}s.Entries=i;function u(){return e.clear()}s.Clear=u;function p(y){return e.delete(y)}s.Delete=p;function l(y){return e.has(y)}s.Has=l;function c(y,b){e.set(y,b)}s.Set=c;function T(y){return e.get(y)}s.Get=T})(Ze||(o.FormatRegistry=Ze={}));var I;(function(s){function e(m){return Array.isArray(m)}s.IsArray=e;function i(m){return typeof m=="bigint"}s.IsBigInt=i;function u(m){return typeof m=="boolean"}s.IsBoolean=u;function p(m){return m instanceof globalThis.Date}s.IsDate=p;function l(m){return m===null}s.IsNull=l;function c(m){return typeof m=="number"}s.IsNumber=c;function T(m){return typeof m=="object"&&m!==null}s.IsObject=T;function y(m){return typeof m=="string"}s.IsString=y;function b(m){return m instanceof globalThis.Uint8Array}s.IsUint8Array=b;function g(m){return m===void 0}s.IsUndefined=g})(I||(o.ValueGuard=I={}));var ze=class extends D{};o.TypeGuardUnknownTypeError=ze;var a;(function(s){function e(r){try{return new RegExp(r),!0}catch{return!1}}function i(r){if(!I.IsString(r))return!1;for(let L=0;L=7&&B<=13||B===27||B===127)return!1}return!0}function u(r){return c(r)||C(r)}function p(r){return I.IsUndefined(r)||I.IsBigInt(r)}function l(r){return I.IsUndefined(r)||I.IsNumber(r)}function c(r){return I.IsUndefined(r)||I.IsBoolean(r)}function T(r){return I.IsUndefined(r)||I.IsString(r)}function y(r){return I.IsUndefined(r)||I.IsString(r)&&i(r)&&e(r)}function b(r){return I.IsUndefined(r)||I.IsString(r)&&i(r)}function g(r){return I.IsUndefined(r)||C(r)}function m(r){return S(r,"Any")&&T(r.$id)}s.TAny=m;function U(r){return S(r,"Array")&&r.type==="array"&&T(r.$id)&&C(r.items)&&l(r.minItems)&&l(r.maxItems)&&c(r.uniqueItems)&&g(r.contains)&&l(r.minContains)&&l(r.maxContains)}s.TArray=U;function d(r){return S(r,"AsyncIterator")&&r.type==="AsyncIterator"&&T(r.$id)&&C(r.items)}s.TAsyncIterator=d;function O(r){return S(r,"BigInt")&&r.type==="bigint"&&T(r.$id)&&p(r.exclusiveMaximum)&&p(r.exclusiveMinimum)&&p(r.maximum)&&p(r.minimum)&&p(r.multipleOf)}s.TBigInt=O;function v(r){return S(r,"Boolean")&&r.type==="boolean"&&T(r.$id)}s.TBoolean=v;function N(r){return S(r,"Constructor")&&r.type==="Constructor"&&T(r.$id)&&I.IsArray(r.parameters)&&r.parameters.every(L=>C(L))&&C(r.returns)}s.TConstructor=N;function j(r){return S(r,"Date")&&r.type==="Date"&&T(r.$id)&&l(r.exclusiveMaximumTimestamp)&&l(r.exclusiveMinimumTimestamp)&&l(r.maximumTimestamp)&&l(r.minimumTimestamp)&&l(r.multipleOfTimestamp)}s.TDate=j;function R(r){return S(r,"Function")&&r.type==="Function"&&T(r.$id)&&I.IsArray(r.parameters)&&r.parameters.every(L=>C(L))&&C(r.returns)}s.TFunction=R;function A(r){return S(r,"Integer")&&r.type==="integer"&&T(r.$id)&&l(r.exclusiveMaximum)&&l(r.exclusiveMinimum)&&l(r.maximum)&&l(r.minimum)&&l(r.multipleOf)}s.TInteger=A;function K(r){return S(r,"Intersect")&&!(I.IsString(r.type)&&r.type!=="object")&&I.IsArray(r.allOf)&&r.allOf.every(L=>C(L)&&!oe(L))&&T(r.type)&&(c(r.unevaluatedProperties)||g(r.unevaluatedProperties))&&T(r.$id)}s.TIntersect=K;function pe(r){return S(r,"Iterator")&&r.type==="Iterator"&&T(r.$id)&&C(r.items)}s.TIterator=pe;function S(r,L){return ee(r)&&r[o.Kind]===L}s.TKindOf=S;function ee(r){return I.IsObject(r)&&o.Kind in r&&I.IsString(r[o.Kind])}s.TKind=ee;function ne(r){return V(r)&&I.IsString(r.const)}s.TLiteralString=ne;function Te(r){return V(r)&&I.IsNumber(r.const)}s.TLiteralNumber=Te;function Ke(r){return V(r)&&I.IsBoolean(r.const)}s.TLiteralBoolean=Ke;function V(r){return S(r,"Literal")&&T(r.$id)&&(I.IsBoolean(r.const)||I.IsNumber(r.const)||I.IsString(r.const))}s.TLiteral=V;function fe(r){return S(r,"Never")&&I.IsObject(r.not)&&Object.getOwnPropertyNames(r.not).length===0}s.TNever=fe;function $(r){return S(r,"Not")&&C(r.not)}s.TNot=$;function te(r){return S(r,"Null")&&r.type==="null"&&T(r.$id)}s.TNull=te;function re(r){return S(r,"Number")&&r.type==="number"&&T(r.$id)&&l(r.exclusiveMaximum)&&l(r.exclusiveMinimum)&&l(r.maximum)&&l(r.minimum)&&l(r.multipleOf)}s.TNumber=re;function _(r){return S(r,"Object")&&r.type==="object"&&T(r.$id)&&I.IsObject(r.properties)&&u(r.additionalProperties)&&l(r.minProperties)&&l(r.maxProperties)&&Object.entries(r.properties).every(([L,B])=>i(L)&&C(B))}s.TObject=_;function ie(r){return S(r,"Promise")&&r.type==="Promise"&&T(r.$id)&&C(r.item)}s.TPromise=ie;function de(r){return S(r,"Record")&&r.type==="object"&&T(r.$id)&&u(r.additionalProperties)&&I.IsObject(r.patternProperties)&&(L=>{let B=Object.getOwnPropertyNames(L.patternProperties);return B.length===1&&e(B[0])&&I.IsObject(L.patternProperties)&&C(L.patternProperties[B[0]])})(r)}s.TRecord=de;function Ee(r){return I.IsObject(r)&&o.Hint in r&&r[o.Hint]==="Recursive"}s.TRecursive=Ee;function ye(r){return S(r,"Ref")&&T(r.$id)&&I.IsString(r.$ref)}s.TRef=ye;function me(r){return S(r,"String")&&r.type==="string"&&T(r.$id)&&l(r.minLength)&&l(r.maxLength)&&y(r.pattern)&&b(r.format)}s.TString=me;function ge(r){return S(r,"Symbol")&&r.type==="symbol"&&T(r.$id)}s.TSymbol=ge;function z(r){return S(r,"TemplateLiteral")&&r.type==="string"&&I.IsString(r.pattern)&&r.pattern[0]==="^"&&r.pattern[r.pattern.length-1]==="$"}s.TTemplateLiteral=z;function Ie(r){return S(r,"This")&&T(r.$id)&&I.IsString(r.$ref)}s.TThis=Ie;function oe(r){return I.IsObject(r)&&o.Transform in r}s.TTransform=oe;function F(r){return S(r,"Tuple")&&r.type==="array"&&T(r.$id)&&I.IsNumber(r.minItems)&&I.IsNumber(r.maxItems)&&r.minItems===r.maxItems&&(I.IsUndefined(r.items)&&I.IsUndefined(r.additionalItems)&&r.minItems===0||I.IsArray(r.items)&&r.items.every(L=>C(L)))}s.TTuple=F;function be(r){return S(r,"Undefined")&&r.type==="undefined"&&T(r.$id)}s.TUndefined=be;function Be(r){return q(r)&&r.anyOf.every(L=>ne(L)||Te(L))}s.TUnionLiteral=Be;function q(r){return S(r,"Union")&&T(r.$id)&&I.IsObject(r)&&I.IsArray(r.anyOf)&&r.anyOf.every(L=>C(L))}s.TUnion=q;function W(r){return S(r,"Uint8Array")&&r.type==="Uint8Array"&&T(r.$id)&&l(r.minByteLength)&&l(r.maxByteLength)}s.TUint8Array=W;function E(r){return S(r,"Unknown")&&T(r.$id)}s.TUnknown=E;function Oe(r){return S(r,"Unsafe")}s.TUnsafe=Oe;function se(r){return S(r,"Void")&&r.type==="void"&&T(r.$id)}s.TVoid=se;function Me(r){return I.IsObject(r)&&r[o.Readonly]==="Readonly"}s.TReadonly=Me;function De(r){return I.IsObject(r)&&r[o.Optional]==="Optional"}s.TOptional=De;function C(r){return I.IsObject(r)&&(m(r)||U(r)||v(r)||O(r)||d(r)||N(r)||j(r)||R(r)||A(r)||K(r)||pe(r)||V(r)||fe(r)||$(r)||te(r)||re(r)||_(r)||ie(r)||de(r)||ye(r)||me(r)||ge(r)||z(r)||Ie(r)||F(r)||be(r)||q(r)||W(r)||E(r)||Oe(r)||se(r)||ee(r)&&Ve.Has(r[o.Kind]))}s.TSchema=C})(a||(o.TypeGuard=a={}));var Ge;(function(s){function e(i){return i[o.Kind]==="Intersect"?i.allOf.every(u=>e(u)):i[o.Kind]==="Union"?i.anyOf.some(u=>e(u)):i[o.Kind]==="Undefined"?!0:i[o.Kind]==="Not"?!e(i.not):!1}s.Check=e})(Ge||(o.ExtendsUndefined=Ge={}));var Ue=class extends D{};o.TypeExtendsError=Ue;var f;(function(s){s[s.Union=0]="Union",s[s.True=1]="True",s[s.False=2]="False"})(f||(o.TypeExtendsResult=f={}));var J;(function(s){function e(n){return n===f.False?n:f.True}function i(n){throw new Ue(n)}function u(n){return a.TNever(n)||a.TIntersect(n)||a.TUnion(n)||a.TUnknown(n)||a.TAny(n)}function p(n,t){return a.TNever(t)?S(n,t):a.TIntersect(t)?R(n,t):a.TUnion(t)?ke(n,t):a.TUnknown(t)?Qe(n,t):a.TAny(t)?l(n,t):i("StructuralRight")}function l(n,t){return f.True}function c(n,t){return a.TIntersect(t)?R(n,t):a.TUnion(t)&&t.anyOf.some(x=>a.TAny(x)||a.TUnknown(x))?f.True:a.TUnion(t)?f.Union:a.TUnknown(t)||a.TAny(t)?f.True:f.Union}function T(n,t){return a.TUnknown(n)?f.False:a.TAny(n)?f.Union:a.TNever(n)?f.True:f.False}function y(n,t){return a.TObject(t)&&z(t)?f.True:u(t)?p(n,t):a.TArray(t)?e(w(n.items,t.items)):f.False}function b(n,t){return u(t)?p(n,t):a.TAsyncIterator(t)?e(w(n.items,t.items)):f.False}function g(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TBigInt(t)?f.True:f.False}function m(n,t){return a.TLiteral(n)&&I.IsBoolean(n.const)||a.TBoolean(n)?f.True:f.False}function U(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TBoolean(t)?f.True:f.False}function d(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TConstructor(t)?n.parameters.length>t.parameters.length?f.False:n.parameters.every((x,M)=>e(w(t.parameters[M],x))===f.True)?e(w(n.returns,t.returns)):f.False:f.False}function O(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TDate(t)?f.True:f.False}function v(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TFunction(t)?n.parameters.length>t.parameters.length?f.False:n.parameters.every((x,M)=>e(w(t.parameters[M],x))===f.True)?e(w(n.returns,t.returns)):f.False:f.False}function N(n,t){return a.TLiteral(n)&&I.IsNumber(n.const)||a.TNumber(n)||a.TInteger(n)?f.True:f.False}function j(n,t){return a.TInteger(t)||a.TNumber(t)?f.True:u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):f.False}function R(n,t){return t.allOf.every(x=>w(n,x)===f.True)?f.True:f.False}function A(n,t){return n.allOf.some(x=>w(x,t)===f.True)?f.True:f.False}function K(n,t){return u(t)?p(n,t):a.TIterator(t)?e(w(n.items,t.items)):f.False}function pe(n,t){return a.TLiteral(t)&&t.const===n.const?f.True:u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TString(t)?se(n,t):a.TNumber(t)?V(n,t):a.TInteger(t)?N(n,t):a.TBoolean(t)?m(n,t):f.False}function S(n,t){return f.False}function ee(n,t){return f.True}function ne(n){let[t,x]=[n,0];for(;a.TNot(t);)t=t.not,x+=1;return x%2===0?t:o.Type.Unknown()}function Te(n,t){return a.TNot(n)?w(ne(n),t):a.TNot(t)?w(n,ne(t)):i("Invalid fallthrough for Not")}function Ke(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TNull(t)?f.True:f.False}function V(n,t){return a.TLiteralNumber(n)||a.TNumber(n)||a.TInteger(n)?f.True:f.False}function fe(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TInteger(t)||a.TNumber(t)?f.True:f.False}function $(n,t){return Object.getOwnPropertyNames(n.properties).length===t}function te(n){return z(n)}function re(n){return $(n,0)||$(n,1)&&"description"in n.properties&&a.TUnion(n.properties.description)&&n.properties.description.anyOf.length===2&&(a.TString(n.properties.description.anyOf[0])&&a.TUndefined(n.properties.description.anyOf[1])||a.TString(n.properties.description.anyOf[1])&&a.TUndefined(n.properties.description.anyOf[0]))}function _(n){return $(n,0)}function ie(n){return $(n,0)}function de(n){return $(n,0)}function Ee(n){return $(n,0)}function ye(n){return z(n)}function me(n){let t=o.Type.Number();return $(n,0)||$(n,1)&&"length"in n.properties&&e(w(n.properties.length,t))===f.True}function ge(n){return $(n,0)}function z(n){let t=o.Type.Number();return $(n,0)||$(n,1)&&"length"in n.properties&&e(w(n.properties.length,t))===f.True}function Ie(n){let t=o.Type.Function([o.Type.Any()],o.Type.Any());return $(n,0)||$(n,1)&&"then"in n.properties&&e(w(n.properties.then,t))===f.True}function oe(n,t){return w(n,t)===f.False||a.TOptional(n)&&!a.TOptional(t)?f.False:f.True}function F(n,t){return a.TUnknown(n)?f.False:a.TAny(n)?f.Union:a.TNever(n)||a.TLiteralString(n)&&te(t)||a.TLiteralNumber(n)&&_(t)||a.TLiteralBoolean(n)&&ie(t)||a.TSymbol(n)&&re(t)||a.TBigInt(n)&&de(t)||a.TString(n)&&te(t)||a.TSymbol(n)&&re(t)||a.TNumber(n)&&_(t)||a.TInteger(n)&&_(t)||a.TBoolean(n)&&ie(t)||a.TUint8Array(n)&&ye(t)||a.TDate(n)&&Ee(t)||a.TConstructor(n)&&ge(t)||a.TFunction(n)&&me(t)?f.True:a.TRecord(n)&&a.TString(q(n))?t[o.Hint]==="Record"?f.True:f.False:a.TRecord(n)&&a.TNumber(q(n))?$(t,0)?f.True:f.False:f.False}function be(n,t){return u(t)?p(n,t):a.TRecord(t)?E(n,t):a.TObject(t)?(()=>{for(let x of Object.getOwnPropertyNames(t.properties)){if(!(x in n.properties)&&!a.TOptional(t.properties[x]))return f.False;if(a.TOptional(t.properties[x]))return f.True;if(oe(n.properties[x],t.properties[x])===f.False)return f.False}return f.True})():f.False}function Be(n,t){return u(t)?p(n,t):a.TObject(t)&&Ie(t)?f.True:a.TPromise(t)?e(w(n.item,t.item)):f.False}function q(n){return o.PatternNumberExact in n.patternProperties?o.Type.Number():o.PatternStringExact in n.patternProperties?o.Type.String():i("Unknown record key pattern")}function W(n){return o.PatternNumberExact in n.patternProperties?n.patternProperties[o.PatternNumberExact]:o.PatternStringExact in n.patternProperties?n.patternProperties[o.PatternStringExact]:i("Unable to get record value schema")}function E(n,t){let[x,M]=[q(t),W(t)];return a.TLiteralString(n)&&a.TNumber(x)&&e(w(n,M))===f.True?f.True:a.TUint8Array(n)&&a.TNumber(x)||a.TString(n)&&a.TNumber(x)||a.TArray(n)&&a.TNumber(x)?w(n,M):a.TObject(n)?(()=>{for(let On of Object.getOwnPropertyNames(n.properties))if(oe(M,n.properties[On])===f.False)return f.False;return f.True})():f.False}function Oe(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?w(W(n),W(t)):f.False}function se(n,t){return a.TLiteral(n)&&I.IsString(n.const)||a.TString(n)?f.True:f.False}function Me(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TString(t)?f.True:f.False}function De(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TSymbol(t)?f.True:f.False}function C(n,t){return a.TTemplateLiteral(n)?w(k.Resolve(n),t):a.TTemplateLiteral(t)?w(n,k.Resolve(t)):i("Invalid fallthrough for TemplateLiteral")}function r(n,t){return a.TArray(t)&&n.items!==void 0&&n.items.every(x=>w(x,t.items)===f.True)}function L(n,t){return a.TNever(n)?f.True:a.TUnknown(n)?f.False:a.TAny(n)?f.Union:f.False}function B(n,t){return u(t)?p(n,t):a.TObject(t)&&z(t)||a.TArray(t)&&r(n,t)?f.True:a.TTuple(t)?I.IsUndefined(n.items)&&!I.IsUndefined(t.items)||!I.IsUndefined(n.items)&&I.IsUndefined(t.items)?f.False:I.IsUndefined(n.items)&&!I.IsUndefined(t.items)||n.items.every((x,M)=>w(x,t.items[M])===f.True)?f.True:f.False:f.False}function fn(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TUint8Array(t)?f.True:f.False}function dn(n,t){return u(t)?p(n,t):a.TObject(t)?F(n,t):a.TRecord(t)?E(n,t):a.TVoid(t)?gn(n,t):a.TUndefined(t)?f.True:f.False}function ke(n,t){return t.anyOf.some(x=>w(n,x)===f.True)?f.True:f.False}function yn(n,t){return n.anyOf.every(x=>w(x,t)===f.True)?f.True:f.False}function Qe(n,t){return f.True}function mn(n,t){return a.TNever(t)?S(n,t):a.TIntersect(t)?R(n,t):a.TUnion(t)?ke(n,t):a.TAny(t)?l(n,t):a.TString(t)?se(n,t):a.TNumber(t)?V(n,t):a.TInteger(t)?N(n,t):a.TBoolean(t)?m(n,t):a.TArray(t)?T(n,t):a.TTuple(t)?L(n,t):a.TObject(t)?F(n,t):a.TUnknown(t)?f.True:f.False}function gn(n,t){return a.TUndefined(n)||a.TUndefined(n)?f.True:f.False}function In(n,t){return a.TIntersect(t)?R(n,t):a.TUnion(t)?ke(n,t):a.TUnknown(t)?Qe(n,t):a.TAny(t)?l(n,t):a.TObject(t)?F(n,t):a.TVoid(t)?f.True:f.False}function w(n,t){return a.TTemplateLiteral(n)||a.TTemplateLiteral(t)?C(n,t):a.TNot(n)||a.TNot(t)?Te(n,t):a.TAny(n)?c(n,t):a.TArray(n)?y(n,t):a.TBigInt(n)?g(n,t):a.TBoolean(n)?U(n,t):a.TAsyncIterator(n)?b(n,t):a.TConstructor(n)?d(n,t):a.TDate(n)?O(n,t):a.TFunction(n)?v(n,t):a.TInteger(n)?j(n,t):a.TIntersect(n)?A(n,t):a.TIterator(n)?K(n,t):a.TLiteral(n)?pe(n,t):a.TNever(n)?ee(n,t):a.TNull(n)?Ke(n,t):a.TNumber(n)?fe(n,t):a.TObject(n)?be(n,t):a.TRecord(n)?Oe(n,t):a.TString(n)?Me(n,t):a.TSymbol(n)?De(n,t):a.TTuple(n)?B(n,t):a.TPromise(n)?Be(n,t):a.TUint8Array(n)?fn(n,t):a.TUndefined(n)?dn(n,t):a.TUnion(n)?yn(n,t):a.TUnknown(n)?mn(n,t):a.TVoid(n)?In(n,t):i(`Unknown left type operand '${n[o.Kind]}'`)}function bn(n,t){return w(n,t)}s.Extends=bn})(J||(o.TypeExtends=J={}));var P;(function(s){function e(y){return y.map(b=>l(b))}function i(y){return new Date(y.getTime())}function u(y){return new Uint8Array(y)}function p(y){let b=Object.getOwnPropertyNames(y).reduce((m,U)=>({...m,[U]:l(y[U])}),{}),g=Object.getOwnPropertySymbols(y).reduce((m,U)=>({...m,[U]:l(y[U])}),{});return{...b,...g}}function l(y){return I.IsArray(y)?e(y):I.IsDate(y)?i(y):I.IsUint8Array(y)?u(y):I.IsObject(y)?p(y):y}function c(y){return y.map(b=>T(b))}s.Rest=c;function T(y,b={}){return{...l(y),...b}}s.Type=T})(P||(o.TypeClone=P={}));var qe;(function(s){function e(d){return d.map(O=>{let{[o.Optional]:v,...N}=P.Type(O);return N})}function i(d){return d.every(O=>a.TOptional(O))}function u(d){return d.some(O=>a.TOptional(O))}function p(d){return i(d.allOf)?o.Type.Optional(o.Type.Intersect(e(d.allOf))):d}function l(d){return u(d.anyOf)?o.Type.Optional(o.Type.Union(e(d.anyOf))):d}function c(d){return d[o.Kind]==="Intersect"?p(d):d[o.Kind]==="Union"?l(d):d}function T(d,O){let v=d.allOf.reduce((N,j)=>{let R=m(j,O);return R[o.Kind]==="Never"?N:[...N,R]},[]);return c(o.Type.Intersect(v))}function y(d,O){let v=d.anyOf.map(N=>m(N,O));return c(o.Type.Union(v))}function b(d,O){let v=d.properties[O];return I.IsUndefined(v)?o.Type.Never():o.Type.Union([v])}function g(d,O){let v=d.items;if(I.IsUndefined(v))return o.Type.Never();let N=v[O];return I.IsUndefined(N)?o.Type.Never():N}function m(d,O){return d[o.Kind]==="Intersect"?T(d,O):d[o.Kind]==="Union"?y(d,O):d[o.Kind]==="Object"?b(d,O):d[o.Kind]==="Tuple"?g(d,O):o.Type.Never()}function U(d,O,v={}){let N=O.map(j=>m(d,j.toString()));return c(o.Type.Union(N,v))}s.Resolve=U})(qe||(o.IndexedAccessor=qe={}));var Y;(function(s){function e(g){let[m,U]=[g.slice(0,1),g.slice(1)];return`${m.toLowerCase()}${U}`}function i(g){let[m,U]=[g.slice(0,1),g.slice(1)];return`${m.toUpperCase()}${U}`}function u(g){return g.toUpperCase()}function p(g){return g.toLowerCase()}function l(g,m){let U=X.ParseExact(g.pattern);if(!Z.Check(U))return{...g,pattern:c(g.pattern,m)};let v=[...G.Generate(U)].map(R=>o.Type.Literal(R)),N=T(v,m),j=o.Type.Union(N);return o.Type.TemplateLiteral([j])}function c(g,m){return typeof g=="string"?m==="Uncapitalize"?e(g):m==="Capitalize"?i(g):m==="Uppercase"?u(g):m==="Lowercase"?p(g):g:g.toString()}function T(g,m){if(g.length===0)return[];let[U,...d]=g;return[b(U,m),...T(d,m)]}function y(g,m){return a.TTemplateLiteral(g)?l(g,m):a.TUnion(g)?o.Type.Union(T(g.anyOf,m)):a.TLiteral(g)?o.Type.Literal(c(g.const,m)):g}function b(g,m){return y(g,m)}s.Map=b})(Y||(o.Intrinsic=Y={}));var Q;(function(s){function e(c,T){return o.Type.Intersect(c.allOf.map(y=>p(y,T)),{...c})}function i(c,T){return o.Type.Union(c.anyOf.map(y=>p(y,T)),{...c})}function u(c,T){return T(c)}function p(c,T){return c[o.Kind]==="Intersect"?e(c,T):c[o.Kind]==="Union"?i(c,T):c[o.Kind]==="Object"?u(c,T):c}function l(c,T,y){return{...p(P.Type(c),T),...y}}s.Map=l})(Q||(o.ObjectMap=Q={}));var Pe;(function(s){function e(b){return b[0]==="^"&&b[b.length-1]==="$"?b.slice(1,b.length-1):b}function i(b,g){return b.allOf.reduce((m,U)=>[...m,...c(U,g)],[])}function u(b,g){let m=b.anyOf.map(U=>c(U,g));return[...m.reduce((U,d)=>d.map(O=>m.every(v=>v.includes(O))?U.add(O):U)[0],new Set)]}function p(b,g){return Object.getOwnPropertyNames(b.properties)}function l(b,g){return g.includePatterns?Object.getOwnPropertyNames(b.patternProperties):[]}function c(b,g){return a.TIntersect(b)?i(b,g):a.TUnion(b)?u(b,g):a.TObject(b)?p(b,g):a.TRecord(b)?l(b,g):[]}function T(b,g){return[...new Set(c(b,g))]}s.ResolveKeys=T;function y(b){return`^(${T(b,{includePatterns:!0}).map(U=>`(${e(U)})`).join("|")})$`}s.ResolvePattern=y})(Pe||(o.KeyResolver=Pe={}));var ve=class extends D{};o.KeyArrayResolverError=ve;var ae;(function(s){function e(i){return Array.isArray(i)?i:a.TUnionLiteral(i)?i.anyOf.map(u=>u.const.toString()):a.TLiteral(i)?[i.const]:a.TTemplateLiteral(i)?(()=>{let u=X.ParseExact(i.pattern);if(!Z.Check(u))throw new ve("Cannot resolve keys from infinite template expression");return[...G.Generate(u)]})():[]}s.Resolve=e})(ae||(o.KeyArrayResolver=ae={}));var Je;(function(s){function*e(u){for(let p of u.anyOf)p[o.Kind]==="Union"?yield*e(p):yield p}function i(u){return o.Type.Union([...e(u)],{...u})}s.Resolve=i})(Je||(o.UnionResolver=Je={}));var Ne=class extends D{};o.TemplateLiteralPatternError=Ne;var Se;(function(s){function e(l){throw new Ne(l)}function i(l){return l.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function u(l,c){return a.TTemplateLiteral(l)?l.pattern.slice(1,l.pattern.length-1):a.TUnion(l)?`(${l.anyOf.map(T=>u(T,c)).join("|")})`:a.TNumber(l)?`${c}${o.PatternNumber}`:a.TInteger(l)?`${c}${o.PatternNumber}`:a.TBigInt(l)?`${c}${o.PatternNumber}`:a.TString(l)?`${c}${o.PatternString}`:a.TLiteral(l)?`${c}${i(l.const.toString())}`:a.TBoolean(l)?`${c}${o.PatternBoolean}`:e(`Unexpected Kind '${l[o.Kind]}'`)}function p(l){return`^${l.map(c=>u(c,"")).join("")}$`}s.Create=p})(Se||(o.TemplateLiteralPattern=Se={}));var k;(function(s){function e(i){let u=X.ParseExact(i.pattern);if(!Z.Check(u))return o.Type.String();let p=[...G.Generate(u)].map(l=>o.Type.Literal(l));return o.Type.Union(p)}s.Resolve=e})(k||(o.TemplateLiteralResolver=k={}));var ue=class extends D{};o.TemplateLiteralParserError=ue;var X;(function(s){function e(d,O,v){return d[O]===v&&d.charCodeAt(O-1)!==92}function i(d,O){return e(d,O,"(")}function u(d,O){return e(d,O,")")}function p(d,O){return e(d,O,"|")}function l(d){if(!(i(d,0)&&u(d,d.length-1)))return!1;let O=0;for(let v=0;v0&&N.push(m(A)),v=R+1}let j=d.slice(v);return j.length>0&&N.push(m(j)),N.length===0?{type:"const",const:""}:N.length===1?N[0]:{type:"or",expr:N}}function g(d){function O(j,R){if(!i(j,R))throw new ue("TemplateLiteralParser: Index must point to open parens");let A=0;for(let K=R;K0&&N.push(m(K)),j=A-1}return N.length===0?{type:"const",const:""}:N.length===1?N[0]:{type:"and",expr:N}}function m(d){return l(d)?m(c(d)):T(d)?b(d):y(d)?g(d):{type:"const",const:d}}s.Parse=m;function U(d){return m(d.slice(1,d.length-1))}s.ParseExact=U})(X||(o.TemplateLiteralParser=X={}));var Re=class extends D{};o.TemplateLiteralFiniteError=Re;var Z;(function(s){function e(c){throw new Re(c)}function i(c){return c.type==="or"&&c.expr.length===2&&c.expr[0].type==="const"&&c.expr[0].const==="0"&&c.expr[1].type==="const"&&c.expr[1].const==="[1-9][0-9]*"}function u(c){return c.type==="or"&&c.expr.length===2&&c.expr[0].type==="const"&&c.expr[0].const==="true"&&c.expr[1].type==="const"&&c.expr[1].const==="false"}function p(c){return c.type==="const"&&c.const===".*"}function l(c){return u(c)?!0:i(c)||p(c)?!1:c.type==="and"?c.expr.every(T=>l(T)):c.type==="or"?c.expr.every(T=>l(T)):c.type==="const"?!0:e("Unknown expression type")}s.Check=l})(Z||(o.TemplateLiteralFinite=Z={}));var xe=class extends D{};o.TemplateLiteralGeneratorError=xe;var G;(function(s){function*e(c){if(c.length===1)return yield*c[0];for(let T of c[0])for(let y of e(c.slice(1)))yield`${T}${y}`}function*i(c){return yield*e(c.expr.map(T=>[...l(T)]))}function*u(c){for(let T of c.expr)yield*l(T)}function*p(c){return yield c.const}function*l(c){return c.type==="and"?yield*i(c):c.type==="or"?yield*u(c):c.type==="const"?yield*p(c):(()=>{throw new xe("Unknown expression")})()}s.Generate=l})(G||(o.TemplateLiteralGenerator=G={}));var He;(function(s){function*e(l){let c=l.trim().replace(/"|'/g,"");return c==="boolean"?yield o.Type.Boolean():c==="number"?yield o.Type.Number():c==="bigint"?yield o.Type.BigInt():c==="string"?yield o.Type.String():yield(()=>{let T=c.split("|").map(y=>o.Type.Literal(y.trim()));return T.length===0?o.Type.Never():T.length===1?T[0]:o.Type.Union(T)})()}function*i(l){if(l[1]!=="{"){let c=o.Type.Literal("$"),T=u(l.slice(1));return yield*[c,...T]}for(let c=2;c{let l={Encode:c=>i[o.Transform].Encode(e(c)),Decode:c=>this.decode(i[o.Transform].Decode(c))};return{...i,[o.Transform]:l}})():(()=>{let u={Decode:this.decode,Encode:e};return{...i,[o.Transform]:u}})()}};o.TransformEncodeBuilder=we;var wn=0,Le=class extends D{};o.TypeBuilderError=Le;var Ae=class{Create(e){return e}Throw(e){throw new Le(e)}Discard(e,i){return i.reduce((u,p)=>{let{[p]:l,...c}=u;return c},e)}Strict(e){return JSON.parse(JSON.stringify(e))}};o.TypeBuilder=Ae;var le=class extends Ae{ReadonlyOptional(e){return this.Readonly(this.Optional(e))}Readonly(e){return{...P.Type(e),[o.Readonly]:"Readonly"}}Optional(e){return{...P.Type(e),[o.Optional]:"Optional"}}Any(e={}){return this.Create({...e,[o.Kind]:"Any"})}Array(e,i={}){return this.Create({...i,[o.Kind]:"Array",type:"array",items:P.Type(e)})}Boolean(e={}){return this.Create({...e,[o.Kind]:"Boolean",type:"boolean"})}Capitalize(e,i={}){return{...Y.Map(P.Type(e),"Capitalize"),...i}}Composite(e,i){let u=o.Type.Intersect(e,{}),l=Pe.ResolveKeys(u,{includePatterns:!1}).reduce((c,T)=>({...c,[T]:o.Type.Index(u,[T])}),{});return o.Type.Object(l,i)}Enum(e,i={}){if(I.IsUndefined(e))return this.Throw("Enum undefined or empty");let u=Object.getOwnPropertyNames(e).filter(c=>isNaN(c)).map(c=>e[c]),l=[...new Set(u)].map(c=>o.Type.Literal(c));return this.Union(l,{...i,[o.Hint]:"Enum"})}Extends(e,i,u,p,l={}){switch(J.Extends(e,i)){case f.Union:return this.Union([P.Type(u,l),P.Type(p,l)]);case f.True:return P.Type(u,l);case f.False:return P.Type(p,l)}}Exclude(e,i,u={}){return a.TTemplateLiteral(e)?this.Exclude(k.Resolve(e),i,u):a.TTemplateLiteral(i)?this.Exclude(e,k.Resolve(i),u):a.TUnion(e)?(()=>{let p=e.anyOf.filter(l=>J.Extends(l,i)===f.False);return p.length===1?P.Type(p[0],u):this.Union(p,u)})():J.Extends(e,i)!==f.False?this.Never(u):P.Type(e,u)}Extract(e,i,u={}){return a.TTemplateLiteral(e)?this.Extract(k.Resolve(e),i,u):a.TTemplateLiteral(i)?this.Extract(e,k.Resolve(i),u):a.TUnion(e)?(()=>{let p=e.anyOf.filter(l=>J.Extends(l,i)!==f.False);return p.length===1?P.Type(p[0],u):this.Union(p,u)})():J.Extends(e,i)!==f.False?P.Type(e,u):this.Never(u)}Index(e,i,u={}){return a.TArray(e)&&a.TNumber(i)?P.Type(e.items,u):a.TTuple(e)&&a.TNumber(i)?(()=>{let l=(I.IsUndefined(e.items)?[]:e.items).map(c=>P.Type(c));return this.Union(l,u)})():(()=>{let p=ae.Resolve(i),l=P.Type(e);return qe.Resolve(l,p,u)})()}Integer(e={}){return this.Create({...e,[o.Kind]:"Integer",type:"integer"})}Intersect(e,i={}){if(e.length===0)return o.Type.Never();if(e.length===1)return P.Type(e[0],i);e.some(c=>a.TTransform(c))&&this.Throw("Cannot intersect transform types");let u=e.every(c=>a.TObject(c)),p=P.Rest(e),l=a.TSchema(i.unevaluatedProperties)?{unevaluatedProperties:P.Type(i.unevaluatedProperties)}:{};return i.unevaluatedProperties===!1||a.TSchema(i.unevaluatedProperties)||u?this.Create({...i,...l,[o.Kind]:"Intersect",type:"object",allOf:p}):this.Create({...i,...l,[o.Kind]:"Intersect",allOf:p})}KeyOf(e,i={}){return a.TRecord(e)?(()=>{let u=Object.getOwnPropertyNames(e.patternProperties)[0];return u===o.PatternNumberExact?this.Number(i):u===o.PatternStringExact?this.String(i):this.Throw("Unable to resolve key type from Record key pattern")})():a.TTuple(e)?(()=>{let p=(I.IsUndefined(e.items)?[]:e.items).map((l,c)=>o.Type.Literal(c.toString()));return this.Union(p,i)})():a.TArray(e)?this.Number(i):(()=>{let u=Pe.ResolveKeys(e,{includePatterns:!1});if(u.length===0)return this.Never(i);let p=u.map(l=>this.Literal(l));return this.Union(p,i)})()}Literal(e,i={}){return this.Create({...i,[o.Kind]:"Literal",const:e,type:typeof e})}Lowercase(e,i={}){return{...Y.Map(P.Type(e),"Lowercase"),...i}}Never(e={}){return this.Create({...e,[o.Kind]:"Never",not:{}})}Not(e,i){return this.Create({...i,[o.Kind]:"Not",not:P.Type(e)})}Null(e={}){return this.Create({...e,[o.Kind]:"Null",type:"null"})}Number(e={}){return this.Create({...e,[o.Kind]:"Number",type:"number"})}Object(e,i={}){let u=Object.getOwnPropertyNames(e),p=u.filter(y=>a.TOptional(e[y])),l=u.filter(y=>!p.includes(y)),c=a.TSchema(i.additionalProperties)?{additionalProperties:P.Type(i.additionalProperties)}:{},T=u.reduce((y,b)=>({...y,[b]:P.Type(e[b])}),{});return l.length>0?this.Create({...i,...c,[o.Kind]:"Object",type:"object",properties:T,required:l}):this.Create({...i,...c,[o.Kind]:"Object",type:"object",properties:T})}Omit(e,i,u={}){let p=ae.Resolve(i);return Q.Map(this.Discard(P.Type(e),["$id",o.Transform]),l=>{I.IsArray(l.required)&&(l.required=l.required.filter(c=>!p.includes(c)),l.required.length===0&&delete l.required);for(let c of Object.getOwnPropertyNames(l.properties))p.includes(c)&&delete l.properties[c];return this.Create(l)},u)}Partial(e,i={}){return Q.Map(this.Discard(P.Type(e),["$id",o.Transform]),u=>{let p=Object.getOwnPropertyNames(u.properties).reduce((l,c)=>({...l,[c]:this.Optional(u.properties[c])}),{});return this.Object(p,this.Discard(u,["required"]))},i)}Pick(e,i,u={}){let p=ae.Resolve(i);return Q.Map(this.Discard(P.Type(e),["$id",o.Transform]),l=>{I.IsArray(l.required)&&(l.required=l.required.filter(c=>p.includes(c)),l.required.length===0&&delete l.required);for(let c of Object.getOwnPropertyNames(l.properties))p.includes(c)||delete l.properties[c];return this.Create(l)},u)}Record(e,i,u={}){return a.TTemplateLiteral(e)?(()=>{let p=X.ParseExact(e.pattern);return Z.Check(p)?this.Object([...G.Generate(p)].reduce((l,c)=>({...l,[c]:P.Type(i)}),{}),u):this.Create({...u,[o.Kind]:"Record",type:"object",patternProperties:{[e.pattern]:P.Type(i)}})})():a.TUnion(e)?(()=>{let p=Je.Resolve(e);if(a.TUnionLiteral(p)){let l=p.anyOf.reduce((c,T)=>({...c,[T.const]:P.Type(i)}),{});return this.Object(l,{...u,[o.Hint]:"Record"})}else this.Throw("Record key of type union contains non-literal types")})():a.TLiteral(e)?I.IsString(e.const)||I.IsNumber(e.const)?this.Object({[e.const]:P.Type(i)},u):this.Throw("Record key of type literal is not of type string or number"):a.TInteger(e)||a.TNumber(e)?this.Create({...u,[o.Kind]:"Record",type:"object",patternProperties:{[o.PatternNumberExact]:P.Type(i)}}):a.TString(e)?(()=>{let p=I.IsUndefined(e.pattern)?o.PatternStringExact:e.pattern;return this.Create({...u,[o.Kind]:"Record",type:"object",patternProperties:{[p]:P.Type(i)}})})():this.Never()}Recursive(e,i={}){I.IsUndefined(i.$id)&&(i.$id=`T${wn++}`);let u=e({[o.Kind]:"This",$ref:`${i.$id}`});return u.$id=i.$id,this.Create({...i,[o.Hint]:"Recursive",...u})}Ref(e,i={}){return I.IsString(e)?this.Create({...i,[o.Kind]:"Ref",$ref:e}):(I.IsUndefined(e.$id)&&this.Throw("Reference target type must specify an $id"),this.Create({...i,[o.Kind]:"Ref",$ref:e.$id}))}Required(e,i={}){return Q.Map(this.Discard(P.Type(e),["$id",o.Transform]),u=>{let p=Object.getOwnPropertyNames(u.properties).reduce((l,c)=>({...l,[c]:this.Discard(u.properties[c],[o.Optional])}),{});return this.Object(p,u)},i)}Rest(e){return a.TTuple(e)&&!I.IsUndefined(e.items)?P.Rest(e.items):a.TIntersect(e)?P.Rest(e.allOf):a.TUnion(e)?P.Rest(e.anyOf):[]}String(e={}){return this.Create({...e,[o.Kind]:"String",type:"string"})}TemplateLiteral(e,i={}){let u=I.IsString(e)?Se.Create(He.Parse(e)):Se.Create(e);return this.Create({...i,[o.Kind]:"TemplateLiteral",type:"string",pattern:u})}Transform(e){return new je(e)}Tuple(e,i={}){let[u,p,l]=[!1,e.length,e.length],c=P.Rest(e),T=e.length>0?{...i,[o.Kind]:"Tuple",type:"array",items:c,additionalItems:u,minItems:p,maxItems:l}:{...i,[o.Kind]:"Tuple",type:"array",minItems:p,maxItems:l};return this.Create(T)}Uncapitalize(e,i={}){return{...Y.Map(P.Type(e),"Uncapitalize"),...i}}Union(e,i={}){return a.TTemplateLiteral(e)?k.Resolve(e):(()=>{let u=e;if(u.length===0)return this.Never(i);if(u.length===1)return this.Create(P.Type(u[0],i));let p=P.Rest(u);return this.Create({...i,[o.Kind]:"Union",anyOf:p})})()}Unknown(e={}){return this.Create({...e,[o.Kind]:"Unknown"})}Unsafe(e={}){return this.Create({...e,[o.Kind]:e[o.Kind]||"Unsafe"})}Uppercase(e,i={}){return{...Y.Map(P.Type(e),"Uppercase"),...i}}};o.JsonTypeBuilder=le;var Fe=class extends le{AsyncIterator(e,i={}){return this.Create({...i,[o.Kind]:"AsyncIterator",type:"AsyncIterator",items:P.Type(e)})}Awaited(e,i={}){let u=p=>p.length>0?(()=>{let[l,...c]=p;return[this.Awaited(l),...u(c)]})():p;return a.TIntersect(e)?o.Type.Intersect(u(e.allOf)):a.TUnion(e)?o.Type.Union(u(e.anyOf)):a.TPromise(e)?this.Awaited(e.item):P.Type(e,i)}BigInt(e={}){return this.Create({...e,[o.Kind]:"BigInt",type:"bigint"})}ConstructorParameters(e,i={}){return this.Tuple([...e.parameters],{...i})}Constructor(e,i,u){let[p,l]=[P.Rest(e),P.Type(i)];return this.Create({...u,[o.Kind]:"Constructor",type:"Constructor",parameters:p,returns:l})}Date(e={}){return this.Create({...e,[o.Kind]:"Date",type:"Date"})}Function(e,i,u){let[p,l]=[P.Rest(e),P.Type(i)];return this.Create({...u,[o.Kind]:"Function",type:"Function",parameters:p,returns:l})}InstanceType(e,i={}){return P.Type(e.returns,i)}Iterator(e,i={}){return this.Create({...i,[o.Kind]:"Iterator",type:"Iterator",items:P.Type(e)})}Parameters(e,i={}){return this.Tuple(e.parameters,{...i})}Promise(e,i={}){return this.Create({...i,[o.Kind]:"Promise",type:"Promise",item:P.Type(e)})}RegExp(e,i={}){let u=I.IsString(e)?e:e.source;return this.Create({...i,[o.Kind]:"String",type:"string",pattern:u})}RegEx(e,i={}){return this.RegExp(e,i)}ReturnType(e,i={}){return P.Type(e.returns,i)}Symbol(e){return this.Create({...e,[o.Kind]:"Symbol",type:"symbol"})}Undefined(e={}){return this.Create({...e,[o.Kind]:"Undefined",type:"undefined"})}Uint8Array(e={}){return this.Create({...e,[o.Kind]:"Uint8Array",type:"Uint8Array"})}Void(e={}){return this.Create({...e,[o.Kind]:"Void",type:"void"})}};o.JavaScriptTypeBuilder=Fe;o.JsonType=new le;o.Type=new Fe});var ce=jn(he(),1),en=ce.Type.String({pattern:".*\\{languageTag|locale\\}.*\\.json$",examples:["./messages/{locale}.json","./i18n/{locale}.json"],title:"Path to language files",description:"Specify the pathPattern to locate resource files in your repository. It must include `{locale}` and end with `.json`."}),Ln=ce.Type.Array(en,{title:"Paths to language files",description:"Specify multiple pathPatterns to locate resource files in your repository. Each must include `{locale}` and end with `.json`."}),Ce=ce.Type.Object({pathPattern:ce.Type.Union([en,Ln])});var nn=s=>s.map(e=>{switch(e.type){case"Text":return e.value;case"VariableReference":return`{${e.name}}`}}).join("");var tn=s=>{let e={};for(let i of s.variants){if(e[i.languageTag]!==void 0)throw new Error(`The message "${s.id}" has multiple variants for the language tag "${i.languageTag}". The inlang-message-format plugin does not support multiple variants for the same language tag at the moment.`);e[i.languageTag]=nn(i.pattern)}return e};var rn=s=>{let e=/\{([^}]+)\}/g,i,u=0,p=[];for(;(i=e.exec(s))!==null;){let c=i[1],T=s.slice(u,i.index);T.length>0&&p.push({type:"Text",value:T}),p.push({type:"VariableReference",name:c}),u=i.index+i[0].length}let l=s.slice(Math.max(0,u));return l.length>0&&p.push({type:"Text",value:l}),p};var _e=s=>({id:s.key,alias:{},selectors:[],variants:[{languageTag:s.languageTag,match:[],pattern:rn(s.value)}]});var An="plugin.inlang.messageFormat",H={id:An,displayName:"Inlang Message Format",description:"A plugin for the inlang SDK that uses a JSON file per language tag to store translations.",key:"inlang-message-format",settingsSchema:Ce,loadMessages:async({settings:s,nodeishFs:e})=>{await $n({settings:s,nodeishFs:e});let i={};for(let u of s.languageTags)try{let p=await e.readFile(s["plugin.inlang.messageFormat"].pathPattern.replace("{languageTag}",u),{encoding:"utf-8"}),l=JSON.parse(p);for(let c in l)c!=="$schema"&&(i[c]?i[c].variants=[...i[c].variants,..._e({key:c,value:l[c],languageTag:u}).variants]:i[c]=_e({key:c,value:l[c],languageTag:u}))}catch(p){if(p?.code!=="ENOENT")throw p}return Object.values(i)},saveMessages:async({settings:s,nodeishFs:e,messages:i})=>{let u={};for(let p of i){let l=tn(p);for(let[c,T]of Object.entries(l))u[c]===void 0&&(u[c]={}),u[c][p.id]=T}for(let[p,l]of Object.entries(u)){let c=s["plugin.inlang.messageFormat"].pathPattern.replace("{languageTag}",p);await Fn({path:c,nodeishFs:e}),await e.writeFile(s["plugin.inlang.messageFormat"].pathPattern.replace("{languageTag}",p),(T=>JSON.stringify(T,void 0," "))({$schema:"https://inlang.com/schema/inlang-message-format",...l}))}}},Fn=async s=>{try{await s.nodeishFs.mkdir(Cn(s.path),{recursive:!0})}catch{}};function Cn(s){if(s.length===0)return".";let e=s.charCodeAt(0),i=e===47,u=-1,p=!0;for(let l=s.length-1;l>=1;--l)if(e=s.charCodeAt(l),e===47){if(!p){u=l;break}}else p=!1;return u===-1?i?"/":".":i&&u===1?"//":s.slice(0,u)}var $n=async s=>{if(s.settings["plugin.inlang.messageFormat"].filePath!=null)try{let e=await s.nodeishFs.readFile(s.settings["plugin.inlang.messageFormat"].filePath,{encoding:"utf-8"});await H.saveMessages?.({messages:JSON.parse(e).data,nodeishFs:s.nodeishFs,settings:s.settings}),console.log("Migration to v2 of the inlang-message-format plugin was successful. Please delete the old messages.json file and the filePath property in the settings file of the project.")}catch{}};var on=async({settings:s})=>{let e=[],i=s[h]?.pathPattern?Array.isArray(s[h].pathPattern)?s[h].pathPattern:[s[h].pathPattern]:[];for(let u of i)for(let p of s.locales)e.push({locale:p,path:u.replace(/{(locale|languageTag)}/,p)});return e};function sn(s){return s&&s.constructor&&typeof s.constructor.isBuffer=="function"&&s.constructor.isBuffer(s)}function an(s){return s}function We(s,e){e=e||{};let i=e.delimiter||".",u=e.maxDepth,p=e.transformKey||an,l={};function c(T,y,b){b=b||1,Object.keys(T).forEach(function(g){let m=T[g],U=e.safe&&Array.isArray(m),d=Object.prototype.toString.call(m),O=sn(m),v=d==="[object Object]"||d==="[object Array]",N=y?y+i+p(g):p(g);if(!U&&!O&&v&&Object.keys(m).length&&(!e.maxDepth||b0&&(U=T(m.shift()),d=T(m[0]))}O[U]=Ye(s[g],e)}),l}var ln=async({files:s})=>{let e=[],i=[],u=[];for(let p of s){let l=JSON.parse(new TextDecoder().decode(p.content)),c=We(l,{safe:!0});for(let T in c){if(T==="$schema")continue;let y=Kn(T,p.locale,c[T]);i.push(y.message),u.push(...y.variants);let b=e.find(g=>g.id===y.bundle.id);b===void 0?e.push(y.bundle):b.declarations=$e([...b.declarations,...y.bundle.declarations])}}return{bundles:e,messages:i,variants:u}};function Kn(s,e,i){let u=En(s,e,i),p=$e(u.declarations),l=$e(u.selectors),c=l.filter(T=>p.find(y=>y.name===T.name)===void 0);for(let T of c)p.push({type:"input-variable",name:T.name});return{bundle:{id:s,declarations:p},message:{bundleId:s,selectors:l,locale:e},variants:u.variants}}function En(s,e,i){if(typeof i=="string"){let y=un(i);return{variants:[{messageBundleId:s,messageLocale:e,matches:[],pattern:y.pattern}],declarations:y.declarations,selectors:[]}}let u=i[0],p=[],l=(u.selectors??[]).map(y=>({type:"variable-reference",name:y})),c=new Set;for(let y of u.declarations??[])c.add(Mn(y));let T=new Set;for(let[y,b]of Object.entries(u.match)){let g=un(b),m=Bn(y);for(let U of g.declarations){let d=!1;for(let O of c)if(O.name===U.name){d=!0;break}if(d)break;c.add(U)}for(let U of m.selectors)T.add(U);p.push({messageBundleId:s,messageLocale:e,matches:m.matches,pattern:g.pattern})}return{variants:p,declarations:Array.from(c),selectors:$e([...l,...Array.from(T)])}}function un(s){let e=[],i=[],u=s.split(/(\{.*?\})/).filter(p=>p!=="");for(let p of u)if((p.startsWith("{")&&p.endsWith("}"))===!1)e.push({type:"text",value:p});else{let l=p.slice(1,-1);i.push({type:"input-variable",name:l}),e.push({type:"expression",arg:{type:"variable-reference",name:l}})}return{declarations:i,pattern:e}}function Bn(s){let e=s.replace(" ",""),i=[],u=[],p=e.split(",");for(let l of p){let[c,T]=l.split("=");!c||!T||(T==="*"?i.push({type:"catchall-match",key:c}):i.push({type:"literal-match",key:c,value:T}),u.push({type:"variable-reference",name:c}))}return{matches:i,selectors:u}}var $e=s=>[...new Set(s.map(e=>JSON.stringify(e)))].map(e=>JSON.parse(e));function Mn(s){if(s.startsWith("input"))return{type:"input-variable",name:s.slice(6).trim()};if(s.startsWith("local")){let e=s.match(/local (\w+) = (\w+): (\w+)(.*)/),[,i,u,p,l]=e,c=l?.trim().split(/\s+/).map(T=>{let[y,b]=T.split("=");return y&&b?{name:y,value:{type:"literal",value:b}}:null}).filter(Boolean);return{type:"local-variable",name:i.trim(),value:{type:"expression",arg:{type:"variable-reference",name:u.trim()},annotation:p?{type:"function-reference",name:p.trim(),options:c??[]}:void 0}}}throw new Error("Unsupported declaration type")}var pn=async({bundles:s,messages:e,variants:i})=>{let u={};for(let l of e){let c=s.find(y=>y.id===l.bundleId),T=[...i.reduce((y,b)=>(b.messageId===l.id&&y.set(JSON.stringify(b.matches),b),y),new Map).values()];u[l.locale]={...u[l.locale],...Dn(c,l,T)}}let p=[];for(let l in u)p.push({locale:l,content:new TextEncoder().encode(JSON.stringify(Ye({$schema:"https://inlang.com/schema/inlang-message-format",...u[l]}),void 0," ")),name:l+".json"});return p};function Dn(s,e,i){let u=e.bundleId,p=kn(s,e,i);return{[u]:p}}function kn(s,e,i){if(i.length===1&&e.selectors.length===0&&s.declarations.some(p=>p.type!=="input-variable")===!1)return cn(i[0].pattern);let u=[];for(let p of i){if(p.matches.length===0)for(let T of p.pattern)T.type==="expression"&&T.arg.type==="variable-reference"&&p.matches.push({key:T.arg.name,type:"catchall-match"});let l=cn(p.pattern),c=Vn(p.matches);u.push([c,l])}return[{declarations:s.declarations.sort((p,l)=>p.name.localeCompare(l.name)).map(zn).sort(),selectors:e.selectors.map(p=>p.name).sort(),match:Object.fromEntries(u)}]}function cn(s){let e="";for(let i of s)if(i.type==="text")e+=i.value;else if(i.arg.type==="variable-reference")e+=`{${i.arg.name}}`;else throw new Error("Unsupported expression type");return e}function Vn(s){return s.sort((i,u)=>i.key.localeCompare(u.key)).map(i=>i.type==="literal-match"?`${i.key}=${i.value}`:`${i.key}=*`).join(", ")}function zn(s){if(s.type==="input-variable")return`input ${s.name}`;if(s.type==="local-variable"){let e="";if(s.value.arg.type==="variable-reference"?e=`local ${s.name} = ${s.value.arg.name}`:s.value.arg.type==="literal"&&(e=`local ${s.name} = "${s.value.arg.value}"`),s.value.annotation&&(e+=`: ${s.value.annotation.name}`),s.value.annotation?.options)for(let i of s.value?.annotation?.options??[]){if(i.value.type!=="literal")throw new Error("Unsupported option type");e+=` ${i.name}=${i.value.value}`}return e}throw new Error("Unsupported declaration type")}var h="plugin.inlang.messageFormat",Tn={key:h,id:H.id,displayName:H.displayName,description:H.description,loadMessages:H.loadMessages,saveMessages:H.saveMessages,settingsSchema:Ce,toBeImportedFiles:on,importFiles:ln,exportFiles:pn};var It=Tn;export{It as default}; diff --git a/frontend/project.inlang/cache/plugins/ygx0uiahq6uw b/frontend/project.inlang/cache/plugins/ygx0uiahq6uw deleted file mode 100644 index 8ce3dc57..00000000 --- a/frontend/project.inlang/cache/plugins/ygx0uiahq6uw +++ /dev/null @@ -1,16 +0,0 @@ -var Vt=Object.create;var It=Object.defineProperty;var Ht=Object.getOwnPropertyDescriptor;var Xt=Object.getOwnPropertyNames;var Yt=Object.getPrototypeOf,tn=Object.prototype.hasOwnProperty;var nn=(l,c)=>()=>(c||l((c={exports:{}}).exports,c),c.exports);var rn=(l,c,p,u)=>{if(c&&typeof c=="object"||typeof c=="function")for(let f of Xt(c))!tn.call(l,f)&&f!==p&&It(l,f,{get:()=>c[f],enumerable:!(u=Ht(c,f))||u.enumerable});return l};var en=(l,c,p)=>(p=l!=null?Vt(Yt(l)):{},rn(c||!l||!l.__esModule?It(p,"default",{value:l,enumerable:!0}):p,l));var Lt=nn((J,gt)=>{(function(l,c){typeof J=="object"&&typeof gt=="object"?gt.exports=c():typeof define=="function"&&define.amd?define([],c):typeof J=="object"?J.Parsimmon=c():l.Parsimmon=c()})(typeof self<"u"?self:J,function(){return function(l){var c={};function p(u){if(c[u])return c[u].exports;var f=c[u]={i:u,l:!1,exports:{}};return l[u].call(f.exports,f,f.exports,p),f.l=!0,f.exports}return p.m=l,p.c=c,p.d=function(u,f,Z){p.o(u,f)||Object.defineProperty(u,f,{configurable:!1,enumerable:!0,get:Z})},p.r=function(u){Object.defineProperty(u,"__esModule",{value:!0})},p.n=function(u){var f=u&&u.__esModule?function(){return u.default}:function(){return u};return p.d(f,"a",f),f},p.o=function(u,f){return Object.prototype.hasOwnProperty.call(u,f)},p.p="",p(p.s=0)}([function(l,c,p){"use strict";function u(t){if(!(this instanceof u))return new u(t);this._=t}var f=u.prototype;function Z(t,n){for(var r=0;r>7),buf:function(o){var i=I(function(a,s,d,y){return a.concat(d===y.length-1?Buffer.from([s,0]).readUInt16BE(0):y.readUInt16BE(d))},[],o);return Buffer.from(j(function(a){return(a<<1&65535)>>8},i))}(r.buf)}}),r}function dt(){return typeof Buffer<"u"}function C(){if(!dt())throw new Error("Buffer global does not exist; please use webpack if you need to parse Buffers in the browser.")}function ht(t){C();var n=I(function(i,a){return i+a},0,t);if(n%8!=0)throw new Error("The bits ["+t.join(", ")+"] add up to "+n+" which is not an even number of bytes; the total should be divisible by 8");var r,e=n/8,o=(r=function(i){return i>48},I(function(i,a){return i||(r(a)?a:i)},null,t));if(o)throw new Error(o+" bit range requested exceeds 48 bit (6 byte) Number max.");return new u(function(i,a){var s=e+a;return s>i.length?b(a,e.toString()+" bytes"):h(s,I(function(d,y){var v=At(y,d.buf);return{coll:d.coll.concat(v.v),buf:v.buf}},{coll:[],buf:i.slice(a,s)},t).coll)})}function E(t,n){return new u(function(r,e){return C(),e+n>r.length?b(e,n+" bytes for "+t):h(e+n,r.slice(e,e+n))})}function K(t,n){if(typeof(r=n)!="number"||Math.floor(r)!==r||n<0||n>6)throw new Error(t+" requires integer length in range [0, 6].");var r}function V(t){return K("uintBE",t),E("uintBE("+t+")",t).map(function(n){return n.readUIntBE(0,t)})}function H(t){return K("uintLE",t),E("uintLE("+t+")",t).map(function(n){return n.readUIntLE(0,t)})}function X(t){return K("intBE",t),E("intBE("+t+")",t).map(function(n){return n.readIntBE(0,t)})}function Y(t){return K("intLE",t),E("intLE("+t+")",t).map(function(n){return n.readIntLE(0,t)})}function U(t){return t instanceof u}function q(t){return{}.toString.call(t)==="[object Array]"}function W(t){return dt()&&Buffer.isBuffer(t)}function h(t,n){return{status:!0,index:t,value:n,furthest:-1,expected:[]}}function b(t,n){return q(n)||(n=[n]),{status:!1,index:-1,value:null,furthest:t,expected:n}}function w(t,n){if(!n||t.furthest>n.furthest)return t;var r=t.furthest===n.furthest?function(e,o){if(function(){if(u._supportsSet!==void 0)return u._supportsSet;var S=typeof Set<"u";return u._supportsSet=S,S}()&&Array.from){for(var i=new Set(e),a=0;a=0;){if(a in r){e=r[a].line,i===0&&(i=r[a].lineStart);break}(t.charAt(a)===` -`||t.charAt(a)==="\r"&&t.charAt(a+1)!==` -`)&&(o++,i===0&&(i=a+1)),a--}var s=e+o,d=n-i;return r[n]={line:s,lineStart:i},{offset:n,line:s+1,column:d+1}}function A(t){if(!U(t))throw new Error("not a parser: "+t)}function nt(t,n){return typeof t=="string"?t.charAt(n):t[n]}function F(t){if(typeof t!="number")throw new Error("not a number: "+t)}function L(t){if(typeof t!="function")throw new Error("not a function: "+t)}function T(t){if(typeof t!="string")throw new Error("not a string: "+t)}var Ft=2,Nt=3,O=8,Rt=5*O,zt=4*O,vt=" ";function rt(t,n){return new Array(n+1).join(t)}function et(t,n,r){var e=n-t.length;return e<=0?t:rt(r,e)+t}function yt(t,n,r,e){return{from:t-n>0?t-n:0,to:t+r>e?e:t+r}}function Dt(t,n){var r,e,o,i,a,s=n.index,d=s.offset,y=1;if(d===t.length)return"Got the end of the input";if(W(t)){var v=d-d%O,_=d-v,x=yt(v,Rt,zt+O,t.length),S=j(function(m){return j(function(R){return et(R.toString(16),2,"0")},m)},function(m,R){var z=m.length,M=[],D=0;if(z<=R)return[m.slice()];for(var Q=0;Q=4&&(r+=1),y=2,o=j(function(m){return m.length<=4?m.join(" "):m.slice(0,4).join(" ")+" "+m.slice(4).join(" ")},S),(a=(8*(i.to>0?i.to-1:i.to)).toString(16).length)<2&&(a=2)}else{var N=t.split(/\r\n|[\n\r\u2028\u2029]/);r=s.column-1,e=s.line-1,i=yt(e,Ft,Nt,N.length),o=N.slice(i.from,i.to),a=i.to.toString().length}var Kt=e-i.from;return W(t)&&(a=(8*(i.to>0?i.to-1:i.to)).toString(16).length)<2&&(a=2),I(function(m,R,z){var M,D=z===Kt,Q=D?"> ":vt;return M=W(t)?et((8*(i.from+z)).toString(16),a,"0"):et((i.from+z+1).toString(),a," "),[].concat(m,[Q+M+" | "+R],D?[vt+rt(" ",a)+" | "+et("",r," ")+rt("^",y)]:[])},[],o).join(` -`)}function bt(t,n){return[` -`,"-- PARSING FAILED "+rt("-",50),` - -`,Dt(t,n),` - -`,(r=n.expected,r.length===1?`Expected: - -`+r[0]:`Expected one of the following: - -`+r.join(", ")),` -`].join("");var r}function xt(t){return t.flags!==void 0?t.flags:[t.global?"g":"",t.ignoreCase?"i":"",t.multiline?"m":"",t.unicode?"u":"",t.sticky?"y":""].join("")}function ut(){for(var t=[].slice.call(arguments),n=t.length,r=0;r=2?F(n):n=0;var r=function(o){return RegExp("^(?:"+o.source+")",xt(o))}(t),e=""+t;return u(function(o,i){var a=r.exec(o.slice(i));if(a){if(0<=n&&n<=a.length){var s=a[0],d=a[n];return h(i+s.length,d)}return b(i,"valid match group (0 to "+a.length+") in "+e)}return b(i,e)})}function P(t){return u(function(n,r){return h(r,t)})}function it(t){return u(function(n,r){return b(r,t)})}function at(t){if(U(t))return u(function(n,r){var e=t._(n,r);return e.index=r,e.value="",e});if(typeof t=="string")return at($(t));if(t instanceof RegExp)return at(B(t));throw new Error("not a string, regexp, or parser: "+t)}function Et(t){return A(t),u(function(n,r){var e=t._(n,r),o=n.slice(r,e.index);return e.status?b(r,'not "'+o+'"'):h(r,null)})}function ft(t){return L(t),u(function(n,r){var e=nt(n,r);return r=t.length?b(n,"any character/byte"):h(n+1,nt(t,n))}),Ut=u(function(t,n){return h(t.length,t.slice(n))}),pt=u(function(t,n){return n=0}).desc(n)},u.optWhitespace=Jt,u.Parser=u,u.range=function(t,n){return ft(function(r){return t<=r&&r<=n}).desc(t+"-"+n)},u.regex=B,u.regexp=B,u.sepBy=wt,u.sepBy1=st,u.seq=ut,u.seqMap=k,u.seqObj=function(){for(var t,n={},r=0,e=(t=arguments,Array.prototype.slice.call(t)),o=e.length,i=0;i255)throw new Error("Value specified to byte constructor ("+t+"=0x"+t.toString(16)+") is larger in value than a single byte.");var n=(t>15?"0x":"0x0")+t.toString(16);return u(function(r,e){var o=nt(r,e);return o===t?h(e+1,o):b(e,n)})},buffer:function(t){return E("buffer",t).map(function(n){return Buffer.from(n)})},encodedString:function(t,n){return E("string",n).map(function(r){return r.toString(t)})},uintBE:V,uint8BE:V(1),uint16BE:V(2),uint32BE:V(4),uintLE:H,uint8LE:H(1),uint16LE:H(2),uint32LE:H(4),intBE:X,int8BE:X(1),int16BE:X(2),int32BE:X(4),intLE:Y,int8LE:Y(1),int16LE:Y(2),int32LE:Y(4),floatBE:E("floatBE",4).map(function(t){return t.readFloatBE(0)}),floatLE:E("floatLE",4).map(function(t){return t.readFloatLE(0)}),doubleBE:E("doubleBE",8).map(function(t){return t.readDoubleBE(0)}),doubleLE:E("doubleLE",8).map(function(t){return t.readDoubleLE(0)})},l.exports=u}])})});var g=en(Lt(),1),un=()=>g.default.createLanguage({entry:l=>g.default.alt(l.findReference,g.default.any).many().map(c=>c.flatMap(p=>p)).map(c=>c.filter(p=>typeof p=="object").flat().filter(p=>p!==null)),findReference:function(l){return g.default.seq(g.default.regex(/(import \* as m)|(import { m })/),l.findMessage.many())},dotNotation:()=>g.default.seqMap(g.default.string("."),g.default.index,g.default.regex(/\w+/),g.default.index,(l,c,p,u)=>({messageId:p,start:c,end:u})),doubleQuote:()=>g.default.seqMap(g.default.string('"'),g.default.index,g.default.regex(/[\w.]+/),g.default.string('"'),(l,c,p)=>({messageId:p,start:c})),singleQuote:()=>g.default.seqMap(g.default.string("'"),g.default.index,g.default.regex(/[\w.]+/),g.default.string("'"),(l,c,p)=>({messageId:p,start:c})),bracketNotation:l=>g.default.seqMap(g.default.string("["),g.default.alt(l.doubleQuote,l.singleQuote),g.default.string("]"),g.default.index,(c,p,u,f)=>({messageId:p.messageId,start:p.start,end:f})),findMessage:l=>g.default.seqMap(g.default.regex(/.*?(?p===null?null:{messageId:`${p.messageId}`,position:{start:{line:p.start.line,character:p.start.column},end:{line:p.end.line,character:p.end.column+u.length}}})});function kt(l){try{return un().entry.tryParse(l)}catch{return[]}}function ct(l){let c=l.trim().replace(/[^a-zA-Z0-9\s_.]/g,"").replace(/[\s.]+/g,"_");return/^[0-9]/.test(c)&&(c="_"+c),c}var Pt={messageReferenceMatchers:[async l=>kt(l.documentText)],extractMessageOptions:[{callback:l=>{let c=ct(l.bundleId);return{bundleId:c,messageReplacement:`{m.${c}()}`}}},{callback:l=>{let c=ct(l.bundleId);return{bundleId:c,messageReplacement:`m.${c}()`}}}],documentSelectors:[{language:"typescriptreact"},{language:"javascript"},{language:"typescript"},{language:"svelte"},{language:"astro"},{language:"vue"}]};var Mt="plugin.inlang.mFunctionMatcher",qt={id:Mt,displayName:"Inlang M Function Matcher",description:"A plugin for the inlang SDK that uses a JSON file per language tag to store translations.",key:Mt,meta:{"app.inlang.ideExtension":Pt}};var yn=qt;export{yn as default}; diff --git a/frontend/src/app.css b/frontend/src/app.css index 236c256b..8d9a66ab 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -42,36 +42,36 @@ .dark { --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.546 0.245 262.881); - --primary-foreground: oklch(0.379 0.146 265.522); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.488 0.243 264.376); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.546 0.245 262.881); - --sidebar-primary-foreground: oklch(0.379 0.146 265.522); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.488 0.243 264.376); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.98 0.01 262.881); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.488 0.243 264.376); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.379 0.146 265.522); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.488 0.243 264.376); } diff --git a/frontend/src/app.html b/frontend/src/app.html index 35bd8b2c..26eb1a95 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,7 +4,16 @@ Anexo76 - Gestión de Comercio Exterior - + + %sveltekit.head% diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4ae6cdf7..5959b930 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,8 +2,10 @@ * Cliente API para comunicación con el backend */ import { getToken } from './auth'; +import { browser } from '$app/environment'; -const API_BASE_URL = import.meta.env.VITE_API_URL; +// Normalize API_BASE_URL to remove trailing slash +const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); export interface ApiResponse { data?: T; @@ -11,14 +13,139 @@ export interface ApiResponse { status: number; } +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; + /** - * Realiza una petición al API + * Agrega una petición a la cola de espera mientras se refresca el token + */ +function subscribeTokenRefresh(callback: (token: string) => void) { + refreshSubscribers.push(callback); +} + +/** + * Notifica a todas las peticiones en espera que el token se ha refrescado + */ +function onTokenRefreshed(token: string) { + refreshSubscribers.forEach((callback) => callback(token)); + refreshSubscribers = []; +} + +/** + * Intenta refrescar el token usando el refresh token + */ +async function refreshToken(): Promise { + if (!browser) return null; + + let refreshTokenValue = localStorage.getItem('refresh_token'); + + // Si no está en localStorage, intentar obtenerlo de las cookies + if (!refreshTokenValue) { + const getCookie = (name: string): string | null => { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift() || null; + return null; + }; + + refreshTokenValue = getCookie('refresh_token'); + if (refreshTokenValue) { + localStorage.setItem('refresh_token', refreshTokenValue); + } + } + + if (!refreshTokenValue) { + console.error('❌ [API] No hay refresh token disponible'); + return null; + } + + try { + const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refresh_token: refreshTokenValue }), + credentials: 'include' + }); + + if (!response.ok) { + console.error('❌ [API] Refresh token expirado o inválido, status:', response.status); + // Si el refresh token también está expirado, limpiar todo + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + // Limpiar cookies también + document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; + document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; + // Redirigir al login después de un pequeño delay para que el usuario vea el mensaje + setTimeout(() => { + if (browser) { + window.location.href = '/login'; + } + }, 2000); + return null; + } + + const data = await response.json(); + + // Guardar los nuevos tokens + if (data.access_token) { + localStorage.setItem('access_token', data.access_token); + + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token); + } + + // Actualizar también las cookies + const isSecure = window.location.protocol === 'https:'; + const secureFlag = isSecure ? '; Secure' : ''; + + document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`; + if (data.refresh_token) { + document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`; + } + + // Actualizar el authStore si está disponible + try { + const { authStore } = await import('./auth'); + authStore.setToken(data.access_token); + } catch (e) { + // Si no se puede importar authStore, no es crítico + console.warn('⚠️ [API] No se pudo actualizar authStore:', e); + } + + return data.access_token; + } + + return null; + } catch (error) { + console.error('❌ [API] Error refreshing token:', error); + return null; + } +} + +/** + * Realiza una petición al API con manejo automático de refresh token */ async function fetchApi( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, + retryCount = 0 ): Promise> { + // Si ya estamos refrescando el token, esperar + if (isRefreshing && retryCount === 0) { + return new Promise((resolve) => { + subscribeTokenRefresh((newToken) => { + resolve(fetchApi(endpoint, options, 1)); + }); + }); + } + const token = getToken(); + + if (!token && !endpoint.includes('/auth/login')) { + console.warn('⚠️ [API] No hay token disponible para', endpoint); + } const headers: Record = { 'Content-Type': 'application/json', @@ -32,12 +159,76 @@ async function fetchApi( try { const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, - headers + headers, + credentials: 'include' // Importante: envía cookies con cada request }); + // Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token + if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { + isRefreshing = true; + + try { + const newToken = await refreshToken(); + + if (newToken) { + // Token refrescado exitosamente + onTokenRefreshed(newToken); + isRefreshing = false; + // Reintentar la petición original con el nuevo token + return await fetchApi(endpoint, options, 1); + } else { + console.error('❌ [API] No se pudo refrescar el token'); + isRefreshing = false; + // Retornar error 401 para que la capa superior lo maneje + return { + error: 'Sesión expirada. Por favor, inicia sesión nuevamente.', + status: 401 + }; + } + } catch (refreshError) { + console.error('❌ [API] Error al refrescar:', refreshError); + isRefreshing = false; + return { + error: 'Error al refrescar la sesión', + status: 401 + }; + } + } + + // Manejar respuestas sin contenido (204 No Content) + if (response.status === 204) { + return { + data: null as T, + status: response.status + }; + } + const data = await response.json(); if (!response.ok) { + // Manejo especial para errores 422 (validation error) + if (response.status === 422 && data.detail) { + let errorMessage = 'Error de validación: '; + + // FastAPI devuelve errores de validación en data.detail como array + if (Array.isArray(data.detail)) { + const errors = data.detail.map((err: any) => { + const field = err.loc ? err.loc.join('.') : 'campo desconocido'; + return `${field}: ${err.msg}`; + }).join(', '); + errorMessage += errors; + } else if (typeof data.detail === 'string') { + errorMessage = data.detail; + } else { + errorMessage += JSON.stringify(data.detail); + } + + return { + error: errorMessage, + status: response.status + }; + } + return { error: data.detail || 'Error en la petición', status: response.status @@ -49,6 +240,7 @@ async function fetchApi( status: response.status }; } catch (error) { + console.error(`❌ [API] Error de conexión en ${endpoint}:`, error); return { error: 'Error de conexión con el servidor', status: 0 @@ -71,6 +263,12 @@ export const api = { method: 'PUT', body: JSON.stringify(body) }), + + patch: (endpoint: string, body: any) => + fetchApi(endpoint, { + method: 'PATCH', + body: JSON.stringify(body) + }), delete: (endpoint: string) => fetchApi(endpoint, { method: 'DELETE' }), @@ -78,6 +276,8 @@ export const api = { auth: { login: (credentials: { username: string; password: string; tenant_slug: string }) => api.post('/v1/auth/login', credentials), + refresh: (refreshToken: string) => + api.post('/v1/auth/refresh', { refresh_token: refreshToken }), logout: (data: { refresh_token: string }) => api.post('/v1/auth/logout', data), me: () => api.get('/v1/auth/me'), health: () => api.get('/health') diff --git a/frontend/src/lib/api/dashboard/a76/classes.ts b/frontend/src/lib/api/dashboard/a76/classes.ts new file mode 100644 index 00000000..a08c2832 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/classes.ts @@ -0,0 +1,107 @@ +/** + * API para gestión de Classes (Clases A76) + */ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface A76Class { + id: number; + tenant_id: number; + company_id: number; + client_id: number; + class_code: string; + description_es: string | null; + description_en: string | null; + material_key: string | null; + unit_of_measure: string; + fraction: string; + us_fraction: string; + sub_key: string; + physical_review: number; + iva_exempt_fraction: string; + created_at: string; + updated_at: string; +} + +export interface A76ClassCreate { + company_id: number; + client_id: number; + class_code: string; + description_es?: string | null; + description_en?: string | null; + material_key?: string | null; + unit_of_measure: string; + fraction: string; + us_fraction: string; + sub_key: string; + physical_review?: number; + iva_exempt_fraction: string; +} + +export interface A76ClassUpdate { + client_id?: number; + class_code?: string; + description_es?: string | null; + description_en?: string | null; + material_key?: string | null; + unit_of_measure?: string; + fraction?: string; + us_fraction?: string; + sub_key?: string; + physical_review?: number; + iva_exempt_fraction?: string; +} + +export interface A76ClassListResponse { + items: A76Class[]; + total: number; + page: number; + page_size: number; +} + +export interface A76ClassListParams { + company_id: number; + page?: number; + page_size?: number; +} + +/** + * API de Classes + */ +export const classesApi = { + /** + * Obtener lista de classes con paginación + */ + list: (params: A76ClassListParams): Promise> => { + const { company_id, page = 1, page_size = 50 } = params; + return api.get(`/v1/a76/classes/?company_id=${company_id}&page=${page}&page_size=${page_size}`); + }, + + /** + * Obtener un class por ID + */ + get: (id: number, company_id: number): Promise> => { + return api.get(`/v1/a76/classes/${id}?company_id=${company_id}`); + }, + + /** + * Crear un nuevo class + */ + create: (data: A76ClassCreate, company_id: number): Promise> => { + return api.post(`/v1/a76/classes/?company_id=${company_id}`, data); + }, + + /** + * Actualizar un class existente + */ + update: (id: number, data: A76ClassUpdate, company_id: number): Promise> => { + return api.put(`/v1/a76/classes/${id}?company_id=${company_id}`, data); + }, + + /** + * Eliminar un class + */ + delete: (id: number, company_id: number): Promise> => { + return api.delete(`/v1/a76/classes/${id}?company_id=${company_id}`); + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/clients-providers.ts b/frontend/src/lib/api/dashboard/a76/clients-providers.ts new file mode 100644 index 00000000..26d31d69 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/clients-providers.ts @@ -0,0 +1,170 @@ +/** + * API Client para Clientes y Proveedores + * Gestiona las operaciones CRUD para clientes y proveedores + */ +import { api } from '$lib/api'; + +export interface ClientProviderAddress { + id?: number; + street?: string | null; + neighborhood?: string | null; + city?: string | null; + state?: string | null; + country?: string | null; + zip_code?: string | null; + client_id?: number; +} + +export interface ClientProviderPrograms { + id?: number; + program_code?: string | null; + authorization_date?: string | null; + client_id?: number; +} + +export interface ClientProvider { + id: number; + rfc: string; + name: string; + curp?: string | null; + residence_country?: string | null; + domicile_fiscal?: string | null; + foreign_tax_id?: string | null; + client_or_provider?: string | null; + is_active?: boolean; + tenant_id: number; + address?: ClientProviderAddress | null; + programs?: ClientProviderPrograms | null; +} + +export interface ClientProviderBasic { + id: number; + rfc: string; + name: string; + curp?: string | null; + residence_country?: string | null; + domicile_fiscal?: string | null; + foreign_tax_id?: string | null; + client_or_provider?: string | null; + is_active?: boolean; + tenant_id: number; +} + +export interface ClientProviderListResponse { + items: ClientProvider[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateClientProviderData { + rfc: string; + name: string; + curp?: string | null; + residence_country?: string | null; + domicile_fiscal?: string | null; + foreign_tax_id?: string | null; + client_or_provider?: string | null; + is_active?: boolean; + address?: Omit | null; + programs?: Omit | null; +} + +export interface UpdateClientProviderData { + rfc?: string; + name?: string; + curp?: string | null; + residence_country?: string | null; + domicile_fiscal?: string | null; + foreign_tax_id?: string | null; + client_or_provider?: string | null; + is_active?: boolean; + address?: Partial | null; + programs?: Partial | null; +} + +/** + * API para Clientes y Proveedores + */ +export const clientsProvidersApi = { + /** + * Lista todos los clientes y proveedores con paginación + * @param companyId - ID de la compañía + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + * @param filters - Filtros opcionales + */ + list: (companyId: number, page = 1, pageSize = 50, filters?: Record) => { + const params = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString() + }); + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + params.append(key, value.toString()); + } + }); + } + + return api.get( + `/v1/a76/clients-providers?${params.toString()}` + ); + }, + + /** + * Obtiene un cliente/proveedor por ID + * @param id - ID del cliente/proveedor + * @param companyId - ID de la compañía + */ + get: (id: number, companyId: number) => + api.get(`/v1/a76/clients-providers/${id}?company_id=${companyId}`), + + /** + * Obtiene información básica de un cliente/proveedor + * @param id - ID del cliente/proveedor + * @param companyId - ID de la compañía + */ + getBasic: (id: number, companyId: number) => + api.get( + `/v1/a76/clients-providers/${id}/basic?company_id=${companyId}` + ), + + /** + * Crea un nuevo cliente/proveedor + * @param companyId - ID de la compañía + * @param data - Datos del cliente/proveedor a crear + */ + create: (companyId: number, data: CreateClientProviderData) => + api.post(`/v1/a76/clients-providers?company_id=${companyId}`, data), + + /** + * Actualiza un cliente/proveedor existente + * @param id - ID del cliente/proveedor a actualizar + * @param companyId - ID de la compañía + * @param data - Datos a actualizar + */ + update: (id: number, companyId: number, data: UpdateClientProviderData) => + api.patch(`/v1/a76/clients-providers/${id}?company_id=${companyId}`, data), + + /** + * Alterna el estado activo/inactivo de un cliente/proveedor + * @param id - ID del cliente/proveedor + * @param companyId - ID de la compañía + */ + toggleStatus: (id: number, companyId: number) => + api.put( + `/v1/a76/clients-providers/${id}/toggle-status?company_id=${companyId}`, + {} + ), + + /** + * Elimina un cliente/proveedor + * @param id - ID del cliente/proveedor a eliminar + * @param companyId - ID de la compañía + */ + delete: (id: number, companyId: number) => + api.delete(`/v1/a76/clients-providers/${id}?company_id=${companyId}`) +}; diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts new file mode 100644 index 00000000..149b97b5 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -0,0 +1,141 @@ + +import { api } from '$lib/api'; + +export interface CustomsBroker { + type?: string | null; + broker_key: string; + name?: string | null; + address?: string | null; + postal_code?: string | null; + city?: string | null; + state?: string | null; + phone?: string | null; + fax?: string | null; + email?: string | null; + country?: string | null; + tax_id?: string | null; + personal_id?: string | null; + position?: string | null; + license: string; + company?: string | null; + contact?: string | null; + tenant_id: string; + company_id: string; +} + +export interface CustomsBrokerVU { + certificate_path?: string | null; + key_path?: string | null; + access_key?: string | null; + fiel_format?: string | null; + signature_read_path?: string | null; + archive_path?: string | null; + fiel_access_key?: string | null; + web_service_user?: string | null; + web_service_access_key?: string | null; + vu_email?: string | null; + vu_figure_type?: string | null; + xml_files_path?: string | null; + query_tax_id?: string | null; + doda_certificate_path?: string | null; + doda_key_path?: string | null; + doda_web_service_user?: string | null; + doda_web_service_access_key?: string | null; + doda_fiel_access_key?: string | null; + doda_xml_files_path?: string | null; +} + +export interface CustomsBrokerPersonnel { + broker_key: string; + line: number; + name?: string | null; + tax_id?: string | null; + personal_id?: string | null; + position?: string | null; + license?: string | null; + first_name?: string | null; + last_name?: string | null; + middle_name?: string | null; + email?: string | null; +} + +export interface CreateCustomsBrokerData { + type?: string | null; + broker_key: string; + name?: string | null; + address?: string | null; + postal_code?: string | null; + city?: string | null; + state?: string | null; + phone?: string | null; + fax?: string | null; + email?: string | null; + country?: string | null; + tax_id?: string | null; + personal_id?: string | null; + position?: string | null; + license?: string | null; + company?: string | null; + contact?: string | null; + tenant_id: string; + company_id: string; +} + +/** + * API para Agentes Aduanales + */ +export const customsBrokersApi = { + /** + * Lista todos los agentes aduanales + */ + list: (companyId: string) => { + return api.get(`/v1/a76/customs-brokers?company_id=${companyId}`); + }, + + /** + * Obtiene un agente aduanal por su clave + */ + get: (brokerKey: string) => { + return api.get(`/v1/a76/customs-brokers/${brokerKey}`); + }, + + /** + * Crea un nuevo agente aduanal + */ + create: (data: CreateCustomsBrokerData) => { + const companyId = data.company_id; + return api.post(`/v1/a76/customs-brokers?company_id=${companyId}`, data); + }, + + /** + * Elimina un agente aduanal + */ + delete: (brokerKey: string, companyId: string) => { + return api.delete(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`); + }, + + /** + * Actualiza la información de un agente aduanal + */ + update: (brokerKey: string, data: CreateCustomsBrokerData) => { + const companyId = data.company_id; + return api.put(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`, data); + }, + + /** + * Actualiza la información de VU de un agente aduanal + */ + updateVU: (brokerKey: string, data: CustomsBrokerVU) => { + return api.put(`/v1/a76/customs-broker-vu/${brokerKey}`, data); + }, + + /** + * Actualiza el personal de un agente aduanal + */ + updatePersonnel: (brokerKey: string, line: number, data: CustomsBrokerPersonnel) => { + return api.put( + `/v1/a76/customs-broker-personnel/${brokerKey}/${line}`, + data + ); + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/exchange-rate.ts b/frontend/src/lib/api/dashboard/a76/exchange-rate.ts new file mode 100644 index 00000000..81b14e90 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/exchange-rate.ts @@ -0,0 +1,48 @@ +/** + * API Client para Exchange Rate + */ +import { api } from '$lib/api'; + +export interface ExchangeRate { + id: number; + date: string; + value: number; + local_currency: string | null; + foreign_currency: string | null; + company_id: number; + tenant_id: number; +} + +export interface ExchangeRateListResponse { + items: ExchangeRate[]; + total: number; + page: number; + size: number; + pages: number; +} + +/** + * Get exchange rate by date + */ +export async function getExchangeRateByDate(date: string, companyId: number): Promise { + try { + // Get all exchange rates and filter by date on client side + const response = await api.get(`/v1/a76/exchange-rate/?company_id=${companyId}`); + + if (response.data && response.data.items && response.data.items.length > 0) { + // Filter by date and find USD exchange rate + const dateOnly = date.split('T')[0]; // Get YYYY-MM-DD part + const matchingRates = response.data.items.filter(rate => { + const rateDate = rate.date.split('T')[0]; + return rateDate === dateOnly && rate.foreign_currency === 'USD'; + }); + + return matchingRates.length > 0 ? matchingRates[0] : null; + } + + return null; + } catch (error) { + console.error('Error fetching exchange rate:', error); + return null; + } +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/classification-concepts.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/classification-concepts.ts new file mode 100644 index 00000000..f87a5871 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/classification-concepts.ts @@ -0,0 +1,64 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface ClassificationConcept { + id: number; + classification: string; + tenant_id: number; + company_id: number; + created_at: string; + updated_at?: string; +} + +export interface ClassificationConceptCreate { + classification: string; +} + +export interface ClassificationConceptUpdate extends Partial {} + +export interface ClassificationConceptListResponse { + items: ClassificationConcept[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getClassificationConcepts( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/classification-concepts/?${params.toString()}`); +} + +export async function getClassificationConcept(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/classification-concepts/${id}/?company_id=${companyId}`); +} + +export async function createClassificationConcept( + data: ClassificationConceptCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/classification-concepts/?company_id=${companyId}`, data); +} + +export async function updateClassificationConcept( + id: number, + data: ClassificationConceptUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/classification-concepts/${id}/?company_id=${companyId}`, data); +} + +export async function deleteClassificationConcept(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/classification-concepts/${id}/?company_id=${companyId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts new file mode 100644 index 00000000..d31e6aba --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -0,0 +1,115 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Company { + id: number; + tenant_id: number; + name: string | null; + rfc: string | null; + curp?: string | null; + main_activity: string | null; + program: string | null; + program_number: string | null; + prosec: number | null; + prosec_authorization: string | null; + manufacturer_id: string | null; + broker_company: string | null; + responsible: string | null; + responsible_name: string | null; + responsible_last_name: string | null; + responsible_mother_last_name: string | null; + responsible_rfc?: string | null; + position?: string | null; + has_express_line?: boolean; + is_service_company?: boolean; + order_format_type?: string | null; + ctpat_svi?: string | null; + trusted_exporter_number?: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface CompanyCreate { + name?: string | null; + rfc?: string | null; + curp?: string | null; + main_activity?: string | null; + program?: string | null; + program_number?: string | null; + prosec?: number | null; + prosec_authorization?: string | null; + manufacturer_id?: string | null; + broker_company?: string | null; + responsible?: string | null; + responsible_name?: string | null; + responsible_last_name?: string | null; + responsible_mother_last_name?: string | null; + responsible_rfc?: string | null; + position?: string | null; + has_express_line?: boolean; + is_service_company?: boolean; + order_format_type?: string | null; + ctpat_svi?: string | null; + trusted_exporter_number?: string | null; +} + +export interface CompanyUpdate { + name?: string | null; + rfc?: string | null; + curp?: string | null; + main_activity?: string | null; + program?: string | null; + program_number?: string | null; + prosec?: number | null; + prosec_authorization?: string | null; + manufacturer_id?: string | null; + broker_company?: string | null; + responsible?: string | null; + responsible_name?: string | null; + responsible_last_name?: string | null; + responsible_mother_last_name?: string | null; + responsible_rfc?: string | null; + position?: string | null; + has_express_line?: boolean; + is_service_company?: boolean; + order_format_type?: string | null; + ctpat_svi?: string | null; + trusted_exporter_number?: string | null; +} + +export interface CompanyListResponse { + items: Company[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getCompanies( + page = 1, + pageSize = 50, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + return await api.get(`/v1/a76/company?${queryParams.toString()}`); +} + +export async function getCompany(id: number): Promise> { + return await api.get(`/v1/a76/company/${id}`); +} + +export async function createCompany(data: CompanyCreate): Promise> { + return await api.post(`/v1/a76/company`, data); +} + +export async function updateCompany(id: number, data: CompanyUpdate): Promise> { + return await api.put(`/v1/a76/company/${id}`, data); +} + +export async function deleteCompany(id: number): Promise> { + return await api.delete(`/v1/a76/company/${id}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/concepts.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/concepts.ts new file mode 100644 index 00000000..86a58061 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/concepts.ts @@ -0,0 +1,81 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Concept { + id: number; + code: string; + description?: string; + description_en?: string; + detailed_description?: string; + priority?: number; + priority_ame?: number; + first_total?: boolean; + type?: string; + is_printed?: boolean; + section?: number; + classification?: string; + tenant_id: string; + company_id?: string; + created_at: string; + updated_at?: string; +} + +export interface ConceptCreate { + code: string; + description?: string; + description_en?: string; + detailed_description?: string; + priority?: number; + priority_ame?: number; + first_total?: boolean; + type?: string; + is_printed?: boolean; + section?: number; + classification?: string; +} + +export interface ConceptUpdate extends Partial {} + +export interface ConceptListResponse { + items: Concept[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getConcepts( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {}, +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/concepts?${params.toString()}`); +} + + +export async function getConcept(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/concepts/${id}?company_id=${companyId}`); +} + + +export async function createConcept(data: ConceptCreate, companyId: number): Promise> { + return await api.post(`/v1/a76/concepts?company_id=${companyId}`, data); +} + + +export async function updateConcept(id: number, data: ConceptUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/concepts/${id}?company_id=${companyId}`, data); +} + + +export async function deleteConcept(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/concepts/${id}?company_id=${companyId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/customs-broker-concepts.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/customs-broker-concepts.ts new file mode 100644 index 00000000..e9cf0b07 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/customs-broker-concepts.ts @@ -0,0 +1,75 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface CustomsBrokerConcept { + id: number; + code: string; + description?: string; + description_en?: string; + detailed_description?: string; + priority?: number; + first_total?: boolean; + type?: string; + is_printed?: boolean; + section?: number; + classification?: string; + tenant_id: string; + company_id?: string; + created_at: string; + updated_at?: string; +} + +export interface CustomsBrokerConceptCreate { + code: string; + description?: string; + description_en?: string; + detailed_description?: string; + priority?: number; + first_total?: boolean; + type?: string; + is_printed?: boolean; + section?: number; + classification?: string; +} + +export interface CustomsBrokerConceptUpdate extends Partial {} + +export interface CustomsBrokerConceptListResponse { + items: CustomsBrokerConcept[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getCustomsBrokerConcepts( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {}, +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/customs-broker-concepts?${params.toString()}`); +} + +export async function getCustomsBrokerConcept(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/customs-broker-concepts/${id}?company_id=${companyId}`); +} + +export async function createCustomsBrokerConcept(data: CustomsBrokerConceptCreate, companyId: number): Promise> { + return await api.post(`/v1/a76/customs-broker-concepts?company_id=${companyId}`, data); +} + +export async function updateCustomsBrokerConcept(id: number, data: CustomsBrokerConceptUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/customs-broker-concepts/${id}?company_id=${companyId}`, data); +} + +export async function deleteCustomsBrokerConcept(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/customs-broker-concepts/${id}?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/customs_broker_concepts.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/customs_broker_concepts.ts new file mode 100644 index 00000000..e9cf0b07 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/customs_broker_concepts.ts @@ -0,0 +1,75 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface CustomsBrokerConcept { + id: number; + code: string; + description?: string; + description_en?: string; + detailed_description?: string; + priority?: number; + first_total?: boolean; + type?: string; + is_printed?: boolean; + section?: number; + classification?: string; + tenant_id: string; + company_id?: string; + created_at: string; + updated_at?: string; +} + +export interface CustomsBrokerConceptCreate { + code: string; + description?: string; + description_en?: string; + detailed_description?: string; + priority?: number; + first_total?: boolean; + type?: string; + is_printed?: boolean; + section?: number; + classification?: string; +} + +export interface CustomsBrokerConceptUpdate extends Partial {} + +export interface CustomsBrokerConceptListResponse { + items: CustomsBrokerConcept[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getCustomsBrokerConcepts( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {}, +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/customs-broker-concepts?${params.toString()}`); +} + +export async function getCustomsBrokerConcept(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/customs-broker-concepts/${id}?company_id=${companyId}`); +} + +export async function createCustomsBrokerConcept(data: CustomsBrokerConceptCreate, companyId: number): Promise> { + return await api.post(`/v1/a76/customs-broker-concepts?company_id=${companyId}`, data); +} + +export async function updateCustomsBrokerConcept(id: number, data: CustomsBrokerConceptUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/customs-broker-concepts/${id}?company_id=${companyId}`, data); +} + +export async function deleteCustomsBrokerConcept(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/customs-broker-concepts/${id}?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts new file mode 100644 index 00000000..45d57304 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts @@ -0,0 +1,186 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + + +export interface DodaContainerSeal { + id: number; + doda_sys_id: number; + seal_line: number; + seal_value?: string; +} + +export interface DodaContainerSealCreate { + seal_value?: string; +} + +export interface DodaContainer { + id: number; + doda_sys_id: number; + container_line: number; + container_value?: string; + seals?: string; + seals_detail?: DodaContainerSeal[]; +} + +export interface DodaContainerCreate { + container_value?: string; + seals?: string; + seals_detail?: DodaContainerSealCreate[]; +} + + +export interface DodaAmericanPedimento { + id: number; + doda_sys_id: number; + american_pedimento_line: number; + american_pedimento_type?: string; + american_pedimento_value?: string; +} + +export interface DodaAmericanPedimentoCreate { + american_pedimento_type?: string; + american_pedimento_value?: string; +} + +export interface DodaPedimento { + id: number; + doda_sys_id: number; + pedimento_line: number; + authorization_patent?: string; + document?: string; + shipment?: string; + cove?: string; + umc?: string; + effective_amount_usd?: number; + difference_amount_usd?: number; + dta_niu?: string; + article_7?: boolean; + pedimento_sys_id?: number; + invoice_line?: number; + part_ii_line?: number; + pedimento_type?: string; + zero_packaging_validation?: boolean; +} + +export interface DodaPedimentoCreate { + authorization_patent?: string; + document?: string; + shipment?: string; + + effective_amount_usd?: number; +} + + +export interface Doda { + id: number; + + integration_number?: string; + doda_date?: number; + doda_time?: number; + dispatch_customs?: string; + customs_sections?: string; + patent?: string; + pedimentos?: string; + caat?: string; + transport_identification?: string; + fast_id?: string; + operation_type?: string; + status?: string; + + + containers?: DodaContainer[]; + american_pedimentos?: DodaAmericanPedimento[]; + pedimentos_detail?: DodaPedimento[]; + + + tenant_id?: string; + created_at?: string; + updated_at?: string; +} + + +export interface DodaCreate { + integration_number?: string; + doda_date?: number; + doda_time?: number; + dispatch_customs?: string; + customs_sections?: string; + patent?: string; + pedimentos?: string; + caat?: string; + transport_identification?: string; + fast_id?: string; + operation_type?: string; + selected?: boolean; + user_selected?: string; + last_user?: string; + responsible?: string; + carrier?: string; + shipments?: string; + pedimento_type?: string; + original_chain?: string; + serial_number?: string; + electronic_signature?: string; + transaction_number?: string; + status?: string; + linq_sat_qr?: string; + sat_certificate?: string; + sat_digital_seal?: string; + xml_doda_sent_path?: string; + xml_doda_response_path?: string; + sat_original_chain?: string; + customs_clearance?: number; + unique_badge_number?: string; +} + +export interface DodaUpdate extends Partial {} + +export interface DodaListResponse { + items: Doda[]; + total: number; + page: number; + page_size: number; + pages: number; +} + + +export async function getDodas( + page: number = 1, + pageSize: number = 50, + filters: Record = {}, + companyId?: number +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + if (companyId) { + params.append('company_id', companyId.toString()); + } + const response = await api.get(`/v1/a76/doda?${params.toString()}`); + return response.data; +} + +export async function getDoda(id: number, companyId?: number): Promise { + const params = new URLSearchParams(); + if (companyId) { + params.append('company_id', companyId.toString()); + } + const response = await api.get(`/v1/a76/doda/${id}?${params.toString()}`); + return response.data; +} + +export async function createDoda(data: DodaCreate, companyId: number): Promise { + const response = await api.post(`/v1/a76/doda?company_id=${companyId}`, data); + return response.data; +} + +export async function updateDoda(id: number, data: DodaUpdate, companyId: number): Promise { + const response = await api.patch(`/v1/a76/doda/${id}?company_id=${companyId}`, data); + return response.data; +} + +export async function deleteDoda(id: number, companyId: number): Promise { + await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/electronic-notices.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/electronic-notices.ts new file mode 100644 index 00000000..591ab614 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/electronic-notices.ts @@ -0,0 +1,90 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface ElectronicNotice { + id: number; + // Campos del Modelo Python + notice_number?: string; + year?: string; + patent?: string; + pedimento?: string; + file_sent?: string; + file_response?: string; + status?: string; + invoice?: string; + validation_acknowledgment?: string; + fea?: string; + certificate_number?: string; + + // Mixins + tenant_id: number; + company_id: number; + created_at: string; + updated_at?: string; +} + +export interface ElectronicNoticeCreate { + notice_number?: string; + year?: string; + patent?: string; + pedimento?: string; + file_sent?: string; + file_response?: string; + status?: string; + invoice?: string; + validation_acknowledgment?: string; + fea?: string; + certificate_number?: string; +} + +export interface ElectronicNoticeUpdate extends Partial {} + +export interface ElectronicNoticeListResponse { + items: ElectronicNotice[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getElectronicNotices( + page: number = 1, + pageSize: number = 50, + filters: Record = {}, + companyId?: number +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + if (companyId) { + params.append('company_id', companyId.toString()); + } + + const response = await api.get(`/v1/a76/electronic-notices/?${params.toString()}`); + return response.data; +} + +export async function getElectronicNotice(id: number, companyId?: number): Promise { + const params = new URLSearchParams(); + if (companyId) { + params.append('company_id', companyId.toString()); + } + const response = await api.get(`/v1/a76/electronic-notices/${id}?${params.toString()}`); + return response.data; +} + +export async function createElectronicNotice(data: ElectronicNoticeCreate, companyId: number): Promise { + const response = await api.post(`/v1/a76/electronic-notices/?company_id=${companyId}`, data); + return response.data; +} + +export async function updateElectronicNotice(id: number, data: ElectronicNoticeUpdate, companyId: number): Promise { + const response = await api.put(`/v1/a76/electronic-notices/${id}?company_id=${companyId}`, data); + return response.data; +} + +export async function deleteElectronicNotice(id: number, companyId: number): Promise { + await api.delete(`/v1/a76/electronic-notices/${id}?company_id=${companyId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts new file mode 100644 index 00000000..0b6c70c3 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts @@ -0,0 +1,72 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Equivalency { + id: number; + fraccion_mex: string; + fraccion_us: string; + description?: string; + tenant_id: number; + company_id: number; + created_at: string; + updated_at?: string; +} + +export interface EquivalencyCreate { + fraccion_mex: string; + fraccion_us: string; + description?: string; +} + +export interface EquivalencyUpdate { + fraccion_mex?: string; + fraccion_us?: string; + description?: string; +} + +export interface EquivalencyListResponse { + items: Equivalency[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getEquivalencies( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/equivalencies/?${params.toString()}`); +} + +export async function getEquivalency(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`); +} + +export async function createEquivalency( + data: EquivalencyCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/equivalencies/?company_id=${companyId}`, data); +} + +export async function updateEquivalency( + id: number, + data: EquivalencyUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`, data); +} + +export async function deleteEquivalency(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/error-catalogs.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/error-catalogs.ts new file mode 100644 index 00000000..f48c9139 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/error-catalogs.ts @@ -0,0 +1,145 @@ +import { api } from '$lib/api'; + +// ========================================== +// ERROR CLASSIFICATION +// ========================================== + +export interface ErrorClassification { + id: number; + code: string; + level?: string; + errors?: ErrorCatalog[]; + tenant_id?: string; + created_at?: string; + updated_at?: string; +} + +export interface ErrorClassificationCreate { + code: string; + level?: string; +} + +export interface ErrorClassificationUpdate { + level?: string; +} + +export interface ErrorClassificationListResponse { + items: ErrorClassification[]; + total: number; + page: number; + page_size: number; + pages: number; +} + + +export interface ErrorCatalog { + id: number; + code: string; + description?: string; + classification_id?: number; + classification?: ErrorClassification; + tenant_id?: string; + created_at?: string; + updated_at?: string; +} + +export interface ErrorCatalogCreate { + code: string; + description?: string; + classification_id?: number; +} + +export interface ErrorCatalogUpdate { + description?: string; + classification_id?: number; +} + +export interface ErrorCatalogListResponse { + items: ErrorCatalog[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getErrorClassifications( + companyId: number, + page: number = 1, + pageSize: number = 50, + filters: Record = {} +): Promise { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + const response = await api.get(`/v1/a76/error-catalogs/classifications/?${params.toString()}`); + return response.data; +} + +export async function getErrorClassification(id: number, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.get(`/v1/a76/error-catalogs/classifications/${id}?${params.toString()}`); + return response.data; +} + +export async function createErrorClassification(data: ErrorClassificationCreate, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post(`/v1/a76/error-catalogs/classifications/?${params.toString()}`, data); + return response.data; +} + +export async function updateErrorClassification(id: number, data: ErrorClassificationUpdate, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.put(`/v1/a76/error-catalogs/classifications/${id}?${params.toString()}`, data); + return response.data; +} + +export async function deleteErrorClassification(id: number, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + await api.delete(`/v1/a76/error-catalogs/classifications/${id}?${params.toString()}`); +} + +// --- Catalogs --- + +export async function getErrorCatalogs( + companyId: number, + page: number = 1, + pageSize: number = 50, + filters: Record = {} +): Promise { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + const response = await api.get(`/v1/a76/error-catalogs/?${params.toString()}`); + return response.data; +} + +export async function getErrorCatalog(id: number, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.get(`/v1/a76/error-catalogs/${id}?${params.toString()}`); + return response.data; +} + +export async function createErrorCatalog(data: ErrorCatalogCreate, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post(`/v1/a76/error-catalogs/?${params.toString()}`, data); + return response.data; +} + +export async function updateErrorCatalog(id: number, data: ErrorCatalogUpdate, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.put(`/v1/a76/error-catalogs/${id}?${params.toString()}`, data); + return response.data; +} + +export async function deleteErrorCatalog(id: number, companyId: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + await api.delete(`/v1/a76/error-catalogs/${id}?${params.toString()}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts new file mode 100644 index 00000000..b8b94a40 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts @@ -0,0 +1,91 @@ +import type { PaginatedResponse } from '$lib/types'; +import { api } from '$lib/api'; + +export interface ExchangeRate { + id: number; + date: string; + value: number | null; + local_currency: string | null; + foreign_currency: string | null; + company_id: number; + tenant_id: number; +} + +export interface ExchangeRateCreate { + date: string; + value?: number | null; + local_currency?: string | null; + foreign_currency?: string | null; +} + +export interface ExchangeRateUpdate { + date?: string; + value?: number | null; + local_currency?: string | null; + foreign_currency?: string | null; +} + +export interface ExchangeRateListResponse extends PaginatedResponse { + items: ExchangeRate[]; +} + +export interface ExchangeRateFilters { + date?: string; + local_currency?: string; + foreign_currency?: string; + page?: number; + page_size?: number; +} + +export async function getExchangeRates( + companyId: number, + filters?: ExchangeRateFilters +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + + if (filters) { + if (filters.date) params.append('date', filters.date); + if (filters.local_currency) params.append('local_currency', filters.local_currency); + if (filters.foreign_currency) params.append('foreign_currency', filters.foreign_currency); + if (filters.page) params.append('page', filters.page.toString()); + if (filters.page_size) params.append('page_size', filters.page_size.toString()); + } + + return api.get(`/v1/a76/exchange-rate/?${params.toString()}`); +} + +export async function getExchangeRate( + exchangeRateId: number, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.get(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`); +} + +export async function createExchangeRate( + data: ExchangeRateCreate, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.post(`/v1/a76/exchange-rate/?${params.toString()}`, data); +} + +export async function updateExchangeRate( + exchangeRateId: number, + data: ExchangeRateUpdate, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.put( + `/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`, + data + ); +} + +export async function deleteExchangeRate( + exchangeRateId: number, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.delete(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts new file mode 100644 index 00000000..98660069 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/identifiers.ts @@ -0,0 +1,74 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Identifier { + id: number; + code: string; + description: string | null; + level: string | null; + complement: string | null; + company_id: number; + tenant_id: number; + created_at: string | null; + updated_at: string | null; +} + +export interface IdentifierCreate { + code: string; + description?: string | null; + level?: string | null; + complement?: string | null; +} + +export interface IdentifierUpdate { + code?: string; + description?: string | null; + level?: string | null; + complement?: string | null; +} + +export interface IdentifierListResponse { + items: Identifier[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getIdentifiers( + page = 1, + pageSize = 50, + companyId: number, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/identifiers/?${queryParams.toString()}`); +} + +export async function createIdentifier( + data: IdentifierCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/identifiers/?company_id=${companyId}`, data); +} + +export async function updateIdentifier( + id: number, + data: IdentifierUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/identifiers/${id}/?company_id=${companyId}`, data); +} + +export async function deleteIdentifier( + id: number, + companyId: number +): Promise> { + return await api.delete(`/v1/a76/identifiers/${id}/?company_id=${companyId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/index.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/index.ts new file mode 100644 index 00000000..257406a5 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/index.ts @@ -0,0 +1,21 @@ +/** + * Índice de exportación para catálogos generales A76 + */ + +// Unit Measures - Main +export * from './unit-measures'; + +// Unit Measures - Customs (Mexican) +export * from './um-customs-mex'; + +// Unit Measures - American +export * from './um-customs-ame'; + +// Unit Measures - ACE +export * from './um-ace'; + +// Unit Measures - OMA +export * from './um-oma'; + +// Locations (from ports) +export * from './locations'; diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/inpc.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/inpc.ts new file mode 100644 index 00000000..4720519b --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/inpc.ts @@ -0,0 +1,68 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface INPC { + id: number; + year: string; + month: string; + value?: number; + tenant_id: number; + company_id: number; + created_at: string; + updated_at?: string; +} + +export interface INPCCreate { + year: string; + month: string; + value?: number; +} + +export interface INPCUpdate extends Partial {} + +export interface INPCListResponse { + items: INPC[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getINPCs( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/inpc/?${params.toString()}`); +} + +export async function getINPC(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/inpc/${id}/?company_id=${companyId}`); +} + +export async function createINPC( + data: INPCCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/inpc/?company_id=${companyId}`, data); +} + +export async function updateINPC( + id: number, + data: INPCUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/inpc/${id}/?company_id=${companyId}`, data); +} + +export async function deleteINPC(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/inpc/${id}/?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/legends.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/legends.ts new file mode 100644 index 00000000..1e81cb63 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/legends.ts @@ -0,0 +1,65 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Legend { + id: number; + code: number; + description?: string; + tenant_id: number; + company_id: number; + created_at: string; + updated_at?: string; +} + +export interface LegendCreate { + code: number; + description?: string; +} + +export interface LegendUpdate extends Partial {} + +export interface LegendListResponse { + items: Legend[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getLegends( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + return await api.get(`/v1/a76/legends/?${params.toString()}`); +} + +export async function getLegend(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/legends/${id}/?company_id=${companyId}`); +} + +export async function createLegend( + data: LegendCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/legends/?company_id=${companyId}`, data); +} + +export async function updateLegend( + id: number, + data: LegendUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/legends/${id}/?company_id=${companyId}`, data); +} + +export async function deleteLegend(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/legends/${id}/?company_id=${companyId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/locations.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/locations.ts new file mode 100644 index 00000000..e07612b3 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/locations.ts @@ -0,0 +1,90 @@ +import type { PaginatedResponse } from '$lib/types'; +import { api } from '$lib/api'; + +export interface Location { + id: number; + location_code: string; + location_description: string | null; + company_id: number; + tenant_id: number; +} + +export interface LocationCreate { + location_code: string; + location_description?: string | null; +} + +export interface LocationUpdate { + location_description?: string | null; +} + +export interface LocationListResponse extends PaginatedResponse { + items: Location[]; +} + +export interface LocationFilters { + location_code?: string; + location_description?: string; + page?: number; + page_size?: number; +} + +export async function getLocations( + companyId: number, + filters?: LocationFilters +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + + if (filters) { + if (filters.location_code) params.append('location_code', filters.location_code); + if (filters.location_description) params.append('location_description', filters.location_description); + if (filters.page) params.append('page', filters.page.toString()); + if (filters.page_size) params.append('page_size', filters.page_size.toString()); + } + + return api.get(`/v1/a76/ports/?${params.toString()}`); +} + +export async function getLocation( + locationId: number, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.get(`/v1/a76/ports/${locationId}?${params.toString()}`); +} + +export async function createLocation( + data: LocationCreate, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.post(`/v1/a76/ports/?${params.toString()}`, { + port_code: data.location_code, + location_code: data.location_code, + description: null, + location_description: data.location_description || null, + port_type: 'ENTRY' + }); +} + +export async function updateLocation( + locationId: number, + data: LocationUpdate, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.put( + `/v1/a76/ports/${locationId}?${params.toString()}`, + { + location_description: data.location_description + } + ); +} + +export async function deleteLocation( + locationId: number, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.delete(`/v1/a76/ports/${locationId}?${params.toString()}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts new file mode 100644 index 00000000..a624e361 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts @@ -0,0 +1,78 @@ +import { api } from '$lib/api'; +import type { PaginatedResponse } from '$lib/types'; + +export interface MultiCurrencyType { + id: number; + currency_type_code: string; + country_key: string | null; + conversion_factor: number | null; + publication_date: number; + company_id: number; + tenant_id: number; +} + +export interface MultiCurrencyTypeCreate { + currency_type_code: string; + country_key?: string | null; + conversion_factor?: number | null; + publication_date: number; +} + +export interface MultiCurrencyTypeUpdate { + currency_type_code?: string; + country_key?: string | null; + conversion_factor?: number | null; + publication_date?: number; +} + +export interface MultiCurrencyTypeListResponse extends PaginatedResponse { + items: MultiCurrencyType[]; +} + +export async function getMultiCurrencyTypes( + companyId: number, + page?: number, + pageSize?: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + if (page) params.append('page', page.toString()); + if (pageSize) params.append('page_size', pageSize.toString()); + + return api.get(`/v1/a76/multi-currency-types/?${params.toString()}`); +} + +export async function getMultiCurrencyType( + multiCurrencyTypeId: number, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.get(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`); +} + +export async function createMultiCurrencyType( + data: MultiCurrencyTypeCreate, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.post(`/v1/a76/multi-currency-types/?${params.toString()}`, data); +} + +export async function updateMultiCurrencyType( + multiCurrencyTypeId: number, + data: MultiCurrencyTypeUpdate, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.put( + `/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`, + data + ); +} + +export async function deleteMultiCurrencyType( + multiCurrencyTypeId: number, + companyId: number +): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.delete(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts new file mode 100644 index 00000000..13a9783a --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts @@ -0,0 +1,81 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Package { + id: number; + tenant_id: number; + company_id: number; + key: string; + description_es: string | null; + description_en: string | null; + weight_unit: number | null; + plurals: string | null; + plural_in: string | null; + code_ace: string | null; + code_aamex: string | null; + created_at: string; + updated_at?: string; +} + +export interface PackageCreate { + key: string; + description_es?: string | null; + description_en?: string | null; + weight_unit?: number | null; + plurals?: string | null; + plural_in?: string | null; + code_ace?: string | null; + code_aamex?: string | null; +} + +export interface PackageUpdate extends Partial {} + +export interface PackageListResponse { + items: Package[]; + total: number; + page: number; + page_size: number; + pages: number; +} + + +export async function getPackages( + page: number = 1, + pageSize: number = 50, + companyId: number, // Obligatorio por el Mixin + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + return await api.get(`/v1/a76/packages?${params.toString()}`); +} + + +export async function getPackage(id: number, companyId: number): Promise> { + return await api.get(`/v1/a76/packages/${id}?company_id=${companyId}`); +} + +export async function createPackage( + data: PackageCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/packages?company_id=${companyId}`, data); +} + + +export async function updatePackage( + id: number, + data: PackageUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/packages/${id}?company_id=${companyId}`, data); +} + +export async function deletePackage(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/packages/${id}?company_id=${companyId}`); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/ports.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/ports.ts new file mode 100644 index 00000000..0234bcf0 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/ports.ts @@ -0,0 +1,74 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export enum PortType { + ENTRY = 'ENTRY', + EXIT = 'EXIT', + BOTH = 'BOTH' +} + +export interface Port { + id: number; + port_code: string; + description: string | null; + location_code: string; + location_description: string | null; + port_type: PortType; + created_at: string | null; + updated_at: string | null; +} + +export interface PortCreate { + port_code: string; + description?: string | null; + location_code: string; + location_description?: string | null; + port_type?: PortType; +} + +export interface PortUpdate { + port_code?: string; + description?: string | null; + location_code?: string; + location_description?: string | null; + port_type?: PortType; +} + +export interface PortListResponse { + items: Port[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getPorts( + page = 1, + pageSize = 50, + filters: Record = {}, + companyId?: number +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + + if (companyId) { + queryParams.append('company_id', companyId.toString()); + } + + return await api.get(`/v1/a76/ports?${queryParams.toString()}`); +} + +export async function createPort(data: PortCreate, companyId: number): Promise> { + return await api.post(`/v1/a76/ports?company_id=${companyId}`, data); +} + +export async function updatePort(id: number, data: PortUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/ports/${id}?company_id=${companyId}`, data); +} + +export async function deletePort(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/ports/${id}?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/prevalidators.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/prevalidators.ts new file mode 100644 index 00000000..1041bd8b --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/prevalidators.ts @@ -0,0 +1,74 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Prevalidator { + id: number; + code: string; + description?: string; + customs_prevalidator?: string; + patent_prevalidator?: string; + + tenant_id: number; + company_id: number; + created_at: string; + updated_at?: string; +} + +export interface PrevalidatorCreate { + code: string; + description?: string; + customs_prevalidator?: string; + patent_prevalidator?: string; +} + +export interface PrevalidatorUpdate extends Partial {} + +export interface PrevalidatorListResponse { + items: Prevalidator[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getPrevalidators( + page: number = 1, + pageSize: number = 50, + filters: Record = {}, + companyId?: number +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + if (companyId) { + params.append('company_id', companyId.toString()); + } + + const response = await api.get(`/v1/a76/prevalidators/?${params.toString()}`); + return response.data; +} + +export async function getPrevalidator(id: number, companyId?: number): Promise { + const params = new URLSearchParams(); + if (companyId) { + params.append('company_id', companyId.toString()); + } + const response = await api.get(`/v1/a76/prevalidators/${id}?${params.toString()}`); + return response.data; +} + +export async function createPrevalidator(data: PrevalidatorCreate, companyId: number): Promise { + const response = await api.post(`/v1/a76/prevalidators/?company_id=${companyId}`, data); + return response.data; +} + +export async function updatePrevalidator(id: number, data: PrevalidatorUpdate, companyId: number): Promise { + const response = await api.put(`/v1/a76/prevalidators/${id}?company_id=${companyId}`, data); + return response.data; +} + +export async function deletePrevalidator(id: number, companyId: number): Promise { + await api.delete(`/v1/a76/prevalidators/${id}?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/seal.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/seal.ts new file mode 100644 index 00000000..e0d848d9 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/seal.ts @@ -0,0 +1,95 @@ +/** + * API client for Seal operations + */ +import { api } from '$lib/api'; + +export interface Seal { + id: number; + seal: string; + company_id: number; + tenant_id: number; +} + +export interface SealListResponse { + items: Seal[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export interface SealCreateRequest { + seal: string; +} + +export interface SealUpdateRequest { + seal?: string; +} + +/** + * Get all seals with pagination and filters + */ +export async function getSeals( + companyId: number, + filters?: { + page?: number; + page_size?: number; + seal?: string; + } +): Promise<{ data: SealListResponse; status: number }> { + const params = new URLSearchParams(); + params.append('company_id', companyId.toString()); + + if (filters?.page) params.append('page', filters.page.toString()); + if (filters?.page_size) params.append('page_size', filters.page_size.toString()); + if (filters?.seal) params.append('seal', filters.seal); + + const response = await api.get(`/v1/a76/seals?${params.toString()}`); + + return response; +} + +/** + * Get a single seal by ID + */ +export async function getSeal( + id: number, + companyId: number +): Promise<{ data: Seal; status: number }> { + const response = await api.get(`/v1/a76/seals/${id}?company_id=${companyId}`); + return response; +} + +/** + * Create a new seal + */ +export async function createSeal( + data: SealCreateRequest, + companyId: number +): Promise<{ data: Seal; status: number }> { + const response = await api.post(`/v1/a76/seals?company_id=${companyId}`, data); + return response; +} + +/** + * Update an existing seal + */ +export async function updateSeal( + id: number, + data: SealUpdateRequest, + companyId: number +): Promise<{ data: Seal; status: number }> { + const response = await api.put(`/v1/a76/seals/${id}?company_id=${companyId}`, data); + return response; +} + +/** + * Delete a seal + */ +export async function deleteSeal( + id: number, + companyId: number +): Promise<{ data: any; status: number }> { + const response = await api.delete(`/v1/a76/seals/${id}?company_id=${companyId}`); + return response; +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/signatures.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/signatures.ts new file mode 100644 index 00000000..3f2bfc7d --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/signatures.ts @@ -0,0 +1,84 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Signature { + id: number; + + code: string; + signature: string | null; + photo_path: string | null; + + + tenant_id: number; + company_id: number; + created_at: string; + updated_at?: string; +} + +export interface SignatureCreate { + code: string; + signature?: string | null; + photo_path?: string | null; + +} + +export interface SignatureUpdate extends Partial {} + +export interface SignatureListResponse { + items: Signature[]; + total: number; + page: number; + page_size: number; + pages: number; +} + + +export async function getSignatures( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + + const response = await api.get(`/v1/a76/signatures/?${params.toString()}`); + return response.data; +} + + +export async function getSignature(id: number, companyId: number): Promise { + const response = await api.get(`/v1/a76/signatures/${id}/?company_id=${companyId}`); + if (response.error) throw new Error(response.error); + return response.data; +} + + +export async function createSignature( + data: SignatureCreate, + companyId: number +): Promise { + const response = await api.post(`/v1/a76/signatures/?company_id=${companyId}`, data); + if (response.error) throw new Error(response.error); + return response.data; +} + +export async function updateSignature( + id: number, + data: SignatureUpdate, + companyId: number +): Promise { + const response = await api.put(`/v1/a76/signatures/${id}/?company_id=${companyId}`, data); + if (response.error) throw new Error(response.error); + return response.data; +} + +export async function deleteSignature(id: number, companyId: number): Promise { + const response = await api.delete(`/v1/a76/signatures/${id}/?company_id=${companyId}`); + if (response.error) throw new Error(response.error); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/um-ace.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-ace.ts new file mode 100644 index 00000000..779996ac --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-ace.ts @@ -0,0 +1,75 @@ +/** + * API Client para UM ACE - Unidades de medida ACE + */ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface UMACE { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UMACECreate { + code: string; + description?: string | null; +} + +export interface UMACEUpdate { + code?: string; + description?: string | null; +} + +export interface UMACEListResponse { + items: UMACE[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +/** + * Lista todas las unidades de medida ACE + */ +export async function getUMACE( + page = 1, + pageSize = 50, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + return await api.get(`/a76/units-of-measure/ace?${queryParams.toString()}`); +} + +/** + * Obtiene una unidad de medida por ID + */ +export async function getUMACEById(id: number): Promise> { + return await api.get(`/a76/units-of-measure/ace/${id}`); +} + +/** + * Crea una nueva unidad de medida + */ +export async function createUMACE(data: UMACECreate): Promise> { + return await api.post('/a76/units-of-measure/ace', data); +} + +/** + * Actualiza una unidad de medida + */ +export async function updateUMACE(id: number, data: UMACEUpdate): Promise> { + return await api.put(`/a76/units-of-measure/ace/${id}`, data); +} + +/** + * Elimina una unidad de medida + */ +export async function deleteUMACE(id: number): Promise> { + return await api.delete(`/a76/units-of-measure/ace/${id}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-ame.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-ame.ts new file mode 100644 index 00000000..cc1e2569 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-ame.ts @@ -0,0 +1,75 @@ +/** + * API Client para UM American - Unidades de medida americanas + */ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface UMCustomsAme { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UMCustomsAmeCreate { + code: string; + description?: string | null; +} + +export interface UMCustomsAmeUpdate { + code?: string; + description?: string | null; +} + +export interface UMCustomsAmeListResponse { + items: UMCustomsAme[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +/** + * Lista todas las unidades de medida americanas + */ +export async function getUMCustomsAme( + page = 1, + pageSize = 50, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + return await api.get(`/a76/units-of-measure/american?${queryParams.toString()}`); +} + +/** + * Obtiene una unidad de medida por ID + */ +export async function getUMCustomsAmeById(id: number): Promise> { + return await api.get(`/a76/units-of-measure/american/${id}`); +} + +/** + * Crea una nueva unidad de medida + */ +export async function createUMCustomsAme(data: UMCustomsAmeCreate): Promise> { + return await api.post('/a76/units-of-measure/american', data); +} + +/** + * Actualiza una unidad de medida + */ +export async function updateUMCustomsAme(id: number, data: UMCustomsAmeUpdate): Promise> { + return await api.put(`/a76/units-of-measure/american/${id}`, data); +} + +/** + * Elimina una unidad de medida + */ +export async function deleteUMCustomsAme(id: number): Promise> { + return await api.delete(`/a76/units-of-measure/american/${id}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-mex.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-mex.ts new file mode 100644 index 00000000..857a77f2 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-customs-mex.ts @@ -0,0 +1,75 @@ +/** + * API Client para UM Customs (Mexican) - Unidades de medida para aduanas mexicanas + */ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface UMCustomsMex { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UMCustomsMexCreate { + code: string; + description?: string | null; +} + +export interface UMCustomsMexUpdate { + code?: string; + description?: string | null; +} + +export interface UMCustomsMexListResponse { + items: UMCustomsMex[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +/** + * Lista todas las unidades de medida para aduanas mexicanas + */ +export async function getUMCustomsMex( + page = 1, + pageSize = 50, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + return await api.get(`/a76/units-of-measure/customs?${queryParams.toString()}`); +} + +/** + * Obtiene una unidad de medida por ID + */ +export async function getUMCustomsMexById(id: number): Promise> { + return await api.get(`/a76/units-of-measure/customs/${id}`); +} + +/** + * Crea una nueva unidad de medida + */ +export async function createUMCustomsMex(data: UMCustomsMexCreate): Promise> { + return await api.post('/a76/units-of-measure/customs', data); +} + +/** + * Actualiza una unidad de medida + */ +export async function updateUMCustomsMex(id: number, data: UMCustomsMexUpdate): Promise> { + return await api.put(`/a76/units-of-measure/customs/${id}`, data); +} + +/** + * Elimina una unidad de medida + */ +export async function deleteUMCustomsMex(id: number): Promise> { + return await api.delete(`/a76/units-of-measure/customs/${id}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/um-oma.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-oma.ts new file mode 100644 index 00000000..40708461 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/um-oma.ts @@ -0,0 +1,75 @@ +/** + * API Client para UM OMA - Unidades de medida OMA + */ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface UMOMA { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UMOMACreate { + code: string; + description?: string | null; +} + +export interface UMOMAUpdate { + code?: string; + description?: string | null; +} + +export interface UMOMAListResponse { + items: UMOMA[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +/** + * Lista todas las unidades de medida OMA + */ +export async function getUMOMA( + page = 1, + pageSize = 50, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + return await api.get(`/a76/units-of-measure/oma?${queryParams.toString()}`); +} + +/** + * Obtiene una unidad de medida por ID + */ +export async function getUMOMAById(id: number): Promise> { + return await api.get(`/a76/units-of-measure/oma/${id}`); +} + +/** + * Crea una nueva unidad de medida + */ +export async function createUMOMA(data: UMOMACreate): Promise> { + return await api.post('/a76/units-of-measure/oma', data); +} + +/** + * Actualiza una unidad de medida + */ +export async function updateUMOMA(id: number, data: UMOMAUpdate): Promise> { + return await api.put(`/a76/units-of-measure/oma/${id}`, data); +} + +/** + * Elimina una unidad de medida + */ +export async function deleteUMOMA(id: number): Promise> { + return await api.delete(`/a76/units-of-measure/oma/${id}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-conversions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-conversions.ts new file mode 100644 index 00000000..7ece8a8e --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-conversions.ts @@ -0,0 +1,81 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface UnitConversion { + id: number; + from_unit_code: string; + to_unit_code: string; + conversion_factor: number; + tenant_id: number; // number + company_id: number; // number + created_at: string; + updated_at?: string; +} + +export interface UnitConversionCreate { + from_unit_code: string; // Ej: "KGM" + to_unit_code: string; // Ej: "LBR" + conversion_factor: number; + // company_id va en la URL +} + +export interface UnitConversionUpdate extends Partial {} + +export interface UnitConversionListResponse { + items: UnitConversion[]; + total: number; + page: number; + page_size: number; + pages: number; +} + + +export async function getUnitConversions( + page: number = 1, + pageSize: number = 50, + companyId: number, // 👈 Obligatorio + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + + // Agregamos /v1 y prefijo. + // NOTA: Revisa si en tu router definiste "unit_conversions" o "unit-conversions" + const response = await api.get(`/v1/a76/unit-conversions/?${params.toString()}`); + return response.data; +} + +export async function getUnitConversion(id: number, companyId: number): Promise { + const response = await api.get(`/v1/a76/unit-conversions/${id}/?company_id=${companyId}`); + if (response.error) throw new Error(response.error); + return response.data; +} + +export async function createUnitConversion( + data: UnitConversionCreate, + companyId: number +): Promise { + const response = await api.post(`/v1/a76/unit-conversions/?company_id=${companyId}`, data); + if (response.error) throw new Error(response.error); + return response.data; +} + + +export async function updateUnitConversion( + id: number, + data: UnitConversionUpdate, + companyId: number +): Promise { + const response = await api.put(`/v1/a76/unit-conversions/${id}/?company_id=${companyId}`, data); + if (response.error) throw new Error(response.error); + return response.data; +} + +export async function deleteUnitConversion(id: number, companyId: number): Promise { + const response = await api.delete(`/v1/a76/unit-conversions/${id}/?company_id=${companyId}`); + if (response.error) throw new Error(response.error); +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-measures.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-measures.ts new file mode 100644 index 00000000..6497bcda --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/unit-measures.ts @@ -0,0 +1,75 @@ +/** + * API Client para Unit Measures - Catálogo principal de unidades de medida + */ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface UnitMeasure { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UnitMeasureCreate { + code: string; + description?: string | null; +} + +export interface UnitMeasureUpdate { + code?: string; + description?: string | null; +} + +export interface UnitMeasureListResponse { + items: UnitMeasure[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +/** + * Lista todas las unidades de medida + */ +export async function getUnitMeasures( + page = 1, + pageSize = 50, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + return await api.get(`/a76/units-of-measure?${queryParams.toString()}`); +} + +/** + * Obtiene una unidad de medida por ID + */ +export async function getUnitMeasure(id: number): Promise> { + return await api.get(`/a76/units-of-measure/${id}`); +} + +/** + * Crea una nueva unidad de medida + */ +export async function createUnitMeasure(data: UnitMeasureCreate): Promise> { + return await api.post('/a76/units-of-measure', data); +} + +/** + * Actualiza una unidad de medida + */ +export async function updateUnitMeasure(id: number, data: UnitMeasureUpdate): Promise> { + return await api.put(`/a76/units-of-measure/${id}`, data); +} + +/** + * Elimina una unidad de medida + */ +export async function deleteUnitMeasure(id: number): Promise> { + return await api.delete(`/a76/units-of-measure/${id}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts new file mode 100644 index 00000000..2aa553ca --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/units-of-measure.ts @@ -0,0 +1,275 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +// --- ACE --- +export interface UnitOfMeasureACE { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UnitOfMeasureACECreate { + code: string; + description?: string | null; +} + +export interface UnitOfMeasureACEUpdate { + code?: string; + description?: string | null; +} + +export interface UnitOfMeasureACEListResponse { + items: UnitOfMeasureACE[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getUnitsOfMeasureACE( + page = 1, + pageSize = 50, + companyId: number, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + return await api.get(`/v1/a76/units-of-measure/ace/?${queryParams.toString()}`); +} + +export async function createUnitOfMeasureACE(data: UnitOfMeasureACECreate, companyId: number): Promise> { + return await api.post(`/v1/a76/units-of-measure/ace/?company_id=${companyId}`, data); +} + +export async function updateUnitOfMeasureACE(id: number, data: UnitOfMeasureACEUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/units-of-measure/ace/${id}/?company_id=${companyId}`, data); +} + +export async function deleteUnitOfMeasureACE(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/units-of-measure/ace/${id}/?company_id=${companyId}`); +} + +// --- OMA --- +export interface UnitOfMeasureOMA { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UnitOfMeasureOMACreate { + code: string; + description?: string | null; +} + +export interface UnitOfMeasureOMAUpdate { + code?: string; + description?: string | null; +} + +export interface UnitOfMeasureOMAListResponse { + items: UnitOfMeasureOMA[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getUnitsOfMeasureOMA( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + return await api.get(`/v1/a76/units-of-measure/oma/?${queryParams.toString()}`); +} + +export async function createUnitOfMeasureOMA(data: UnitOfMeasureOMACreate, companyId: number): Promise> { + return await api.post(`/v1/a76/units-of-measure/oma/?company_id=${companyId}`, data); +} + +export async function updateUnitOfMeasureOMA(id: number, data: UnitOfMeasureOMAUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/units-of-measure/oma/${id}/?company_id=${companyId}`, data); +} + +export async function deleteUnitOfMeasureOMA(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/units-of-measure/oma/${id}/?company_id=${companyId}`); +} + +// --- American --- +export interface UnitOfMeasureAmerican { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UnitOfMeasureAmericanCreate { + code: string; + description?: string | null; +} + +export interface UnitOfMeasureAmericanUpdate { + code?: string; + description?: string | null; +} + +export interface UnitOfMeasureAmericanListResponse { + items: UnitOfMeasureAmerican[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getUnitsOfMeasureAmerican( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + return await api.get(`/v1/a76/units-of-measure/american/?${queryParams.toString()}`); +} + +export async function createUnitOfMeasureAmerican(data: UnitOfMeasureAmericanCreate, companyId: number): Promise> { + return await api.post(`/v1/a76/units-of-measure/american/?company_id=${companyId}`, data); +} + +export async function updateUnitOfMeasureAmerican(id: number, data: UnitOfMeasureAmericanUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/units-of-measure/american/${id}/?company_id=${companyId}`, data); +} + +export async function deleteUnitOfMeasureAmerican(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/units-of-measure/american/${id}/?company_id=${companyId}`); +} + +// --- General --- +export interface UnitOfMeasureGeneral { + id: number; + code: string; + description: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UnitOfMeasureGeneralCreate { + code: string; + description?: string | null; +} + +export interface UnitOfMeasureGeneralUpdate { + code?: string; + description?: string | null; +} + +export interface UnitOfMeasureGeneralListResponse { + items: UnitOfMeasureGeneral[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getUnitsOfMeasureGeneral( + page = 1, + pageSize = 50, + companyId: number, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + return await api.get(`/v1/a76/units-of-measure/general/?${queryParams.toString()}`); +} + +export async function createUnitOfMeasureGeneral(data: UnitOfMeasureGeneralCreate, companyId: number): Promise> { + return await api.post(`/v1/a76/units-of-measure/general/?company_id=${companyId}`, data); +} + +export async function updateUnitOfMeasureGeneral(id: number, data: UnitOfMeasureGeneralUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/units-of-measure/general/${id}/?company_id=${companyId}`, data); +} + +export async function deleteUnitOfMeasureGeneral(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/units-of-measure/general/${id}/?company_id=${companyId}`); +} + +// --- Customs --- +export interface UnitOfMeasureCustoms { + id: number; + code: string; + description: string | null; + scaii_unit_code: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface UnitOfMeasureCustomsCreate { + code: string; + description?: string | null; + scaii_unit_code?: string | null; +} + +export interface UnitOfMeasureCustomsUpdate { + code?: string; + description?: string | null; + scaii_unit_code?: string | null; +} + +export interface UnitOfMeasureCustomsListResponse { + items: UnitOfMeasureCustoms[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getUnitsOfMeasureCustoms( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + return await api.get(`/v1/a76/units-of-measure/customs/?${queryParams.toString()}`); +} + +export async function createUnitOfMeasureCustoms(data: UnitOfMeasureCustomsCreate, companyId: number): Promise> { + return await api.post(`/v1/a76/units-of-measure/customs/?company_id=${companyId}`, data); +} + +export async function updateUnitOfMeasureCustoms(id: number, data: UnitOfMeasureCustomsUpdate, companyId: number): Promise> { + return await api.put(`/v1/a76/units-of-measure/customs/${id}/?company_id=${companyId}`, data); +} + +export async function deleteUnitOfMeasureCustoms(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/units-of-measure/customs/${id}/?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/index.ts b/frontend/src/lib/api/dashboard/a76/index.ts new file mode 100644 index 00000000..5bc4da02 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/index.ts @@ -0,0 +1,19 @@ +/** + * Exportaciones de APIs para módulo A76 + */ +export * from './classes'; +export * from './general_catalogs/packages'; +export * from './general_catalogs/exchange-rate'; +export * from './general_catalogs/concepts'; +export * from './general_catalogs/customs-broker-concepts'; +export * from './general_catalogs/classification-concepts'; +export * from './general_catalogs/unit-conversions'; +export * from './general_catalogs/equivalencies'; +export * from './general_catalogs/multi-currency-types'; +export * from './general_catalogs/inpc'; +export * from './general_catalogs/legends'; +export * from './general_catalogs/signatures'; +export * from './general_catalogs/error-catalogs'; +export * from './general_catalogs/doda'; +export * from './general_catalogs/prevalidators'; +export * from './general_catalogs/electronic-notices'; diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts new file mode 100644 index 00000000..e245f335 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -0,0 +1,450 @@ +/** + * API Client para Facturas (Invoices) + * Gestiona las operaciones CRUD para facturas y sus relaciones + */ +import { api } from '$lib/api'; + +export type OperationType = 'imp' | 'exp'; +export type TransportType = 'none' | 'transport' | 'box' | 'licence plates' | 'truck' | 'vessel' | 'rail barge' | 'container' | 'airplane' | 'gondola' | 'flatbed'; + +// --- Interfaces --- + +export interface InvoiceComplianceMx { + invoice_id?: number; + pedimento?: string | null; + pedimento_code?: string | null; + pedimento_k1?: string | null; + remesa?: number | null; + aduana?: string | null; + port_of_entry?: string | null; + destination?: string | null; + manifest_number?: string | null; + provider_header?: string | null; + provider_id?: string | null; + sold_to_header?: string | null; + sold_to_id?: string | null; + shipped_to_header?: string | null; + shipped_to_id?: string | null; + shipped_by_header?: string | null; + shipped_by_id?: string | null; + customs_broker_id?: string | null; + customs_broker_us_id?: string | null; + broker_invoice_num?: string | null; + broker_invoice_date?: string | null; + is_mixed?: boolean | null; + waste_type?: string | null; + scrap_type?: string | null; + appendix_17?: number | null; + is_regime_change?: string | null; + which_exchange_rate?: string | null; + value_method?: string | null; + act_value?: string | null; + is_pedimento_pending?: boolean | null; + is_owner_of_goods?: string | null; + generate_balances?: string | null; + was_reviewed_by_company?: boolean | null; + edocument?: string | null; + code_signature?: string | null; + electronic_signature?: string | null; + certificate_number?: string | null; + niu_number?: string | null; + bill_of_lading_count?: string | null; + addendum_vu?: string | null; + origin_destination_cove?: string | null; + vucem_operation_num?: string | null; + customs_person_line?: number | null; + contingency_mode?: boolean | null; + enclosure?: string | null; + guide_type_to_identify?: string | null; + location?: string | null; + dot_code?: string | null; + subdivision?: string | null; + acts_as?: string | null; + movement_type?: string | null; + office_document?: string | null; + reason_export?: string | null; + signature_key?: string | null; + sem_id?: number | null; +} + +export interface InvoiceFinancials { + id?: number; + invoice_id?: number; + currency?: string | null; + currency_type?: string | null; + exchange_rate?: number | null; + exchange_rate_mm?: number | null; + value_mn?: number | null; + value_me?: number | null; + value_mc?: number | null; + customs_value_mn?: number | null; + customs_value_me?: number | null; + raw_material_value_mn?: number | null; + raw_material_value_me?: number | null; + aggregate_value_mn?: number | null; + aggregate_value_me?: number | null; + aggregate_value_mc?: number | null; + mexican_value_mn?: number | null; + mexican_value_me?: number | null; + mexican_value_mc?: number | null; + national_packaging_mn?: number | null; + national_packaging_me?: number | null; + national_packaging_mc?: number | null; + freight?: number | null; + insurance?: number | null; + insurance_value?: number | null; + packaging?: number | null; + other_increments?: number | null; + total_increments_mn?: number | null; + total_increments_me?: number | null; + iva_mn?: number | null; + iva_me?: number | null; + iva_mc?: number | null; + iva_factor?: string | null; + tax_value_me?: number | null; + seal_value_2500?: boolean | null; + total_quantity?: number | null; + gross_weight?: number | null; + net_weight?: number | null; + bundle_count?: number | null; + weight_factor?: number | null; +} + +export interface InvoiceLogistics { + id?: number; + invoice_id?: number; + carrier_id?: string | null; + transport_id?: string | null; + transport_us_id?: string | null; + transport_type?: TransportType | null; + transport_num?: string | null; + transport_mode?: string | null; + driver_name?: string | null; + is_rail?: string | null; + rail_id?: string | null; + vehicle_num?: string | null; + license_plate?: string | null; + license_plate_complete?: string | null; + trailer_num?: string | null; + seal_number?: string | null; + guide_number?: string | null; + bill_number?: string | null; + reference_number?: string | null; + shipment_number?: string | null; + incoterm?: string | null; + identifier_1?: string | null; + complement_1?: string | null; + identifier_2?: string | null; + complement_2?: string | null; + weight_type?: string | null; + container_types?: string | null; + vehicle_data?: string | null; + origin_location?: string | null; + destination_location?: string | null; + transport_itinerary?: string | null; + destination_goods?: string | null; + entry_exit_date?: string | null; + delivery_date?: string | null; + delivered_status?: string | null; + received_by?: string | null; + payment_date?: string | null; + payment_receipt_num?: string | null; + is_ctm_process?: string | null; +} + +export interface InvoiceSalesDetails { + id?: number; + invoice_id?: number; + line_number: number; + sales_order?: string | null; + colors_description?: string | null; + square_color_code?: string | null; + line_bundles?: number | null; +} + +export interface InvoiceCollections { + id?: number; + invoice_id?: number; + concept?: string | null; + is_collected?: number | null; + collection_date?: string | null; + amount?: number | null; + collector_user?: string | null; +} + +export interface Invoice { + id: number; + tenant_id: number; + company_id: number; + system?: string | null; + operation_type?: OperationType | null; + invoice_type?: string | null; + invoice_number?: string | null; + project_number?: string | null; + purchase_order?: string | null; + related_doc_id?: number | null; + alternate_invoice?: string | null; + invoice_ref?: string | null; + proforma_number?: string | null; + invoice_date?: string | null; + capture_date: string; + emission_date?: string | null; + is_updated?: boolean | null; + updated_date?: string | null; + who_updated?: string | null; + capture_user?: string | null; + traffic_light_status?: string | null; + process_log?: string | null; + status_rec?: number | null; + status_rep?: string | null; + observation_es?: string | null; + observation_en?: string | null; + comments_status?: string | null; + vu_observations?: string | null; + cfdi_uuid?: string | null; + path_pdf?: string | null; + path_xml?: string | null; + subcompany?: string | null; + party_count?: number | null; + generate_id?: string | null; + generate_desc_parties?: string | null; + apply_manual_discount?: string | null; + is_bulk?: boolean | null; + download_substance?: boolean | null; + download_class?: boolean | null; + download_def?: boolean | null; + payment_terms?: string | null; + handling_fees?: number | null; + option_iv18?: string | null; + enajenation_goods?: boolean | null; + compliance_mx?: InvoiceComplianceMx | null; + financials?: InvoiceFinancials | null; + logistics?: InvoiceLogistics[]; + details?: InvoiceSalesDetails[]; + collections?: InvoiceCollections[]; +} + +export interface InvoiceListResponse { + items: Invoice[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateInvoiceData { + system?: string | null; + operation_type?: OperationType | null; + invoice_type?: string | null; + invoice_number?: string | null; + project_number?: string | null; + purchase_order?: string | null; + related_doc_id?: number | null; + alternate_invoice?: string | null; + invoice_ref?: string | null; + proforma_number?: string | null; + invoice_date?: string | null; + emission_date?: string | null; + is_updated?: boolean | null; + updated_date?: string | null; + who_updated?: string | null; + capture_user?: string | null; + traffic_light_status?: string | null; + process_log?: string | null; + status_rec?: number | null; + status_rep?: string | null; + observation_es?: string | null; + observation_en?: string | null; + comments_status?: string | null; + vu_observations?: string | null; + cfdi_uuid?: string | null; + path_pdf?: string | null; + path_xml?: string | null; + subcompany?: string | null; + party_count?: number | null; + generate_id?: string | null; + generate_desc_parties?: string | null; + apply_manual_discount?: string | null; + is_bulk?: boolean | null; + download_substance?: boolean | null; + download_class?: boolean | null; + download_def?: boolean | null; + payment_terms?: string | null; + handling_fees?: number | null; + option_iv18?: string | null; + enajenation_goods?: boolean | null; + compliance_mx?: Omit | null; + financials?: Omit | null; + logistics?: Omit[] | null; + details?: Omit[] | null; + collections?: Omit[] | null; +} + +export interface UpdateInvoiceData { + operation_type?: OperationType | null; + invoice_type?: string | null; + invoice_number?: string | null; + project_number?: string | null; + purchase_order?: string | null; + related_doc_id?: number | null; + invoice_date?: string | null; + traffic_light_status?: string | null; + process_log?: string | null; + observation_es?: string | null; + observation_en?: string | null; + comments_status?: string | null; + cfdi_uuid?: string | null; + path_pdf?: string | null; + path_xml?: string | null; + compliance_mx?: Partial | null; + financials?: Partial | null; + logistics?: Partial[] | null; + details?: Partial[] | null; + collections?: Partial[] | null; +} + +/** + * API para Facturas + */ +export const invoicesApi = { + /** + * Lista todas las facturas con paginación + */ + list: (companyId: number, page = 1, pageSize = 50, filters?: Record) => { + const params = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString() + }); + + // Agregar filtros si existen + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + params.append(key, String(value)); + } + }); + } + + return api.get(`/v1/a76/invoices/?${params.toString()}`); + }, + + /** + * Obtiene una factura por ID + */ + get: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/invoices/${invoiceId}?${params.toString()}`); + }, + + /** + * Crea una nueva factura + */ + create: (companyId: number, data: CreateInvoiceData) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`/v1/a76/invoices/?${params.toString()}`, data); + }, + + /** + * Actualiza una factura existente + */ + update: (invoiceId: number, companyId: number, data: UpdateInvoiceData) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`/v1/a76/invoices/${invoiceId}?${params.toString()}`, data); + }, + + /** + * Elimina una factura + */ + delete: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`/v1/a76/invoices/${invoiceId}?${params.toString()}`); + }, + + // --- Nested Resources --- + + /** + * Logística de factura + */ + logistics: { + list: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/invoices/${invoiceId}/logistics/?${params.toString()}`); + }, + + create: (invoiceId: number, companyId: number, data: Omit) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`/v1/a76/invoices/${invoiceId}/logistics/?${params.toString()}`, data); + }, + + delete: (invoiceId: number, logisticsId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`/v1/a76/invoices/${invoiceId}/logistics/${logisticsId}/?${params.toString()}`); + } + }, + + /** + * Detalles de venta de factura + */ + details: { + list: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/invoices/${invoiceId}/details/?${params.toString()}`); + }, + + create: (invoiceId: number, companyId: number, data: Omit) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`/v1/a76/invoices/${invoiceId}/details/?${params.toString()}`, data); + }, + + delete: (invoiceId: number, detailId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`/v1/a76/invoices/${invoiceId}/details/${detailId}/?${params.toString()}`); + } + }, + + /** + * Cobranzas de factura + */ + collections: { + list: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/invoices/${invoiceId}/collections/?${params.toString()}`); + }, + + create: (invoiceId: number, companyId: number, data: Omit) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`/v1/a76/invoices/${invoiceId}/collections/?${params.toString()}`, data); + }, + + delete: (invoiceId: number, collectionId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`/v1/a76/invoices/${invoiceId}/collections/${collectionId}/?${params.toString()}`); + } + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/pedimento-dates.ts b/frontend/src/lib/api/dashboard/a76/pedimento-dates.ts new file mode 100644 index 00000000..1cbe8ef4 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/pedimento-dates.ts @@ -0,0 +1,61 @@ +/** + * API Client para Fechas de Pedimentos + */ +import { api } from '$lib/api'; + +export interface PedimentoDates { + id: number; + pedimento_id: number; + tenant_id: number; + entry_date?: string | null; + pedimento_date?: string | null; + payment_date?: string | null; + rectification_payment_date?: string | null; + extraction_date?: string | null; + submission_date?: string | null; + eucan_date?: string | null; + original_date?: string | null; + start_date?: string | null; + end_date?: string | null; + created_at: string; +} + +export interface CreatePedimentoDatesData { + entry_date?: string | null; + pedimento_date?: string | null; + payment_date?: string | null; + rectification_payment_date?: string | null; + extraction_date?: string | null; + submission_date?: string | null; + eucan_date?: string | null; + original_date?: string | null; + start_date?: string | null; + end_date?: string | null; +} + +export interface UpdatePedimentoDatesData { + entry_date?: string | null; + pedimento_date?: string | null; + payment_date?: string | null; + rectification_payment_date?: string | null; + extraction_date?: string | null; + submission_date?: string | null; + eucan_date?: string | null; + original_date?: string | null; + start_date?: string | null; + end_date?: string | null; +} + +export const pedimentoDatesApi = { + get: (pedimentoId: number) => + api.get(`/v1/a76/pedimentos/${pedimentoId}/dates`), + + create: (pedimentoId: number, data: CreatePedimentoDatesData) => + api.post(`/v1/a76/pedimentos/${pedimentoId}/dates`, data), + + update: (pedimentoId: number, data: UpdatePedimentoDatesData) => + api.put(`/v1/a76/pedimentos/${pedimentoId}/dates`, data), + + delete: (pedimentoId: number) => + api.delete(`/v1/a76/pedimentos/${pedimentoId}/dates`) +}; diff --git a/frontend/src/lib/api/dashboard/a76/pedimento-payments.ts b/frontend/src/lib/api/dashboard/a76/pedimento-payments.ts new file mode 100644 index 00000000..ac5bfa08 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/pedimento-payments.ts @@ -0,0 +1,64 @@ +/** + * API Client para Pagos de Pedimentos + */ +import { api } from '$lib/api'; + +export interface PedimentoPayments { + id: number; + pedimento_id: number; + tenant_id: number; + acknowledgment?: string | null; + operation_number?: string | null; + bank_code?: number | null; + cashier?: string | null; + date?: string | null; + time?: string | null; + shift?: string | null; + total_cash_paid?: number | null; + total_contributions?: number | null; + counter_payment?: number | null; + pece_code?: string | null; + created_at: string; +} + +export interface CreatePedimentoPaymentsData { + acknowledgment?: string | null; + operation_number?: string | null; + bank_code?: number | null; + cashier?: string | null; + date?: string | null; + time?: string | null; + shift?: string | null; + total_cash_paid?: number | null; + total_contributions?: number | null; + counter_payment?: number | null; + pece_code?: string | null; +} + +export interface UpdatePedimentoPaymentsData { + acknowledgment?: string | null; + operation_number?: string | null; + bank_code?: number | null; + cashier?: string | null; + date?: string | null; + time?: string | null; + shift?: string | null; + total_cash_paid?: number | null; + total_contributions?: number | null; + counter_payment?: number | null; + pece_code?: string | null; +} + +export const pedimentoPaymentsApi = { + get: (pedimentoId: number) => + api.get(`/v1/a76/pedimentos/${pedimentoId}/payments`), + + create: (pedimentoId: number, data: CreatePedimentoPaymentsData) => + api.post(`/v1/a76/pedimentos/${pedimentoId}/payments`, data), + + update: (pedimentoId: number, data: UpdatePedimentoPaymentsData) => + api.put(`/v1/a76/pedimentos/${pedimentoId}/payments`, data), + + delete: (pedimentoId: number) => + api.delete(`/v1/a76/pedimentos/${pedimentoId}/payments`) +}; diff --git a/frontend/src/lib/api/dashboard/a76/pedimento-transport.ts b/frontend/src/lib/api/dashboard/a76/pedimento-transport.ts new file mode 100644 index 00000000..b5f7d424 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/pedimento-transport.ts @@ -0,0 +1,43 @@ +/** + * API Client para Medios de Transporte de Pedimentos + */ +import { api } from '$lib/api'; + +export interface PedimentoTransportMeans { + id: number; + pedimento_id: number; + tenant_id: number; + destination?: number | null; + entry_exit?: string | null; + arrival?: string | null; + departure?: string | null; + created_at: string; +} + +export interface CreatePedimentoTransportMeansData { + destination?: number | null; + entry_exit?: string | null; + arrival?: string | null; + departure?: string | null; +} + +export interface UpdatePedimentoTransportMeansData { + destination?: number | null; + entry_exit?: string | null; + arrival?: string | null; + departure?: string | null; +} + +export const pedimentoTransportApi = { + get: (pedimentoId: number) => + api.get(`/v1/a76/pedimentos/${pedimentoId}/transport-means`), + + create: (pedimentoId: number, data: CreatePedimentoTransportMeansData) => + api.post(`/v1/a76/pedimentos/${pedimentoId}/transport-means`, data), + + update: (pedimentoId: number, data: UpdatePedimentoTransportMeansData) => + api.put(`/v1/a76/pedimentos/${pedimentoId}/transport-means`, data), + + delete: (pedimentoId: number) => + api.delete(`/v1/a76/pedimentos/${pedimentoId}/transport-means`) +}; diff --git a/frontend/src/lib/api/dashboard/a76/pedimento-validation.ts b/frontend/src/lib/api/dashboard/a76/pedimento-validation.ts new file mode 100644 index 00000000..8d78de9c --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/pedimento-validation.ts @@ -0,0 +1,55 @@ +/** + * API Client para Validación de Pedimentos + */ +import { api } from '$lib/api'; + +export interface PedimentoValidation { + id: number; + pedimento_id: number; + tenant_id: number; + validator?: string | null; + validation_ack?: string | null; + pre_ack?: string | null; + line_signature?: string | null; + electronic_signature?: string | null; + certificate_number?: string | null; + validator_id?: number | null; + responsible_id?: number | null; + created_at: string; +} + +export interface CreatePedimentoValidationData { + validator?: string | null; + validation_ack?: string | null; + pre_ack?: string | null; + line_signature?: string | null; + electronic_signature?: string | null; + certificate_number?: string | null; + validator_id?: number | null; + responsible_id?: number | null; +} + +export interface UpdatePedimentoValidationData { + validator?: string | null; + validation_ack?: string | null; + pre_ack?: string | null; + line_signature?: string | null; + electronic_signature?: string | null; + certificate_number?: string | null; + validator_id?: number | null; + responsible_id?: number | null; +} + +export const pedimentoValidationApi = { + get: (pedimentoId: number) => + api.get(`/v1/a76/pedimentos/${pedimentoId}/validation`), + + create: (pedimentoId: number, data: CreatePedimentoValidationData) => + api.post(`/v1/a76/pedimentos/${pedimentoId}/validation`, data), + + update: (pedimentoId: number, data: UpdatePedimentoValidationData) => + api.put(`/v1/a76/pedimentos/${pedimentoId}/validation`, data), + + delete: (pedimentoId: number) => + api.delete(`/v1/a76/pedimentos/${pedimentoId}/validation`) +}; diff --git a/frontend/src/lib/api/dashboard/a76/pedimentos.ts b/frontend/src/lib/api/dashboard/a76/pedimentos.ts new file mode 100644 index 00000000..fea0001e --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/pedimentos.ts @@ -0,0 +1,242 @@ +/** + * API Client para Pedimentos + * Gestiona las operaciones CRUD para pedimentos + */ +import { api } from '$lib/api'; + +// Sub-resource interfaces +export interface PedimentoDates { + entry_date?: string | null; + pedimento_date?: string | null; + payment_date?: string | null; + rectification_payment_date?: string | null; + extraction_date?: string | null; + submission_date?: string | null; + eucan_date?: string | null; + original_date?: string | null; + start_date?: string | null; + end_date?: string | null; +} + +export interface PedimentoPayments { + acknowledgment?: string | null; + operation_number?: string | null; + bank_code?: string | null; + cashier?: string | null; + date?: string | null; + time?: string | null; + shift?: string | null; + total_cash_paid?: string | null; + total_contributions?: string | null; + counter_payment?: string | null; + pece_code?: string | null; +} + +export interface PedimentoTransportMeans { + destination?: number | null; + entry_exit?: string | null; + arrival?: string | null; + departure?: string | null; +} + +export interface PedimentoValidation { + validator?: string | null; + validation_ack?: string | null; + pre_ack?: string | null; + line_signature?: string | null; + electronic_signature?: string | null; + certificate_number?: string | null; + validator_id?: number | null; + responsible_id?: number | null; +} + +export interface PedimentoIncrementables { + insured_value?: number | null; + packaging?: number | null; + freight?: number | null; + deductibles?: number | null; + currency?: string | null; + not_affect_usd_value?: number | null; + not_affect_customs_value?: number | null; +} + +export interface PedimentoDecrementables { + freight?: number | null; + insurance?: number | null; + loading?: number | null; + unloading?: number | null; + others?: number | null; + currency?: string | null; + not_affect_usd_value?: number | null; +} + +export interface PedimentoIndexes { + update_factor_type?: number | null; + update_factor?: number | null; + manual_update_factor?: number | null; +} + +export interface PedimentoConfigAdditional { + manual_pedimento_year?: string | null; + add_po_identifier?: number | null; + do_not_exempt_norms_complement_x?: number | null; + enable_import_invoice_recipient?: number | null; + send_502_validation_file_for_consolidated?: number | null; + add_remove_norms?: number | null; +} + +export interface Pedimento { + id: number; + tenant_id: number; + year?: string | null; + customs_office?: string | null; + license?: string | null; + pedimento_number?: string | null; + client_id?: number | null; + operation_type?: number | null; + pedimento_type?: string | null; + pedimento_code?: string | null; + regime?: string | null; + status?: string | null; + usd_value?: number | null; + paid_price?: number | null; + gross_weight?: number | null; + exchange_rate?: number | null; + observaciones?: string | null; + created_at: string; + // Sub-resources + pedimento_dates?: PedimentoDates | null; + pedimento_payments?: PedimentoPayments | null; + pedimento_transport_means?: PedimentoTransportMeans | null; + pedimento_validation?: PedimentoValidation | null; + pedimento_incrementables?: PedimentoIncrementables | null; + pedimento_decrementables?: PedimentoDecrementables | null; + pedimento_indexes?: PedimentoIndexes | null; + pedimento_config_additional?: PedimentoConfigAdditional | null; +} + +export interface PedimentoListResponse { + items: Pedimento[]; + total: number; + page: number; + page_size: number; +} + +export interface CreatePedimentoData { + year?: string | null; + customs_office?: string | null; + license?: string | null; + pedimento_number?: string | null; + client_id?: number | null; + operation_type?: number | null; + pedimento_type?: string | null; + pedimento_code?: string | null; + regime?: string | null; + status?: string | null; + usd_value?: number | null; + paid_price?: number | null; + gross_weight?: number | null; + exchange_rate?: number | null; + observaciones?: string | null; + // Sub-resources + pedimento_dates?: PedimentoDates | null; + pedimento_payments?: PedimentoPayments | null; + pedimento_transport_means?: PedimentoTransportMeans | null; + pedimento_validation?: PedimentoValidation | null; + pedimento_incrementables?: PedimentoIncrementables | null; + pedimento_decrementables?: PedimentoDecrementables | null; + pedimento_indexes?: PedimentoIndexes | null; + pedimento_config_additional?: PedimentoConfigAdditional | null; +} + +export interface UpdatePedimentoData { + year?: string | null; + customs_office?: string | null; + license?: string | null; + pedimento_number?: string | null; + client_id?: number | null; + operation_type?: number | null; + pedimento_type?: string | null; + pedimento_code?: string | null; + regime?: string | null; + status?: string | null; + usd_value?: number | null; + paid_price?: number | null; + gross_weight?: number | null; + exchange_rate?: number | null; + observaciones?: string | null; + // Sub-resources + pedimento_dates?: PedimentoDates | null; + pedimento_payments?: PedimentoPayments | null; + pedimento_transport_means?: PedimentoTransportMeans | null; + pedimento_validation?: PedimentoValidation | null; + pedimento_incrementables?: PedimentoIncrementables | null; + pedimento_decrementables?: PedimentoDecrementables | null; + pedimento_indexes?: PedimentoIndexes | null; + pedimento_config_additional?: PedimentoConfigAdditional | null; +} + +export interface PedimentoFilters { + status?: string; + client_id?: number; + year?: string; +} + +/** + * API para Pedimentos + */ +export const pedimentosApi = { + /** + * Lista todos los pedimentos con paginación y filtros + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + * @param filters - Filtros opcionales + * @param companyId - ID de la compañía (por defecto 1) + */ + list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId = 1) => { + let url = `/v1/a76/pedimentos?company_id=${companyId}&page=${page}&page_size=${pageSize}`; + + if (filters?.status) { + url += `&status=${encodeURIComponent(filters.status)}`; + } + if (filters?.client_id) { + url += `&client_id=${filters.client_id}`; + } + if (filters?.year) { + url += `&year=${encodeURIComponent(filters.year)}`; + } + + return api.get(url); + }, + + /** + * Obtiene un pedimento por ID + * @param id - ID del pedimento + * @param companyId - ID de la compañía (por defecto 1) + */ + get: (id: number, companyId = 1) => api.get(`/v1/a76/pedimentos/${id}?company_id=${companyId}`), + + /** + * Crea un nuevo pedimento + * @param data - Datos del pedimento a crear + * @param companyId - ID de la compañía (por defecto 1) + */ + create: (data: CreatePedimentoData, companyId = 1) => + api.post(`/v1/a76/pedimentos?company_id=${companyId}`, data), + + /** + * Actualiza un pedimento existente + * @param id - ID del pedimento a actualizar + * @param data - Datos a actualizar + * @param companyId - ID de la compañía (por defecto 1) + */ + update: (id: number, data: UpdatePedimentoData, companyId = 1) => + api.put(`/v1/a76/pedimentos/${id}?company_id=${companyId}`, data), + + /** + * Elimina un pedimento + * @param id - ID del pedimento a eliminar + * @param companyId - ID de la compañía (por defecto 1) + */ + delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/code_pedimento_regimens.ts b/frontend/src/lib/api/dashboard/refrence_data/code_pedimento_regimens.ts index e69de29b..fd8e4d4a 100644 --- a/frontend/src/lib/api/dashboard/refrence_data/code_pedimento_regimens.ts +++ b/frontend/src/lib/api/dashboard/refrence_data/code_pedimento_regimens.ts @@ -0,0 +1,73 @@ +/** + * API Client para Code Pedimento Regimens + * Gestiona las operaciones CRUD para las relaciones entre códigos de pedimento y regímenes + */ +import { api } from '$lib/api'; + +export interface CodePedimentoRegimen { + id: number; + pedimento_code: string; + regimen_code: string | null; + type_code: string | null; +} + +export interface CodePedimentoRegimenListResponse { + items: CodePedimentoRegimen[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateCodePedimentoRegimenData { + pedimento_code: string; + regimen_code?: string; + type_code?: string; +} + +export interface UpdateCodePedimentoRegimenData { + pedimento_code?: string; + regimen_code?: string; + type_code?: string; +} + +/** + * API para Code Pedimento Regimens + */ +export const codePedimentoRegimensApi = { + /** + * Lista todos los code pedimento regimens con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/code-pedimento-regimens?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un code pedimento regimen por ID + * @param id - ID del code pedimento regimen + */ + get: (id: number) => api.get(`/v1/code-pedimento-regimens/${id}`), + + /** + * Crea un nuevo code pedimento regimen + * @param data - Datos del code pedimento regimen a crear + */ + create: (data: CreateCodePedimentoRegimenData) => + api.post('/v1/public/refrence_data/code-pedimento-regimens', data), + + /** + * Actualiza un code pedimento regimen existente + * @param id - ID del code pedimento regimen a actualizar + * @param data - Datos a actualizar + */ + update: (id: number, data: UpdateCodePedimentoRegimenData) => + api.put(`/v1/public/refrence_data/code-pedimento-regimens/${id}`, data), + + /** + * Elimina un code pedimento regimen + * @param id - ID del code pedimento regimen a eliminar + */ + delete: (id: number) => api.delete(`/v1/public/refrence_data/code-pedimento-regimens/${id}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/containers.ts b/frontend/src/lib/api/dashboard/refrence_data/containers.ts new file mode 100644 index 00000000..64294fbe --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/containers.ts @@ -0,0 +1,69 @@ +/** + * API Client para Containers + * Gestiona las operaciones CRUD para los contenedores + */ +import { api } from '$lib/api'; + +export interface Container { + key: string; + description: string; +} + +export interface ContainerListResponse { + items: Container[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateContainerData { + key: string; + description: string; +} + +export interface UpdateContainerData { + key?: string; + description?: string; +} + +/** + * API para Containers + */ +export const containersApi = { + /** + * Lista todos los containers con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/containers?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un container por ID + * @param key - ID del container + */ + get: (key: number) => api.get(`/v1/containers/${key}`), + + /** + * Crea un nuevo container + * @param data - Datos del container a crear + */ + create: (data: CreateContainerData) => + api.post('/v1/public/refrence_data/containers', data), + + /** + * Actualiza un container existente + * @param key - ID del container a actualizar + * @param data - Datos a actualizar + */ + update: (key: number, data: UpdateContainerData) => + api.put(`/v1/public/refrence_data/containers/${key}`, data), + + /** + * Elimina un container + * @param key - ID del container a eliminar + */ + delete: (key: number) => api.delete(`/v1/public/refrence_data/containers/${key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/countries.ts b/frontend/src/lib/api/dashboard/refrence_data/countries.ts new file mode 100644 index 00000000..55bea4b4 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/countries.ts @@ -0,0 +1,78 @@ +/** + * API Client para Countries + * Gestiona las operaciones CRUD para los países + */ +import { api } from '$lib/api'; + +export interface Country { + m3_key: string; + mex_key: string; + ame_key: string; + description_es: string; + description_en: string; +} + +export interface CountryListResponse { + items: Country[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateCountryData { + m3_key: string; + mex_key: string; + ame_key: string; + description_es: string; + description_en: string; +} + +export interface UpdateCountryData { + m3_key?: string; + mex_key?: string; + ame_key?: string; + description_es?: string; + description_en?: string; +} + +/** + * API para Countries + */ +export const countriesApi = { + /** + * Lista todos los países con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/countries?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un país por su clave M3 + * @param m3_key - Clave M3 del país + */ + get: (m3_key: string) => api.get(`/v1/public/refrence_data/countries/${m3_key}`), + + /** + * Crea un nuevo país + * @param data - Datos del país a crear + */ + create: (data: CreateCountryData) => + api.post('/v1/public/refrence_data/countries', data), + + /** + * Actualiza un país existente + * @param m3_key - Clave M3 del país a actualizar + * @param data - Datos a actualizar + */ + update: (m3_key: string, data: UpdateCountryData) => + api.put(`/v1/public/refrence_data/countries/${m3_key}`, data), + + /** + * Elimina un país + * @param m3_key - Clave M3 del país a eliminar + */ + delete: (m3_key: string) => api.delete(`/v1/public/refrence_data/countries/${m3_key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/currency_types.ts b/frontend/src/lib/api/dashboard/refrence_data/currency_types.ts new file mode 100644 index 00000000..a3912934 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/currency_types.ts @@ -0,0 +1,72 @@ +/** + * API Client para Currency Types + * Gestiona las operaciones CRUD para los tipos de moneda + */ +import { api } from '$lib/api'; + +export interface CurrencyType { + code: string; + currency_name: string; + country_description: string; +} + +export interface CurrencyTypeListResponse { + items: CurrencyType[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateCurrencyTypeData { + code: string; + currency_name: string; + country_description: string; +} + +export interface UpdateCurrencyTypeData { + code?: string; + currency_name?: string; + country_description?: string; +} + +/** + * API para Currency Types + */ +export const currencyTypesApi = { + /** + * Lista todos los tipos de moneda con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/currency-types?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un tipo de moneda por código + * @param code - Código del tipo de moneda + */ + get: (code: string) => api.get(`/v1/public/refrence_data/currency-types/${code}`), + + /** + * Crea un nuevo tipo de moneda + * @param data - Datos del tipo de moneda a crear + */ + create: (data: CreateCurrencyTypeData) => + api.post('/v1/public/refrence_data/currency-types', data), + + /** + * Actualiza un tipo de moneda existente + * @param code - Código del tipo de moneda a actualizar + * @param data - Datos a actualizar + */ + update: (code: string, data: UpdateCurrencyTypeData) => + api.put(`/v1/public/refrence_data/currency-types/${code}`, data), + + /** + * Elimina un tipo de moneda + * @param code - Código del tipo de moneda a eliminar + */ + delete: (code: string) => api.delete(`/v1/public/refrence_data/currency-types/${code}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/customs_sections.ts b/frontend/src/lib/api/dashboard/refrence_data/customs_sections.ts new file mode 100644 index 00000000..5ee4141b --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/customs_sections.ts @@ -0,0 +1,69 @@ +/** + * API Client para Customs Sections + * Gestiona las operaciones CRUD para las secciones aduaneras + */ +import { api } from '$lib/api'; + +export interface CustomsSection { + customs_code: string; + section_name: string; +} + +export interface CustomsSectionListResponse { + items: CustomsSection[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateCustomsSectionData { + customs_code: string; + section_name: string; +} + +export interface UpdateCustomsSectionData { + customs_code?: string; + section_name?: string; +} + +/** + * API para Customs Sections + */ +export const customsSectionsApi = { + /** + * Lista todas las secciones aduaneras con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/customs-sections?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene una sección aduanera por código + * @param customs_code - Código de la sección aduanera + */ + get: (customs_code: string) => api.get(`/v1/public/refrence_data/customs-sections/${customs_code}`), + + /** + * Crea una nueva sección aduanera + * @param data - Datos de la sección aduanera a crear + */ + create: (data: CreateCustomsSectionData) => + api.post('/v1/public/refrence_data/customs-sections', data), + + /** + * Actualiza una sección aduanera existente + * @param customs_code - Código de la sección aduanera a actualizar + * @param data - Datos a actualizar + */ + update: (customs_code: string, data: UpdateCustomsSectionData) => + api.put(`/v1/public/refrence_data/customs-sections/${customs_code}`, data), + + /** + * Elimina una sección aduanera + * @param customs_code - Código de la sección aduanera a eliminar + */ + delete: (customs_code: string) => api.delete(`/v1/public/refrence_data/customs-sections/${customs_code}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/customs_warehouses.ts b/frontend/src/lib/api/dashboard/refrence_data/customs_warehouses.ts new file mode 100644 index 00000000..5b2bb3c1 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/customs_warehouses.ts @@ -0,0 +1,77 @@ +/** + * API Client para Customs Warehouses + * Gestiona las operaciones CRUD para los recintos fiscalizados + */ +import { api } from '$lib/api'; + +export interface CustomsWarehouse { + key: string; + customs: string; + fiscalized_warehouse: string; +} + +export interface CustomsWarehouseListResponse { + items: CustomsWarehouse[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateCustomsWarehouseData { + key: string; + customs: string; + fiscalized_warehouse: string; +} + +export interface UpdateCustomsWarehouseData { + key?: string; + customs?: string; + fiscalized_warehouse?: string; +} + +/** + * API para Customs Warehouses + */ +export const customsWarehousesApi = { + /** + * Lista todos los recintos fiscalizados con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/customs-warehouses?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un recinto fiscalizado por clave compuesta (key + customs) + * @param key - Clave del recinto + * @param customs - Aduana asociada + */ + get: (key: string, customs: string) => + api.get(`/v1/public/refrence_data/customs-warehouses/${key}/${customs}`), + + /** + * Crea un nuevo recinto fiscalizado + * @param data - Datos del recinto fiscalizado a crear + */ + create: (data: CreateCustomsWarehouseData) => + api.post('/v1/public/refrence_data/customs-warehouses', data), + + /** + * Actualiza un recinto fiscalizado existente + * @param key - Clave del recinto a actualizar + * @param customs - Aduana asociada + * @param data - Datos a actualizar + */ + update: (key: string, customs: string, data: UpdateCustomsWarehouseData) => + api.put(`/v1/public/refrence_data/customs-warehouses/${key}/${customs}`, data), + + /** + * Elimina un recinto fiscalizado + * @param key - Clave del recinto a eliminar + * @param customs - Aduana asociada + */ + delete: (key: string, customs: string) => + api.delete(`/v1/public/refrence_data/customs-warehouses/${key}/${customs}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/incoterms.ts b/frontend/src/lib/api/dashboard/refrence_data/incoterms.ts new file mode 100644 index 00000000..a83609e1 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/incoterms.ts @@ -0,0 +1,72 @@ +/** + * API Client para Incoterms + * Gestiona las operaciones CRUD para los términos internacionales de comercio + */ +import { api } from '$lib/api'; + +export interface Incoterm { + code: string; + description_es: string; + description_en: string; +} + +export interface IncotermListResponse { + items: Incoterm[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateIncotermData { + code: string; + description_es: string; + description_en: string; +} + +export interface UpdateIncotermData { + code?: string; + description_es?: string; + description_en?: string; +} + +/** + * API para Incoterms + */ +export const incotermsApi = { + /** + * Lista todos los incoterms con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/incoterms?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un incoterm por código + * @param code - Código del incoterm + */ + get: (code: string) => api.get(`/v1/public/refrence_data/incoterms/${code}`), + + /** + * Crea un nuevo incoterm + * @param data - Datos del incoterm a crear + */ + create: (data: CreateIncotermData) => + api.post('/v1/public/refrence_data/incoterms', data), + + /** + * Actualiza un incoterm existente + * @param code - Código del incoterm a actualizar + * @param data - Datos a actualizar + */ + update: (code: string, data: UpdateIncotermData) => + api.put(`/v1/public/refrence_data/incoterms/${code}`, data), + + /** + * Elimina un incoterm + * @param code - Código del incoterm a eliminar + */ + delete: (code: string) => api.delete(`/v1/public/refrence_data/incoterms/${code}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/invoice_types.ts b/frontend/src/lib/api/dashboard/refrence_data/invoice_types.ts new file mode 100644 index 00000000..67bdae54 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/invoice_types.ts @@ -0,0 +1,85 @@ +/** + * API Client para Invoice Types + * Gestiona las operaciones CRUD para los tipos de factura + */ +import { api } from '$lib/api'; + +export interface InvoiceType { + key: string; + description: string; + note?: string; + type?: string; + operation?: string; +} + +export interface InvoiceTypeListResponse { + items: InvoiceType[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateInvoiceTypeData { + key: string; + description: string; + note?: string; + type?: string; +} + +export interface UpdateInvoiceTypeData { + key?: string; + description?: string; + note?: string; + type?: string; +} + +/** + * API para Invoice Types + */ +export const invoiceTypesApi = { + /** + * Lista todos los tipos de factura con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + * @param operation - Filtrar por tipo de operación (imp, exp) + */ + list: (page = 1, pageSize = 50, operation?: string) => { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString() + }); + if (operation) { + params.append('operation', operation); + } + return api.get( + `/v1/public/refrence_data/invoice-types?${params.toString()}` + ); + }, + + /** + * Obtiene un tipo de factura por key + * @param key - Clave del tipo de factura + */ + get: (key: string) => api.get(`/v1/public/refrence_data/invoice-types/${key}`), + + /** + * Crea un nuevo tipo de factura + * @param data - Datos del tipo de factura a crear + */ + create: (data: CreateInvoiceTypeData) => + api.post('/v1/public/refrence_data/invoice-types', data), + + /** + * Actualiza un tipo de factura existente + * @param key - Clave del tipo de factura a actualizar + * @param data - Datos a actualizar + */ + update: (key: string, data: UpdateInvoiceTypeData) => + api.put(`/v1/public/refrence_data/invoice-types/${key}`, data), + + /** + * Elimina un tipo de factura + * @param key - Clave del tipo de factura a eliminar + */ + delete: (key: string) => api.delete(`/v1/public/refrence_data/invoice-types/${key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/material_types.ts b/frontend/src/lib/api/dashboard/refrence_data/material_types.ts new file mode 100644 index 00000000..e9457401 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/material_types.ts @@ -0,0 +1,72 @@ +/** + * API Client para Material Types + * Gestiona las operaciones CRUD para los tipos de material + */ +import { api } from '$lib/api'; + +export interface MaterialType { + key: string; + type: string; + description: string; +} + +export interface MaterialTypeListResponse { + items: MaterialType[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateMaterialTypeData { + key: string; + type: string; + description: string; +} + +export interface UpdateMaterialTypeData { + key?: string; + type?: string; + description?: string; +} + +/** + * API para Material Types + */ +export const materialTypesApi = { + /** + * Lista todos los tipos de material con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/material-types?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un tipo de material por key + * @param key - Clave del tipo de material + */ + get: (key: string) => api.get(`/v1/public/refrence_data/material-types/${key}`), + + /** + * Crea un nuevo tipo de material + * @param data - Datos del tipo de material a crear + */ + create: (data: CreateMaterialTypeData) => + api.post('/v1/public/refrence_data/material-types', data), + + /** + * Actualiza un tipo de material existente + * @param key - Clave del tipo de material a actualizar + * @param data - Datos a actualizar + */ + update: (key: string, data: UpdateMaterialTypeData) => + api.put(`/v1/public/refrence_data/material-types/${key}`, data), + + /** + * Elimina un tipo de material + * @param key - Clave del tipo de material a eliminar + */ + delete: (key: string) => api.delete(`/v1/public/refrence_data/material-types/${key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/payment_methods.ts b/frontend/src/lib/api/dashboard/refrence_data/payment_methods.ts new file mode 100644 index 00000000..cf651f59 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/payment_methods.ts @@ -0,0 +1,69 @@ +/** + * API Client para Payment Methods + * Gestiona las operaciones CRUD para los métodos de pago + */ +import { api } from '$lib/api'; + +export interface PaymentMethod { + key: string; + description: string; +} + +export interface PaymentMethodListResponse { + items: PaymentMethod[]; + total: number; + page: number; + page_size: number; +} + +export interface CreatePaymentMethodData { + key: string; + description: string; +} + +export interface UpdatePaymentMethodData { + key?: string; + description?: string; +} + +/** + * API para Payment Methods + */ +export const paymentMethodsApi = { + /** + * Lista todos los métodos de pago con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/payment-methods?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un método de pago por key + * @param key - Clave del método de pago + */ + get: (key: string) => api.get(`/v1/public/refrence_data/payment-methods/${key}`), + + /** + * Crea un nuevo método de pago + * @param data - Datos del método de pago a crear + */ + create: (data: CreatePaymentMethodData) => + api.post('/v1/public/refrence_data/payment-methods', data), + + /** + * Actualiza un método de pago existente + * @param key - Clave del método de pago a actualizar + * @param data - Datos a actualizar + */ + update: (key: string, data: UpdatePaymentMethodData) => + api.put(`/v1/public/refrence_data/payment-methods/${key}`, data), + + /** + * Elimina un método de pago + * @param key - Clave del método de pago a eliminar + */ + delete: (key: string) => api.delete(`/v1/public/refrence_data/payment-methods/${key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/pedimento_codes.ts b/frontend/src/lib/api/dashboard/refrence_data/pedimento_codes.ts new file mode 100644 index 00000000..36d0d46e --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/pedimento_codes.ts @@ -0,0 +1,69 @@ +/** + * API Client para Pedimento Codes + * Gestiona las operaciones CRUD para las claves de pedimento + */ +import { api } from '$lib/api'; + +export interface PedimentoCode { + code: string; + description: string; +} + +export interface PedimentoCodeListResponse { + items: PedimentoCode[]; + total: number; + page: number; + page_size: number; +} + +export interface CreatePedimentoCodeData { + code: string; + description: string; +} + +export interface UpdatePedimentoCodeData { + code?: string; + description?: string; +} + +/** + * API para Pedimento Codes + */ +export const pedimentoCodesApi = { + /** + * Lista todas las claves de pedimento con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/pedimento-codes?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene una clave de pedimento por code + * @param code - Código de la clave de pedimento + */ + get: (code: string) => api.get(`/v1/public/refrence_data/pedimento-codes/${code}`), + + /** + * Crea una nueva clave de pedimento + * @param data - Datos de la clave de pedimento a crear + */ + create: (data: CreatePedimentoCodeData) => + api.post('/v1/public/refrence_data/pedimento-codes', data), + + /** + * Actualiza una clave de pedimento existente + * @param code - Código de la clave de pedimento a actualizar + * @param data - Datos a actualizar + */ + update: (code: string, data: UpdatePedimentoCodeData) => + api.put(`/v1/public/refrence_data/pedimento-codes/${code}`, data), + + /** + * Elimina una clave de pedimento + * @param code - Código de la clave de pedimento a eliminar + */ + delete: (code: string) => api.delete(`/v1/public/refrence_data/pedimento-codes/${code}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/pedimento_regimens.ts b/frontend/src/lib/api/dashboard/refrence_data/pedimento_regimens.ts new file mode 100644 index 00000000..2fdf716c --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/pedimento_regimens.ts @@ -0,0 +1,69 @@ +/** + * API Client para Pedimento Regimens + * Gestiona las operaciones CRUD para los regímenes de pedimento + */ +import { api } from '$lib/api'; + +export interface PedimentoRegimen { + code: string; + description: string; +} + +export interface PedimentoRegimenListResponse { + items: PedimentoRegimen[]; + total: number; + page: number; + page_size: number; +} + +export interface CreatePedimentoRegimenData { + code: string; + description: string; +} + +export interface UpdatePedimentoRegimenData { + code?: string; + description?: string; +} + +/** + * API para Pedimento Regimens + */ +export const pedimentoRegimensApi = { + /** + * Lista todos los regímenes de pedimento con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/pedimento-regimens?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un régimen de pedimento por code + * @param code - Código del régimen de pedimento + */ + get: (code: string) => api.get(`/v1/public/refrence_data/pedimento-regimens/${code}`), + + /** + * Crea un nuevo régimen de pedimento + * @param data - Datos del régimen de pedimento a crear + */ + create: (data: CreatePedimentoRegimenData) => + api.post('/v1/public/refrence_data/pedimento-regimens', data), + + /** + * Actualiza un régimen de pedimento existente + * @param code - Código del régimen de pedimento a actualizar + * @param data - Datos a actualizar + */ + update: (code: string, data: UpdatePedimentoRegimenData) => + api.put(`/v1/public/refrence_data/pedimento-regimens/${code}`, data), + + /** + * Elimina un régimen de pedimento + * @param code - Código del régimen de pedimento a eliminar + */ + delete: (code: string) => api.delete(`/v1/public/refrence_data/pedimento-regimens/${code}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/sectors.ts b/frontend/src/lib/api/dashboard/refrence_data/sectors.ts new file mode 100644 index 00000000..1d334cf0 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/sectors.ts @@ -0,0 +1,72 @@ +/** + * API Client para Sectors + * Gestiona las operaciones CRUD para los sectores + */ +import { api } from '$lib/api'; + +export interface Sector { + key: string; + description: string; + authorized: number; +} + +export interface SectorListResponse { + items: Sector[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateSectorData { + key: string; + description: string; + authorized: number; +} + +export interface UpdateSectorData { + key?: string; + description?: string; + authorized?: number; +} + +/** + * API para Sectors + */ +export const sectorsApi = { + /** + * Lista todos los sectores con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/sectors?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un sector por key + * @param key - Clave del sector + */ + get: (key: string) => api.get(`/v1/public/refrence_data/sectors/${key}`), + + /** + * Crea un nuevo sector + * @param data - Datos del sector a crear + */ + create: (data: CreateSectorData) => + api.post('/v1/public/refrence_data/sectors', data), + + /** + * Actualiza un sector existente + * @param key - Clave del sector a actualizar + * @param data - Datos a actualizar + */ + update: (key: string, data: UpdateSectorData) => + api.put(`/v1/public/refrence_data/sectors/${key}`, data), + + /** + * Elimina un sector + * @param key - Clave del sector a eliminar + */ + delete: (key: string) => api.delete(`/v1/public/refrence_data/sectors/${key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/states.ts b/frontend/src/lib/api/dashboard/refrence_data/states.ts new file mode 100644 index 00000000..ed24c5c4 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/states.ts @@ -0,0 +1,75 @@ +/** + * API Client para States + * Gestiona las operaciones CRUD para los estados + */ +import { api } from '$lib/api'; + +export interface State { + m3_key: string; + description: string; + mex_key?: string | null; + ame_key?: string | null; +} + +export interface StateListResponse { + items: State[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateStateData { + m3_key: string; + description: string; + mex_key?: string | null; + ame_key?: string | null; +} + +export interface UpdateStateData { + m3_key?: string; + description?: string; + mex_key?: string | null; + ame_key?: string | null; +} + +/** + * API para States + */ +export const statesApi = { + /** + * Lista todos los estados con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/states?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un estado por m3_key + * @param m3Key - Clave M3 del estado + */ + get: (m3Key: string) => api.get(`/v1/public/refrence_data/states/${m3Key}`), + + /** + * Crea un nuevo estado + * @param data - Datos del estado a crear + */ + create: (data: CreateStateData) => + api.post('/v1/public/refrence_data/states', data), + + /** + * Actualiza un estado existente + * @param m3Key - Clave M3 del estado a actualizar + * @param data - Datos a actualizar + */ + update: (m3Key: string, data: UpdateStateData) => + api.put(`/v1/public/refrence_data/states/${m3Key}`, data), + + /** + * Elimina un estado + * @param m3Key - Clave M3 del estado a eliminar + */ + delete: (m3Key: string) => api.delete(`/v1/public/refrence_data/states/${m3Key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/transport_modes.ts b/frontend/src/lib/api/dashboard/refrence_data/transport_modes.ts new file mode 100644 index 00000000..f62d6b56 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/transport_modes.ts @@ -0,0 +1,69 @@ +/** + * API Client para Transport Modes + * Gestiona las operaciones CRUD para los modos de transporte + */ +import { api } from '$lib/api'; + +export interface TransportMode { + key: string; + name: string; +} + +export interface TransportModeListResponse { + items: TransportMode[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateTransportModeData { + key: string; + name: string; +} + +export interface UpdateTransportModeData { + key?: string; + name?: string; +} + +/** + * API para Transport Modes + */ +export const transportModesApi = { + /** + * Lista todos los modos de transporte con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/transport-modes?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un modo de transporte por key + * @param key - Clave del modo de transporte + */ + get: (key: string) => api.get(`/v1/public/refrence_data/transport-modes/${key}`), + + /** + * Crea un nuevo modo de transporte + * @param data - Datos del modo de transporte a crear + */ + create: (data: CreateTransportModeData) => + api.post('/v1/public/refrence_data/transport-modes', data), + + /** + * Actualiza un modo de transporte existente + * @param key - Clave del modo de transporte a actualizar + * @param data - Datos a actualizar + */ + update: (key: string, data: UpdateTransportModeData) => + api.put(`/v1/public/refrence_data/transport-modes/${key}`, data), + + /** + * Elimina un modo de transporte + * @param key - Clave del modo de transporte a eliminar + */ + delete: (key: string) => api.delete(`/v1/public/refrence_data/transport-modes/${key}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/transport_types.ts b/frontend/src/lib/api/dashboard/refrence_data/transport_types.ts new file mode 100644 index 00000000..bbb2e06b --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/transport_types.ts @@ -0,0 +1,69 @@ +/** + * API Client para Transport Types + * Gestiona las operaciones CRUD para los tipos de transporte + */ +import { api } from '$lib/api'; + +export interface TransportType { + transport_code: string; + description: string; +} + +export interface TransportTypeListResponse { + items: TransportType[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateTransportTypeData { + transport_code: string; + description: string; +} + +export interface UpdateTransportTypeData { + transport_code?: string; + description?: string; +} + +/** + * API para Transport Types + */ +export const transportTypesApi = { + /** + * Lista todos los tipos de transporte con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/transport-types?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un tipo de transporte por transport_code + * @param transportCode - Código del tipo de transporte + */ + get: (transportCode: string) => api.get(`/v1/public/refrence_data/transport-types/${transportCode}`), + + /** + * Crea un nuevo tipo de transporte + * @param data - Datos del tipo de transporte a crear + */ + create: (data: CreateTransportTypeData) => + api.post('/v1/public/refrence_data/transport-types', data), + + /** + * Actualiza un tipo de transporte existente + * @param transportCode - Código del tipo de transporte a actualizar + * @param data - Datos a actualizar + */ + update: (transportCode: string, data: UpdateTransportTypeData) => + api.put(`/v1/public/refrence_data/transport-types/${transportCode}`, data), + + /** + * Elimina un tipo de transporte + * @param transportCode - Código del tipo de transporte a eliminar + */ + delete: (transportCode: string) => api.delete(`/v1/public/refrence_data/transport-types/${transportCode}`) +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/valuation_methods.ts b/frontend/src/lib/api/dashboard/refrence_data/valuation_methods.ts new file mode 100644 index 00000000..a4277151 --- /dev/null +++ b/frontend/src/lib/api/dashboard/refrence_data/valuation_methods.ts @@ -0,0 +1,69 @@ +/** + * API Client para Valuation Methods + * Gestiona las operaciones CRUD para los métodos de valoración + */ +import { api } from '$lib/api'; + +export interface ValuationMethod { + key: string; + description: string; +} + +export interface ValuationMethodListResponse { + items: ValuationMethod[]; + total: number; + page: number; + page_size: number; +} + +export interface CreateValuationMethodData { + key: string; + description: string; +} + +export interface UpdateValuationMethodData { + key?: string; + description?: string; +} + +/** + * API para Valuation Methods + */ +export const valuationMethodsApi = { + /** + * Lista todos los métodos de valoración con paginación + * @param page - Número de página (por defecto 1) + * @param pageSize - Tamaño de página (por defecto 50) + */ + list: (page = 1, pageSize = 50) => + api.get( + `/v1/public/refrence_data/valuation-methods?page=${page}&page_size=${pageSize}` + ), + + /** + * Obtiene un método de valoración por key + * @param key - Clave del método de valoración + */ + get: (key: string) => api.get(`/v1/public/refrence_data/valuation-methods/${key}`), + + /** + * Crea un nuevo método de valoración + * @param data - Datos del método de valoración a crear + */ + create: (data: CreateValuationMethodData) => + api.post('/v1/public/refrence_data/valuation-methods', data), + + /** + * Actualiza un método de valoración existente + * @param key - Clave del método de valoración a actualizar + * @param data - Datos a actualizar + */ + update: (key: string, data: UpdateValuationMethodData) => + api.put(`/v1/public/refrence_data/valuation-methods/${key}`, data), + + /** + * Elimina un método de valoración + * @param key - Clave del método de valoración a eliminar + */ + delete: (key: string) => api.delete(`/v1/public/refrence_data/valuation-methods/${key}`) +}; diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg index cc5dc66a..6ab796db 100644 --- a/frontend/src/lib/assets/favicon.svg +++ b/frontend/src/lib/assets/favicon.svg @@ -1 +1 @@ -svelte-logo \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index ff0b5933..c15e97b6 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -173,8 +173,11 @@ export const initKeycloak = async (): Promise => { } }; +// Variable para rastrear el tenant anterior +let previousTenantId: number | undefined = undefined; + /** - * Actualiza el estado de autenticación + * Actualiza el estado de autenticación con los datos de Keycloak */ const updateAuthState = async () => { if (!keycloakInstance?.authenticated) { @@ -189,19 +192,36 @@ const updateAuthState = async () => { const roles = tokenParsed?.realm_access?.roles || []; const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id; + const newTenantId = tenantId ? parseInt(tenantId) : undefined; + + // Detectar si cambió el tenant + const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId; const user: User = { id: profile.id || '', username: profile.username || '', email: profile.email, name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(), - tenantId: tenantId ? parseInt(tenantId) : undefined, + tenantId: newTenantId, roles }; authStore.setAuthenticated(true); authStore.setUser(user); authStore.setToken(token); + + // Si cambió el tenant, limpiar el store de compañías + if (tenantChanged && browser) { + try { + const { companyStore } = await import('./stores/company.svelte'); + companyStore.clear(); + } catch (error) { + console.error('Error al limpiar store de compañías:', error); + } + } + + // Actualizar el tenant anterior + previousTenantId = newTenantId; } catch (error) { console.error('Error actualizando estado de autenticación:', error); authStore.reset(); @@ -289,6 +309,9 @@ export const login = async (credentials: { // Guardar en cookies para que el servidor pueda acceder setCookie('access_token', loginData.access_token); + if (loginData.refresh_token) { + setCookie('refresh_token', loginData.refresh_token); + } } // Cargar información del usuario @@ -358,10 +381,20 @@ export const logout = async () => { } } + // Limpiar store de compañías + try { + const { companyStore } = await import('./stores/company.svelte'); + companyStore.clear(); + } catch (error) { + console.error('Error al limpiar store de compañías:', error); + } + // Limpiar estado local authStore.reset(); localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); + deleteCookie('access_token'); + deleteCookie('refresh_token'); // Si hay instancia de Keycloak, hacer logout de Keycloak if (keycloakInstance?.authenticated) { @@ -405,12 +438,74 @@ export const getToken = (): string | null => { // Si no, intentar de localStorage if (browser) { - return localStorage.getItem('access_token'); + let token = localStorage.getItem('access_token'); + + // Si no hay token en localStorage, intentar de las cookies + if (!token) { + token = getCookie('access_token'); + // Si lo encontramos en cookies, sincronizarlo a localStorage + if (token) { + localStorage.setItem('access_token', token); + } + } + + return token; } return null; }; +/** + * Refresca el access token usando el refresh token + */ +export const refreshAccessToken = async (): Promise => { + if (!browser) return false; + + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) { + return false; + } + + try { + const { api } = await import('./api'); + const response = await api.auth.refresh(refreshToken); + + if (response.error || !response.data) { + console.error('Failed to refresh token:', response.error); + // Si falla el refresh, hacer logout + await logout(); + return false; + } + + // Actualizar tokens + const newAccessToken = response.data.access_token; + const newRefreshToken = response.data.refresh_token; + + authStore.setToken(newAccessToken); + localStorage.setItem('access_token', newAccessToken); + + if (newRefreshToken) { + localStorage.setItem('refresh_token', newRefreshToken); + } + + // Actualizar también la cookie + setCookie('access_token', newAccessToken); + return true; + + } catch (error) { + await logout(); + return false; + } +}; + +/** + * Obtiene el refresh token + */ +export const getRefreshToken = (): string | null => { + if (!browser) return null; + return localStorage.getItem('refresh_token'); +}; + /** * Obtiene la instancia de Keycloak */ diff --git a/frontend/src/lib/components/dashboard/classes/columns.ts b/frontend/src/lib/components/dashboard/classes/columns.ts new file mode 100644 index 00000000..f5535b3c --- /dev/null +++ b/frontend/src/lib/components/dashboard/classes/columns.ts @@ -0,0 +1,177 @@ +import type { ColumnDef } from "@tanstack/table-core"; +import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/index.js"; +import { createRawSnippet } from "svelte"; +import DataTableActions from "./data-table-actions.svelte"; +import type { A76Class } from "$lib/api/dashboard/a76/classes"; + +/** + * Formatea una fecha + */ +function formatDate(date?: string | null): string { + if (!date) return '-'; + return new Date(date).toLocaleDateString('es-MX', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +/** + * Obtiene el color del badge según el tipo de revisión física + */ +function getReviewColor(physicalReview: number): string { + return physicalReview === 1 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-green-100 text-green-800'; +} + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + { + accessorKey: "id", + header: "ID", + cell: ({ row }) => { + const idSnippet = createRawSnippet<[{ id: number }]>((getId) => { + const { id } = getId(); + return { + render: () => + `
#${id}
` + }; + }); + return renderSnippet(idSnippet, { id: row.original.id }); + } + }, + { + accessorKey: "class_code", + header: "Código de Clase", + cell: ({ row }) => { + const codeSnippet = createRawSnippet<[{ code: string }]>((getCode) => { + const { code } = getCode(); + return { + render: () => + `${code}` + }; + }); + return renderSnippet(codeSnippet, { code: row.original.class_code }); + } + }, + { + accessorKey: "description_es", + header: "Descripción", + cell: ({ row }) => { + const description = row.original.description_es || row.original.description_en || '-'; + const descSnippet = createRawSnippet<[{ desc: string }]>((getDesc) => { + const { desc } = getDesc(); + return { + render: () => + `
${desc}
` + }; + }); + return renderSnippet(descSnippet, { desc: description }); + } + }, + { + accessorKey: "fraction", + header: "Fracción", + cell: ({ row }) => { + const fractionSnippet = createRawSnippet<[{ fraction: string }]>((getFraction) => { + const { fraction } = getFraction(); + return { + render: () => + `
${fraction}
` + }; + }); + return renderSnippet(fractionSnippet, { fraction: row.original.fraction }); + } + }, + { + accessorKey: "us_fraction", + header: "Fracción US", + cell: ({ row }) => { + const usFractionSnippet = createRawSnippet<[{ usFraction: string }]>((getUsFraction) => { + const { usFraction } = getUsFraction(); + return { + render: () => + `
${usFraction}
` + }; + }); + return renderSnippet(usFractionSnippet, { usFraction: row.original.us_fraction }); + } + }, + { + accessorKey: "unit_of_measure", + header: "Unidad", + cell: ({ row }) => { + const unitSnippet = createRawSnippet<[{ unit: string }]>((getUnit) => { + const { unit } = getUnit(); + return { + render: () => + ` + ${unit} + ` + }; + }); + return renderSnippet(unitSnippet, { unit: row.original.unit_of_measure }); + } + }, + { + accessorKey: "physical_review", + header: "Rev. Física", + cell: ({ row }) => { + const review = row.original.physical_review; + const colorClass = getReviewColor(review); + const label = review === 1 ? 'Sí' : 'No'; + + const reviewSnippet = createRawSnippet<[{ label: string; colorClass: string }]>((getReview) => { + const { label, colorClass } = getReview(); + return { + render: () => + ` + ${label} + ` + }; + }); + return renderSnippet(reviewSnippet, { label, colorClass }); + } + }, + { + accessorKey: "client_id", + header: "Cliente", + cell: ({ row }) => { + const clientSnippet = createRawSnippet<[{ clientId: number }]>((getClient) => { + const { clientId } = getClient(); + return { + render: () => + `
Cliente #${clientId}
` + }; + }); + return renderSnippet(clientSnippet, { clientId: row.original.client_id }); + } + }, + { + accessorKey: "created_at", + header: "Fecha de Creación", + cell: ({ row }) => { + const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { + const { date } = getDate(); + return { + render: () => + `
${date}
` + }; + }); + return renderSnippet(dateSnippet, { date: formatDate(row.original.created_at) }); + } + }, + { + id: "actions", + cell: ({ row }) => { + return renderComponent(DataTableActions, { item: row.original, onSuccess }); + } + } + ]; +} + +// Mantener compatibilidad hacia atrás +export const columns = createColumns(); diff --git a/frontend/src/lib/components/dashboard/classes/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/classes/create-edit-dialog.svelte new file mode 100644 index 00000000..aaaaafa4 --- /dev/null +++ b/frontend/src/lib/components/dashboard/classes/create-edit-dialog.svelte @@ -0,0 +1,520 @@ + + + + + + {title} + + {isEdit ? 'Modifica los datos de la clase' : 'Completa los datos para crear una nueva clase'} + + + +
+ + {#if error} +
+ {error} +
+ {/if} + + + {#if companyStore.activeCompany} +
+
+ + + + +
+

+ {companyStore.activeCompany.name} +

+

+ ID: {companyStore.activeCompany.id} +

+
+
+
+ {/if} + + +
+ + {#if loadingClients} +
+
+ Cargando clientes... +
+ {:else if clients.length > 0} + + {:else} +
+ No hay clientes disponibles +
+ {/if} +
+ + +
+ + +
+ + +
+
+ + +
+
+ + + +
+

Listado de precintos separados por comas

+
+ + +
+
+ + + + + + + + + + (isGuiasDialogOpen = open)}> + + + Guías de Pedimento + +
+
+ + +
+ +
+ + v && (currentGuia.identificador = v)} + > + + {currentGuia.identificador || 'Seleccionar identificador'} + + + Maestra + + +
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/contribuciones-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/contribuciones-tab-form.svelte new file mode 100644 index 00000000..b781de36 --- /dev/null +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/contribuciones-tab-form.svelte @@ -0,0 +1,715 @@ + + + + + +
+ + + + + +
+ + +
+ {#if activeTab === 'DTA'} +
+
+ + +
+
+ + +

(dejar en cero para calcular automático)

+
+
+ + +

(dejar en cero para calcular automático)

+
+
+ {:else if activeTab === 'PREV'} +
+
+ + +
+
+ {:else if activeTab === 'ECI'} +
+
+ + +
+
+ + +

+ (dejar en cero para que el sistema calcule el importe) +

+
+
+ {:else if activeTab === 'MULT'} +
+
+ + +
+
+ + +
+
+ {:else if activeTab === 'REC'} +
+
+ + +
+
+ + +
+
+ {/if} +
+ + +
+ + + + Contribución + T.T. + Tasa + F.P. + Importe + Gravamen + Abreviación + F.P. + Importe + Acciones + + + + {#if !formData?.contribuciones || formData.contribuciones.length === 0} + + + No hay contribuciones registradas + + + {:else} + {#each formData.contribuciones as contribucion, index} + + {contribucion.contribucion} + {contribucion.tipo_tasa} + {contribucion.tasa.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 5 + })} + {contribucion.forma_pago} + {contribucion.importe.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + {contribucion.gravamen} + {contribucion.abreviacion} + {contribucion.forma_pago_2} + {contribucion.importe_2.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ + +
+
+
+ + formData && (formData.calculo_manual = checked === true)} + /> + +
+ +
+ + formData && (formData.operaciones_regla_31_40 = checked === true)} + /> + +
+
+ +
+ + + +
+
+
+
+ + + + + + + {editingIndex !== null ? 'Editar Contribución' : 'Nueva Contribución'} + + + +
+
+
+ + v && (currentContribucion.contribucion = v)} + > + + {currentContribucion.contribucion || 'Seleccionar contribución'} + + + {#each opcionesContribuciones as opcion} + {opcion} + {/each} + + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+ + + + + + + {editingContribGenIndex !== null ? 'Editar Contribución General' : 'Nueva Contribución General'} + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/cuentas-compensacion-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/cuentas-compensacion-tab-form.svelte new file mode 100644 index 00000000..7008c275 --- /dev/null +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/cuentas-compensacion-tab-form.svelte @@ -0,0 +1,867 @@ + + +
+ + + +
+ + + + Número Contrato + Tipo Cuenta + Institución Emisora + Folio Constancia + Fecha Constancia + Total Garantía + Tipo Garantía + Cantidad UMC + Acciones + + + + {#if !formData?.cuentas_garantia || formData.cuentas_garantia.length === 0} + + + No hay cuentas de garantía registradas + + + {:else} + {#each formData.cuentas_garantia as cuenta, index} + + {cuenta.numero_contrato} + {cuenta.tipo_cuenta} + {cuenta.institucion_emisora} + {cuenta.folio_constancia} + {cuenta.fecha_constancia} + {cuenta.total_garantia.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + {cuenta.tipo_garantia} + {cuenta.cantidad_umc.toLocaleString('es-MX', { + minimumFractionDigits: 4, + maximumFractionDigits: 4 + })} + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ +
+ + + +
+
+
+ + +
+ + + + Compensaciones + + +
+ + + + Patente Original + Pedimento Original + Aduana y Sección + Fecha Pago + Gravamen + Importe + Acciones + + + + {#if !formData?.compensaciones || formData.compensaciones.length === 0} + + + No hay compensaciones registradas + + + {:else} + {#each formData.compensaciones as compensacion, index} + + {compensacion.patente_original} + {compensacion.pedimento_original} + {compensacion.aduana_seccion_original} + {compensacion.fecha_pago_original} + {compensacion.gravamen} + {compensacion.importe.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ +
+ + + +
+
+
+ + + + + Documentos que amparan las Formas de Pago + + +
+ + + + Forma de Pago + Dependencia + Número Documento + Fecha Constancia + Importe Total + Saldo Disponible + Importe Pedimento + Acciones + + + + {#if !formData?.documentos_pago || formData.documentos_pago.length === 0} + + + No hay documentos registrados + + + {:else} + {#each formData.documentos_pago as documento, index} + + {documento.forma_pago} + {documento.dependencia} + {documento.numero_documento} + {documento.fecha_constancia} + {documento.importe_total.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + {documento.saldo_disponible.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + {documento.importe_pedimento.toLocaleString('es-MX', { + minimumFractionDigits: 0, + maximumFractionDigits: 0 + })} + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ +
+ + + +
+
+
+
+
+ + + + + + Cuentas Aduaneras a nivel Pedimento + + +
+ +
+

Cuenta:

+
+
+ + +
+
+ + v && (currentCuenta.tipo_cuenta = v)} + > + + {currentCuenta.tipo_cuenta || '0- Cuenta Aduane'} + + + 0- Cuenta Aduane + + +
+
+ +
+ v && (currentCuenta.institucion_emisora = v)} + > + + {currentCuenta.institucion_emisora || '1'} + + + 1 + + + +
+
+
+
+ + +
+

Constancia:

+
+
+ + +
+
+ + +
+
+
+ + +
+

Garantía:

+
+
+ + +
+
+ +
+ v && (currentCuenta.tipo_garantia = v)} + > + + {currentCuenta.tipo_garantia || '1'} + + + 1 + + + +
+
+
+
+ + +
+

Títulos:

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + + +
+
+ + + + + + Compensaciones + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
+ + + + + + Documentos que amparan las formas de pago: 4, 12, 15 y 19 + + +
+
+ +
+ v && (currentDocumento.forma_pago = v)} + > + + {currentDocumento.forma_pago || '0'} + + + 0 + + + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/dates-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/dates-tab-form.svelte new file mode 100644 index 00000000..070c664b --- /dev/null +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/dates-tab-form.svelte @@ -0,0 +1,83 @@ + + + + + Observaciones + + Agrega notas y observaciones sobre el pedimento + + + +
+ +
+
+
+ Tipo de Pedimento: + {tipoPedimentoNombre} +
+ {#if pedimentoNumber} +
+ Número de Pedimento: + {pedimentoNumber} +
+ {/if} +
+
+ +
+ + +
+
+
+
\ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/descargas-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/descargas-tab-form.svelte new file mode 100644 index 00000000..7695298b --- /dev/null +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/descargas-tab-form.svelte @@ -0,0 +1,495 @@ + + +
+ + + + Descargos + + +
+ + + + Patente + Pedimento + Aduana + Clave + Fecha Pago + Fracción + UMT + Cantidad Tarifa + Acciones + + + + {#if !formData?.descargos || formData.descargos.length === 0} + + + No hay descargos registrados + + + {:else} + {#each formData.descargos as descargo, index} + + {descargo.patente} + {descargo.pedimento} + {descargo.aduana} + {descargo.clave} + {descargo.fecha_pago} + {descargo.fraccion} + {descargo.umt} + {descargo.cantidad_tarifa.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ +
+ + + + + +
+
+
+ + + + + Destinatarios + + +
+ + + + Clave + Identificación Fiscal + Nombre + Acciones + + + + {#if !formData?.destinatarios || formData.destinatarios.length === 0} + + + No hay destinatarios registrados + + + {:else} + {#each formData.destinatarios as destinatario, index} + + {destinatario.clave} + {destinatario.identificacion_fiscal} + {destinatario.nombre} + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ +
+ + + +
+
+
+
+ + + + + + + {editingDescargoIndex !== null ? 'Editar Descargo' : 'Nuevo Descargo'} + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+ + + + + + + {editingDestinatarioIndex !== null ? 'Editar Destinatario' : 'Nuevo Destinatario'} + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/digitalizacion-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/digitalizacion-tab-form.svelte new file mode 100644 index 00000000..689ee413 --- /dev/null +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/digitalizacion-tab-form.svelte @@ -0,0 +1,414 @@ + + + + + +
+ + + + Línea + Clave + Documento + E-Document + Operación + Observaciones + Acciones + + + + {#if !formData?.digitalizaciones || formData.digitalizaciones.length === 0} + + + No hay digitalizaciones registradas + + + {:else if paginatedDigitalizaciones.length === 0} + + + No hay datos en esta página + + + {:else} + {#each paginatedDigitalizaciones as digitalizacion, index} + + {digitalizacion.linea} + {digitalizacion.clave} + {digitalizacion.documento} + {digitalizacion.e_document} + {digitalizacion.operacion} + + {digitalizacion.observaciones} + + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ + + {#if formData?.digitalizaciones && formData.digitalizaciones.length > 0} +
+ + + + Página {currentPage + 1} de {totalPages || 1} + + + +
+ {/if} + + +
+ + + + + + + + + +
+
+
+ + + + + + + {editingIndex !== null ? 'Editar Digitalización' : 'Nueva Digitalización'} + + + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + {/if} + + + {#if showNotas} + + + Notas del Pedimento + + + +
+ + + + Usuario + Creada + Comentario + + + + Fecha + Hora + + + + + {#if notasPedimento.length === 0} + + + No hay notas registradas + + + {:else} + {#each visibleNotas as nota} + + {nota.usuario} + {nota.fecha} + {nota.hora} + {nota.comentario} + + {/each} + {/if} + +
+
+ + +
+
+ + + + Página {currentNotasPage + 1} de {totalNotasPages || 1} + + + +
+
+ + +
+ + + +
+
+
+ {/if} + + + {#if showSeleccionAutomatizada} + + + Selección Automatizada + + +
+
+ + +
+ +
+ + { + if (v) seleccionAutomatizadaFormData.primera_revision = v; + }} + > + + + {seleccionAutomatizadaFormData.primera_revision || 'Seleccionar'} + + + + Rojo + Verde + + +
+ +
+ + +
+ +
+ + { + if (v) seleccionAutomatizadaFormData.segunda_revision = v; + }} + > + + + {seleccionAutomatizadaFormData.segunda_revision || 'Seleccionar'} + + + + Rojo + Verde + + +
+ +
+ + +
+
+
+
+ {/if} + + + {#if showMultas} + + + Multas + + +
+ + { + if (v) multasFormData.cargo = v; + }} + > + + + {multasFormData.cargo || 'Seleccionar'} + + + + cargo oficina + cargo cliente + + +
+
+
+ {/if} + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + Embarque Parcial de Mercancías + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Mercancías del Embarque

+
+ + + +
+
+ + +
+ + + + # + Descripción de Mercancía + Cantidad UMC + Cantidad UMT + Peso + + + + {#if mercancias.length === 0} + + + No hay mercancías registradas + + + {:else} + {#each mercancias as mercancia, index} + + {index + 1} + {mercancia.descripcion} + {mercancia.cantidad_umc.toFixed(3)} + {mercancia.cantidad_umt.toFixed(3)} + {mercancia.peso.toFixed(3)} + + {/each} + {/if} + +
+
+ + +
+ + + + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+
+ + + + + +
+
+ + + + + + Mercancías del Embarque Parcial + + +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + + + +
+
+ + + + + + Diferencias en contribuciones + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + +
+
+ + + + + + Notas del Pedimento + + +
+
+ + +
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/partidas-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/partidas-tab-form.svelte new file mode 100644 index 00000000..6e90abf8 --- /dev/null +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/partidas-tab-form.svelte @@ -0,0 +1,543 @@ + + + + + +
+ + + + + + + + +
+ + +
+ + + + Sel. + Línea + Fracción + Sub + Descripción + Tipo + Sector + Origen + Cant. Comercial + UMC + Dólares + Acciones + + + + {#if !formData?.partidas || formData.partidas.length === 0} + + + No hay partidas registradas. Haga clic en "Nuevo" para agregar una. + + + {:else} + {#each formData.partidas as partida, index} + + + toggleSelectPartida(index)} + /> + + {partida.linea} + {partida.fraccion} + {partida.sub} + + {partida.descripcion} + + {partida.tipo} + {partida.sector} + {partida.origen} + {partida.cantidad_comercial.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + {partida.umc} + {partida.dolares.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + +
+ + +
+
+
+ {/each} + {/if} +
+
+
+ + +
+
+
+ Partidas: + {totalPartidas} +
+
+ Dólares: + {totalDolares.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} USD +
+
+ Pesos: + 0.00 MXN +
+
+ Valor Comercial: + 0.00 +
+
+ +
+
+ Cant. UMC: + {totalCantidadComercial.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} +
+
+ Cant. UMT: + 0.00 +
+
+ Val Agreg. Pesos: + 0.00 MXN +
+
+ Val Agreg. Dólares: + 0.00 USD +
+
+
+ + +
+ + formData && (formData.convertir_uma_umc = checked === true)} + /> + +
+
+
+ + + + + + {editingIndex !== null ? 'Editar Partida' : 'Nueva Partida'} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + diff --git a/frontend/src/lib/components/ui/tooltip/index.ts b/frontend/src/lib/components/ui/tooltip/index.ts index 313a7f06..aacf780b 100644 --- a/frontend/src/lib/components/ui/tooltip/index.ts +++ b/frontend/src/lib/components/ui/tooltip/index.ts @@ -2,9 +2,10 @@ import { Tooltip as TooltipPrimitive } from "bits-ui"; import Trigger from "./tooltip-trigger.svelte"; import Content from "./tooltip-content.svelte"; -const Root = TooltipPrimitive.Root; -const Provider = TooltipPrimitive.Provider; -const Portal = TooltipPrimitive.Portal; +// Handle SSR safely +const Root = TooltipPrimitive?.Root ?? (class {} as any); +const Provider = TooltipPrimitive?.Provider ?? (class {} as any); +const Portal = TooltipPrimitive?.Portal ?? (class {} as any); export { Root, diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts new file mode 100644 index 00000000..41ec018b --- /dev/null +++ b/frontend/src/lib/server/api.ts @@ -0,0 +1,285 @@ +/** + * Utilidades para llamadas a la API desde el servidor (SSR) + * Centraliza la lógica de configuración de URL, autenticación y manejo de tokens + */ + +import { redirect, type Cookies } from '@sveltejs/kit'; + +/** + * Obtiene y normaliza la URL base de la API para llamadas desde el servidor + * Automáticamente reemplaza localhost/127.0.0.1 con 'backend' para Docker + */ +export function getServerApiUrl(): string { + let apiUrl = process.env.INTERNAL_API_URL; + + if (!apiUrl) { + apiUrl = import.meta.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL: asegurar que termine con '/' + return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; +} + +/** + * Obtiene los tokens de autenticación de las cookies + */ +export function getAuthTokens(cookies: Cookies) { + return { + accessToken: cookies.get('access_token'), + refreshToken: cookies.get('refresh_token') + }; +} + +/** + * Establece los tokens de autenticación en las cookies + */ +export function setAuthTokens( + cookies: Cookies, + accessToken: string, + refreshToken?: string +) { + cookies.set('access_token', accessToken, { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 7 // 7 días + }); + + if (refreshToken) { + cookies.set('refresh_token', refreshToken, { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 30 // 30 días + }); + } +} + +/** + * Limpia todos los tokens de autenticación de las cookies + */ +export function clearAuthTokens(cookies: Cookies) { + cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('active_company_id', { path: '/' }); +} + +/** + * Crea headers de autorización con el token Bearer + */ +export function createAuthHeaders(token: string, additionalHeaders?: Record) { + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...additionalHeaders + }; +} + +/** + * Intenta refrescar el token de acceso usando el refresh token + * @returns El nuevo access token o null si falla + */ +export async function refreshAccessToken( + cookies: Cookies, + fetch: typeof globalThis.fetch +): Promise { + const { refreshToken } = getAuthTokens(cookies); + + if (!refreshToken) { + return null; + } + + try { + const baseUrl = getServerApiUrl(); + const response = await fetch(`${baseUrl}v1/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + // Actualizar las cookies con los nuevos tokens + setAuthTokens(cookies, data.access_token, data.refresh_token); + + return data.access_token; + } catch (error) { + console.error('🔄 [API] Error al refrescar token:', error); + return null; + } +} + +/** + * Realiza una petición autenticada a la API con manejo automático de refresh + * @param endpoint - Endpoint relativo (ej: 'v1/auth/me') + * @param options - Opciones de fetch + * @param cookies - Objeto de cookies de SvelteKit + * @param fetch - Función fetch de SvelteKit + * @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional) + */ +export async function authenticatedFetch( + endpoint: string, + options: RequestInit = {}, + cookies: Cookies, + fetch: typeof globalThis.fetch, + redirectUrl?: string +): Promise { + try { + const baseUrl = getServerApiUrl(); + let { accessToken } = getAuthTokens(cookies); + + // Si no hay token, redirigir o lanzar error + if (!accessToken) { + if (redirectUrl) { + throw redirect(303, redirectUrl); + } + throw new Error('No access token available'); + } + + // Construir URL completa + const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; + + // Realizar la petición inicial + const headers = createAuthHeaders(accessToken, options.headers as Record); + let response = await fetch(url, { + ...options, + headers + }); + + // Si es 401, intentar refrescar el token + if (response.status === 401) { + const newToken = await refreshAccessToken(cookies, fetch); + + if (newToken) { + // Reintentar la petición con el nuevo token + const newHeaders = createAuthHeaders(newToken, options.headers as Record); + response = await fetch(url, { + ...options, + headers: newHeaders + }); + } else { + // No se pudo refrescar, limpiar y redirigir + clearAuthTokens(cookies); + if (redirectUrl) { + throw redirect(303, redirectUrl); + } + } + } + + return response; + } catch (error) { + // Si es un redirect, re-lanzarlo + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error); + + // Retornar una respuesta de error simulada en lugar de lanzar + return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +} + +/** + * Valida que el usuario esté autenticado y obtiene sus datos + * @returns Los datos del usuario o null si no está autenticado + */ +export async function validateAuth( + cookies: Cookies, + fetch: typeof globalThis.fetch, + redirectOnFail?: string +): Promise { + try { + const response = await authenticatedFetch( + 'v1/auth/me', + {}, + cookies, + fetch, + redirectOnFail + ); + + if (!response.ok) { + if (redirectOnFail) { + clearAuthTokens(cookies); + throw redirect(303, redirectOnFail); + } + return null; + } + + return await response.json(); + } catch (error) { + // Si es un redirect, re-lanzarlo + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + console.error('🔐 [API] Error validando autenticación:', error); + + if (redirectOnFail) { + clearAuthTokens(cookies); + throw redirect(303, redirectOnFail); + } + + return null; + } +} + +/** + * Obtiene las compañías del usuario autenticado + */ +export async function getUserCompanies( + cookies: Cookies, + fetch: typeof globalThis.fetch +): Promise { + try { + const response = await authenticatedFetch( + 'v1/a76/company/my-companies', + {}, + cookies, + fetch + ); + + if (!response.ok) { + console.error('🏢 [API] Error cargando compañías:', response.status); + return []; + } + + return await response.json(); + } catch (error) { + console.error('🏢 [API] Error cargando compañías:', error); + return []; + } +} + +/** + * Obtiene el ID de la compañía activa, o la primera disponible si no hay ninguna seleccionada + */ +export async function getActiveCompanyId( + cookies: Cookies, + fetch: typeof globalThis.fetch +): Promise { + let companyId = cookies.get('active_company_id'); + + // Si no hay companyId en cookie, obtener las compañías del usuario y usar la primera + if (!companyId) { + const companies = await getUserCompanies(cookies, fetch); + if (companies.length > 0) { + companyId = companies[0].id.toString(); + } + } + + return companyId || null; +} diff --git a/frontend/src/lib/sso.ts b/frontend/src/lib/sso.ts index 15b5c132..cb67ee25 100644 --- a/frontend/src/lib/sso.ts +++ b/frontend/src/lib/sso.ts @@ -12,7 +12,6 @@ export type SSOProvider = 'microsoft' | 'google' | 'github'; */ export const loginWithProvider = async (provider: SSOProvider): Promise => { if (!browser) { - console.warn('loginWithProvider solo funciona en el navegador'); return; } @@ -40,11 +39,6 @@ export const loginWithProvider = async (provider: SSOProvider): Promise => // URL de login de Keycloak con el provider específico const loginUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid&kc_idp_hint=${provider}`; - console.log('🔐 Iniciando login con', provider); - console.log('📍 URL de Keycloak:', keycloakUrl); - console.log('🏰 Realm:', realm); - console.log('🔑 Client ID:', clientId); - // Redirigir al usuario al proveedor SSO window.location.href = loginUrl; } catch (error) { diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts new file mode 100644 index 00000000..0714482d --- /dev/null +++ b/frontend/src/lib/stores/company.svelte.ts @@ -0,0 +1,193 @@ +/** + * Store para manejar la compañía activa del usuario + * Permite cambiar entre las compañías que pertenecen al tenant + */ + +import { browser } from '$app/environment'; + +interface Company { + id: number; + name: string; + rfc?: string; + logo?: string; + tenant_id: number; +} + +class CompanyStore { + private _activeCompany = $state(null); + private _companies = $state([]); + private _loading = $state(false); + private _currentTenantId = $state(null); + + get activeCompany() { + return this._activeCompany; + } + + get companies() { + return this._companies; + } + + get loading() { + return this._loading; + } + + /** + * Carga las compañías del tenant del usuario desde el backend + * @param preloadedCompanies - Compañías pre-cargadas desde el servidor (SSR) + */ + async loadCompanies(preloadedCompanies?: Company[]) { + // Si tenemos compañías pre-cargadas, usarlas directamente + if (preloadedCompanies && preloadedCompanies.length > 0) { + // Detectar si el tenant ha cambiado + const newTenantId = preloadedCompanies[0].tenant_id; + + // Si el tenant cambió, limpiar el store primero + if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) { + this.clear(); + } + + this._currentTenantId = newTenantId; + this._companies = preloadedCompanies; + + // Si hay compañías y no hay una activa, seleccionar la primera o la guardada + if (this._companies.length > 0 && !this._activeCompany) { + // Intentar restaurar la compañía guardada + if (typeof window !== 'undefined') { + const savedId = localStorage.getItem('activeCompanyId'); + if (savedId) { + const company = this._companies.find(c => c.id === parseInt(savedId)); + if (company) { + this.setActiveCompany(company, true); // silent=true para inicialización + return; + } + } + } + // Si no hay guardada, seleccionar la primera + this.setActiveCompany(this._companies[0], true); // silent=true para inicialización + } + return; + } + + // Si no hay datos pre-cargados, hacer fetch (fallback) + // Solo en el navegador, nunca durante SSR + if (!browser) { + return; + } + + this._loading = true; + try { + const response = await fetch('/api/v1/a76/company/my-companies'); + if (response.ok) { + const newCompanies = await response.json(); + + // Detectar si el tenant ha cambiado + if (newCompanies.length > 0) { + const newTenantId = newCompanies[0].tenant_id; + + // Si el tenant cambió, limpiar el store primero + if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) { + + this.clear(); + } + + this._currentTenantId = newTenantId; + } + + this._companies = newCompanies; + + // Si hay compañías y no hay una activa, seleccionar la primera + if (this._companies.length > 0 && !this._activeCompany) { + this.setActiveCompany(this._companies[0], true); // silent=true para inicialización + } + } else { + console.error('Error loading companies:', response.statusText); + // Si falla la carga (ej: 401), limpiar el store + if (response.status === 401) { + this.clear(); + } + } + } catch (error) { + console.error('Error loading companies:', error); + } finally { + this._loading = false; + } + } + + /** + * Establece la compañía activa + * @param company - La compañía a establecer como activa + * @param silent - Si es true, no dispara el evento companyChanged (para inicialización) + */ + setActiveCompany(company: Company, silent: boolean = false) { + const previousCompanyId = this._activeCompany?.id; + this._activeCompany = company; + + // Guardar en localStorage para persistencia + if (typeof window !== 'undefined') { + localStorage.setItem('activeCompanyId', company.id.toString()); + } + + // Guardar en cookie para acceso desde el servidor (SSR) + if (typeof document !== 'undefined') { + document.cookie = `active_company_id=${company.id}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`; + } + + // Despachar evento personalizado solo si: + // 1. No es silent (no es inicialización) + // 2. Y realmente cambió la compañía (el ID es diferente) + if (!silent && typeof window !== 'undefined' && previousCompanyId !== company.id) { + window.dispatchEvent(new CustomEvent('companyChanged', { + detail: { companyId: company.id } + })); + } + } + + /** + * Restaura la compañía activa desde localStorage + */ + restoreActiveCompany() { + if (typeof window !== 'undefined') { + const savedId = localStorage.getItem('activeCompanyId'); + if (savedId && this._companies.length > 0) { + const company = this._companies.find(c => c.id === parseInt(savedId)); + if (company) { + this._activeCompany = company; + } + } + } + } + + /** + * Limpia el store (útil al cambiar de tenant o cerrar sesión) + */ + clear() { + this._activeCompany = null; + this._companies = []; + this._loading = false; + this._currentTenantId = null; + + // Limpiar localStorage + if (typeof window !== 'undefined') { + localStorage.removeItem('activeCompanyId'); + } + + // Limpiar cookie + if (typeof document !== 'undefined') { + document.cookie = 'active_company_id=; path=/; max-age=0'; + } + } + + /** + * Inicializa el store cargando las compañías + * @param preloadedCompanies - Compañías pre-cargadas desde el servidor (SSR) + */ + async initialize(preloadedCompanies?: Company[]) { + await this.loadCompanies(preloadedCompanies); + // Si no hay compañías pre-cargadas, intentar restaurar de localStorage + if (!preloadedCompanies) { + this.restoreActiveCompany(); + } + } +} + +export const companyStore = new CompanyStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index d77165d0..8c56a3c6 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,30 +1,12 @@ -{#if initialized} - {@render children?.()} -{:else} -
-
-
-

Cargando Anexo76...

-
-
-{/if} +{@render children?.()} diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index d237e4e6..36adcfa4 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -1,14 +1,40 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch, clearAuthTokens } from '$lib/server/api'; -export const load: PageServerLoad = async ({ cookies }) => { - const token = cookies.get('access_token'); +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); - // Si está autenticado, redirigir al dashboard - if (token) { - throw redirect(303, '/dashboard'); + // Si hay token, validar que sea válido antes de redirigir + if (accessToken) { + try { + // Verificar si el token es válido usando authenticatedFetch + const response = await authenticatedFetch( + 'v1/auth/me', + {}, + cookies, + fetch + ); + + // Solo redirigir al dashboard si el token es válido + if (response.ok) { + throw redirect(303, '/dashboard'); + } else { + // Token inválido, limpiar cookies y mostrar la página pública + clearAuthTokens(cookies); + } + } catch (error) { + // Si es un redirect, re-lanzarlo + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + // Para otros errores, limpiar cookies y continuar + clearAuthTokens(cookies); + } } - // Si no está autenticado, redirigir al login - throw redirect(303, '/login'); + // Si no está autenticado, mostrar la página principal pública + return { + isAuthenticated: false + }; }; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 773d54ad..b1cdc8e3 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -77,7 +77,7 @@ Bienvenido a Anexo76

- Plataforma SaaS para gestión de comercio exterior conforme a Anexos 24, 31 y 22 del SAT. + Plataforma SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT. Ideal para maquilas, empresas IMMEX y agentes aduanales.

diff --git a/frontend/src/routes/api-sveltekit/company/my-companies/+server.ts b/frontend/src/routes/api-sveltekit/company/my-companies/+server.ts new file mode 100644 index 00000000..16544fa0 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/company/my-companies/+server.ts @@ -0,0 +1,48 @@ +/** + * API route proxy para obtener las compañías del usuario + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies, fetch }) => { + const token = cookies.get('access_token'); + + if (!token) { + // Limpiar cualquier cookie de compañía si no hay autenticación + cookies.delete('active_company_id', { path: '/' }); + return json({ error: 'No authenticated' }, { status: 401 }); + } + + // Configurar la URL de la API + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + try { + const response = await fetch(`${baseUrl}v1/a76/company/my-companies`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + // Si la autenticación falló, limpiar la cookie de compañía + if (response.status === 401) { + cookies.delete('active_company_id', { path: '/' }); + } + return json({ error: 'Failed to fetch companies' }, { status: response.status }); + } + const companies = await response.json(); + return json(companies); + } catch (error) { + console.error('Error fetching companies:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/frontend/src/routes/api-sveltekit/invoices/[id]/edit-data/+server.ts b/frontend/src/routes/api-sveltekit/invoices/[id]/edit-data/+server.ts new file mode 100644 index 00000000..48c5cd3f --- /dev/null +++ b/frontend/src/routes/api-sveltekit/invoices/[id]/edit-data/+server.ts @@ -0,0 +1,80 @@ +import type { RequestHandler } from './$types'; +import { json, error } from '@sveltejs/kit'; +import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api'; + +export const GET: RequestHandler = async ({ params, cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + throw error(401, 'Not authenticated'); + } + + const companyId = await getActiveCompanyId(cookies, fetch); + + if (!companyId) { + throw error(400, 'No company selected'); + } + + const invoiceId = parseInt(params.id); + if (isNaN(invoiceId)) { + throw error(400, 'Invalid invoice ID'); + } + + try { + // Cargar la factura y datos de referencia en paralelo + const [ + invoiceResponse, + invoiceTypesResponse, + customsBrokersResponse, + clientsResponse, + providersResponse, + currencyTypesResponse, + transportTypesResponse, + sealsResponse, + incotermsResponse, + pedimentosResponse + ] = await Promise.all([ + authenticatedFetch(`v1/a76/invoices/${invoiceId}?company_id=${companyId}`, {}, cookies, fetch), + authenticatedFetch('v1/public/refrence_data/invoice-types/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch(`v1/a76/customs-brokers/?company_id=${companyId}&page=1&page_size=100`, {}, cookies, fetch), + authenticatedFetch(`v1/a76/clients-providers/?company_id=${companyId}&type=client&page=1&page_size=1000`, {}, cookies, fetch), + authenticatedFetch(`v1/a76/clients-providers/?company_id=${companyId}&type=provider&page=1&page_size=1000`, {}, cookies, fetch), + authenticatedFetch('v1/public/refrence_data/currency-types/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch('v1/public/refrence_data/transport-types/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch(`v1/a76/seals/?company_id=${companyId}&page=1&page_size=100`, {}, cookies, fetch), + authenticatedFetch('v1/public/refrence_data/incoterms/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch(`v1/a76/pedimentos/?company_id=${companyId}&page=1&page_size=100`, {}, cookies, fetch) + ]); + + if (!invoiceResponse.ok) { + throw error(invoiceResponse.status, 'Error loading invoice'); + } + + const invoice = await invoiceResponse.json(); + const invoiceTypes = invoiceTypesResponse.ok ? await invoiceTypesResponse.json() : { items: [] }; + const customsBrokers = customsBrokersResponse.ok ? await customsBrokersResponse.json() : { items: [] }; + const clients = clientsResponse.ok ? await clientsResponse.json() : { items: [] }; + const providers = providersResponse.ok ? await providersResponse.json() : { items: [] }; + const currencyTypes = currencyTypesResponse.ok ? await currencyTypesResponse.json() : { items: [] }; + const transportTypes = transportTypesResponse.ok ? await transportTypesResponse.json() : { items: [] }; + const seals = sealsResponse.ok ? await sealsResponse.json() : { items: [] }; + const incoterms = incotermsResponse.ok ? await incotermsResponse.json() : { items: [] }; + const pedimentos = pedimentosResponse.ok ? await pedimentosResponse.json() : { items: [] }; + + return json({ + invoice, + invoiceTypes: invoiceTypes.items || [], + customsBrokers: customsBrokers.items || [], + clients: clients.items || [], + providers: providers.items || [], + currencyTypes: currencyTypes.items || [], + transportTypes: transportTypes.items || [], + seals: seals.items || [], + incoterms: incoterms.items || [], + pedimentos: pedimentos.items || [] + }); + } catch (err) { + console.error('Error loading invoice edit data:', err); + throw error(500, 'Error loading invoice'); + } +}; diff --git a/frontend/src/routes/api-sveltekit/invoices/reference-data/+server.ts b/frontend/src/routes/api-sveltekit/invoices/reference-data/+server.ts new file mode 100644 index 00000000..97c0e9ab --- /dev/null +++ b/frontend/src/routes/api-sveltekit/invoices/reference-data/+server.ts @@ -0,0 +1,67 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api'; + +export const GET: RequestHandler = async ({ cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + const companyId = await getActiveCompanyId(cookies, fetch); + + if (!companyId) { + return json({ error: 'No company selected' }, { status: 400 }); + } + + try { + // Cargar todos los datos de referencia en paralelo + const [ + invoiceTypesResponse, + customsBrokersResponse, + clientsResponse, + providersResponse, + currencyTypesResponse, + transportTypesResponse, + sealsResponse, + incotermsResponse, + pedimentosResponse + ] = await Promise.all([ + authenticatedFetch('v1/public/refrence_data/invoice-types/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch(`v1/a76/customs-brokers/?company_id=${companyId}&page=1&page_size=100`, {}, cookies, fetch), + authenticatedFetch(`v1/a76/clients-providers/?company_id=${companyId}&type=client&page=1&page_size=1000`, {}, cookies, fetch), + authenticatedFetch(`v1/a76/clients-providers/?company_id=${companyId}&type=provider&page=1&page_size=1000`, {}, cookies, fetch), + authenticatedFetch('v1/public/refrence_data/currency-types/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch('v1/public/refrence_data/transport-types/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch(`v1/a76/seals/?company_id=${companyId}&page=1&page_size=100`, {}, cookies, fetch), + authenticatedFetch('v1/public/refrence_data/incoterms/?page=1&page_size=100', {}, cookies, fetch), + authenticatedFetch(`v1/a76/pedimentos/?company_id=${companyId}&page=1&page_size=100`, {}, cookies, fetch) + ]); + + const invoiceTypes = invoiceTypesResponse.ok ? await invoiceTypesResponse.json() : { items: [] }; + const customsBrokers = customsBrokersResponse.ok ? await customsBrokersResponse.json() : { items: [] }; + const clients = clientsResponse.ok ? await clientsResponse.json() : { items: [] }; + const providers = providersResponse.ok ? await providersResponse.json() : { items: [] }; + const currencyTypes = currencyTypesResponse.ok ? await currencyTypesResponse.json() : { items: [] }; + const transportTypes = transportTypesResponse.ok ? await transportTypesResponse.json() : { items: [] }; + const seals = sealsResponse.ok ? await sealsResponse.json() : { items: [] }; + const incoterms = incotermsResponse.ok ? await incotermsResponse.json() : { items: [] }; + const pedimentos = pedimentosResponse.ok ? await pedimentosResponse.json() : { items: [] }; + + return json({ + invoiceTypes: invoiceTypes.items || [], + customsBrokers: customsBrokers.items || [], + clients: clients.items || [], + providers: providers.items || [], + currencyTypes: currencyTypes.items || [], + transportTypes: transportTypes.items || [], + seals: seals.items || [], + incoterms: incoterms.items || [], + pedimentos: pedimentos.items || [] + }); + } catch (err) { + console.error('Error loading reference data:', err); + return json({ error: 'Error loading reference data' }, { status: 500 }); + } +}; diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts index a0b8b00d..6e0426d5 100644 --- a/frontend/src/routes/auth/callback/+page.server.ts +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -8,10 +8,6 @@ export const load: PageServerLoad = async ({ url, cookies }) => { const errorParam = url.searchParams.get('error'); const errorDescription = url.searchParams.get('error_description'); - console.log('🔄 [Callback Server] Procesando callback de autenticación'); - console.log('📝 [Callback Server] Código recibido:', code ? 'Sí' : 'No'); - console.log('📝 [Callback Server] State recibido:', state); - if (errorParam) { console.error('❌ [Callback Server] Error en autenticación:', errorParam, errorDescription); throw redirect(303, `/login?error=${encodeURIComponent(errorDescription || errorParam)}`); @@ -34,10 +30,6 @@ export const load: PageServerLoad = async ({ url, cookies }) => { // La redirect_uri debe coincidir exactamente con la registrada en Keycloak const redirectUri = `${url.origin}/auth/callback`; - console.log('🔄 [Callback Server] Intercambiando código por tokens...'); - console.log('📍 [Callback Server] Keycloak URL:', KEYCLOAK_URL); - console.log('📍 [Callback Server] Redirect URI:', redirectUri); - const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`; const body = new URLSearchParams({ @@ -63,7 +55,6 @@ export const load: PageServerLoad = async ({ url, cookies }) => { } const tokens = await tokenResponse.json(); - console.log('✅ [Callback Server] Tokens recibidos exitosamente'); // Establecer las cookies en el servidor (esto es lo importante) // Las cookies deben ser HttpOnly y Secure en producción @@ -87,8 +78,6 @@ export const load: PageServerLoad = async ({ url, cookies }) => { }); } - console.log('✅ [Callback Server] Cookies establecidas exitosamente'); - // Obtener la URL de redirección del state o ir al dashboard let redirectTo = '/dashboard'; if (state) { @@ -100,8 +89,6 @@ export const load: PageServerLoad = async ({ url, cookies }) => { } } - console.log('🚀 [Callback Server] Redirigiendo a:', redirectTo); - // Redirigir a la página de destino throw redirect(303, redirectTo); diff --git a/frontend/src/routes/auth/callback/+page.svelte b/frontend/src/routes/auth/callback/+page.svelte index 9a6a0d98..27711a62 100644 --- a/frontend/src/routes/auth/callback/+page.svelte +++ b/frontend/src/routes/auth/callback/+page.svelte @@ -1,12 +1,5 @@
diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index b95e0ad3..33475637 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -1,49 +1,36 @@ import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; +import { + validateAuth, + getUserCompanies, + getAuthTokens, + clearAuthTokens +} from '$lib/server/api'; export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // Verificar si existe el token en las cookies - const token = cookies.get('access_token'); + const { accessToken } = getAuthTokens(cookies); // Si no hay token, redirigir al login - if (!token) { - // Guardar la URL a la que intentaba acceder para redirigir después del login - throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); + if (!accessToken) { + const redirectUrl = `/login?redirect=${encodeURIComponent(url.pathname)}`; + throw redirect(303, redirectUrl); } - // Validar el token con el backend para asegurar que sea válido + // Validar el token con el backend y obtener datos del usuario + // La función validateAuth maneja automáticamente el refresh de tokens + const redirectOnFail = `/login?redirect=${encodeURIComponent(url.pathname)}`; + try { - // En Docker, el servidor debe usar el nombre del servicio 'backend' en lugar de 'localhost' - // VITE_API_URL ya incluye '/api/' al final (ej: http://localhost:8000/api/) - let apiUrl = process.env.INTERNAL_API_URL; - if (!apiUrl) { - apiUrl = import.meta.env.VITE_API_URL; - // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) - apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); - } + const userData = await validateAuth(cookies, fetch, redirectOnFail); - // Normalizar la URL: asegurar que termine con '/' - const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; - - console.log('🔐 [Dashboard] Validando token con:', `${baseUrl}v1/auth/me`); - - const response = await fetch(`${baseUrl}v1/auth/me`, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - // Token inválido, limpiar y redirigir - cookies.delete('access_token', { path: '/' }); - throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); - } - - const userData = await response.json(); + // Cargar las compañías del usuario en el servidor (SSR) + const companies = await getUserCompanies(cookies, fetch); return { authenticated: true, - user: userData + user: userData, + companies // Pasar las compañías al cliente }; } catch (error) { // Si es un redirect, re-lanzarlo sin tocar las cookies @@ -53,7 +40,7 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // Para cualquier otro error (conexión, etc), limpiar token y redirigir console.error('🔐 [Dashboard] Error validando token:', error); - cookies.delete('access_token', { path: '/' }); - throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); + clearAuthTokens(cookies); + throw redirect(303, redirectOnFail); } }; diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 8b96419d..60b5d95c 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -1,11 +1,63 @@ -{@render children()} + + + +
+
+ + + +
+
+
+ + {@render children()} +
+
+
diff --git a/frontend/src/routes/dashboard/+layout.ts b/frontend/src/routes/dashboard/+layout.ts new file mode 100644 index 00000000..daa0c25c --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.ts @@ -0,0 +1,10 @@ +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async ({ data }) => { + // Pasar los datos del servidor al cliente + return { + user: data.user, + companies: data.companies, + authenticated: data.authenticated + }; +}; diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index a0031a88..71473c62 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -1,39 +1,79 @@ - - - -
-
- - - - - - - +
+ +
+

Bienvenido al Dashboard

+

+ Sistema de gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT +

+
+ + +
+ + + Total de Pedimentos + + +
0
+

Registros activos

+
+
+ + + + Datos de Referencia + + +
12
+

Catálogos disponibles

+
+
+ + + + Licencia Activa + + +
+

Cuenta verificada

+
+
+
+ + + + + Accesos Rápidos + Accede a las funciones más utilizadas del sistema + + +
+ + + Código Pedimento - Regímenes + Gestionar relaciones +
+ + Catálogo de Tipos + Próximamente +
+ + Reportes + Próximamente
-
-
-
-
-
-
-
-
-
-
+ + +
\ No newline at end of file diff --git a/frontend/src/routes/dashboard/classes/+page.svelte b/frontend/src/routes/dashboard/classes/+page.svelte new file mode 100644 index 00000000..247de471 --- /dev/null +++ b/frontend/src/routes/dashboard/classes/+page.svelte @@ -0,0 +1,346 @@ + + +
+ +
+
+

Clases A76

+

+ Gestiona las clases de materiales del sistema +

+
+ +
+ + + + + + + Filtros + Filtra las clases por diferentes criterios + + + { e.preventDefault(); applyFilters(); }} class="grid grid-cols-1 md:grid-cols-4 gap-4"> +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + + {#if error} + + + Error + {error} + + + {/if} + + + + +
+
+ Listado de Clases + + Mostrando {allItems.length} de {totalItems} registros + +
+ +
+
+ + + + +
+
+ + + diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.server.ts b/frontend/src/routes/dashboard/clients_and_providers/+page.server.ts new file mode 100644 index 00000000..dc458a5f --- /dev/null +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.server.ts @@ -0,0 +1,107 @@ +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + // Esperar a que el layout padre valide/refresque el token + const parentData = await parent(); + + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return { + error: 'No authenticated', + items: [], + total: 0, + page: 1, + page_size: 50, + companies: parentData.companies || [] + }; + } + + try { + // Obtener parámetros de paginación de la URL + const page = parseInt(url.searchParams.get('page') || '1'); + const pageSize = parseInt(url.searchParams.get('page_size') || '50'); + + // Obtener company_id de múltiples fuentes (en orden de prioridad): + // 1. URL query param (permite cambiar vía navegación) + // 2. Cookie active_company_id (setted por el team-switcher) + // 3. Primera compañía del usuario (fallback) + const companyIdParam = url.searchParams.get('company_id'); + const cookieCompanyId = cookies.get('active_company_id'); + + const companyId = companyIdParam + ? parseInt(companyIdParam) + : cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; + + if (!companyId) { + return { + error: 'No company selected', + items: [], + total: 0, + page: page, + page_size: pageSize, + companies: parentData.companies || [] + }; + } + + // Construir URL con parámetros + let apiUrl = `v1/a76/clients-providers?company_id=${companyId}&page=${page}&page_size=${pageSize}`; + + const type = url.searchParams.get('type'); + if (type && type !== 'both') { + apiUrl += `&type=${type}`; + } + + // Usar authenticatedFetch para manejar automáticamente el refresh de tokens + const response = await authenticatedFetch( + apiUrl, + {}, + cookies, + fetch + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('📊 [Clients&Providers] API Error:', { + status: response.status, + statusText: response.statusText, + error: errorText + }); + + return { + error: `Error ${response.status}: ${response.statusText}`, + items: [], + total: 0, + page: page, + page_size: pageSize, + companies: parentData.companies || [], + currentCompanyId: companyId + }; + } + + const data = await response.json(); + + return { + items: data.items || [], + total: data.total || 0, + page: data.page || page, + page_size: data.page_size || pageSize, + error: null, + companies: parentData.companies || [], + currentCompanyId: companyId + }; + } catch (error) { + console.error('📊 [Clients&Providers] Load error:', error); + return { + error: 'Error loading data', + items: [], + total: 0, + page: 1, + page_size: 50, + companies: parentData.companies || [] + }; + } +}; diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte new file mode 100644 index 00000000..ca02a3f0 --- /dev/null +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte @@ -0,0 +1,259 @@ + + +
+ +
+
+

Clientes y Proveedores

+

+ Gestiona el catálogo de clientes y proveedores de tu empresa +

+
+ +
+ + + {#if error} + + + Error + {error} + + + {/if} + + + + +
+
+ Listado de Clientes y Proveedores + + Mostrando {allItems.length} de {totalItems} registros + {#if companyStore.activeCompany} + - Compañía: {companyStore.activeCompany.name} + {/if} + +
+
+ + + {selectedType === 'both' ? 'Todos' : selectedType === 'client' ? 'Clientes' : 'Proveedores'} + + + Todos + Clientes + Proveedores + + + +
+
+
+ + + + +
+
+ + + diff --git a/frontend/src/routes/dashboard/clients_and_providers/new/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/new/+page.svelte new file mode 100644 index 00000000..2888f15a --- /dev/null +++ b/frontend/src/routes/dashboard/clients_and_providers/new/+page.svelte @@ -0,0 +1,438 @@ + + +
+ +
+ +
+

Nuevo Cliente/Proveedor

+

+ Completa los datos para crear un nuevo cliente o proveedor +

+
+
+ + + + + Información del Cliente/Proveedor + + Todos los campos marcados con * son obligatorios + + + +
+ {#if error} +
+ {error} +
+ {/if} + + +
+

Información Básica

+ +
+
+ + +
+ +
+ + (formData.client_or_provider = value || "client")}> + + + + + Cliente + Proveedor + Ambos + + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+

Información Fiscal

+ +
+ + +
+ +
+ + +
+
+ + +
+

Dirección

+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+

Programas

+ +
+
+ + +
+ +
+ + +
+
+
+
+
+
+
+ + +
+ + + +
+ + +
diff --git a/frontend/src/routes/dashboard/customs_brokers/+page.server.ts b/frontend/src/routes/dashboard/customs_brokers/+page.server.ts new file mode 100644 index 00000000..59408e0e --- /dev/null +++ b/frontend/src/routes/dashboard/customs_brokers/+page.server.ts @@ -0,0 +1,84 @@ +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + // Esperar a que el layout padre valide/refresque el token + const parentData = await parent(); + + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return { + error: 'No authenticated', + brokers: [], + companies: parentData.companies || [] + }; + } +/////HOLAAA + try { + // Obtener company_id de múltiples fuentes (en orden de prioridad): + // 1. URL query param (permite cambiar vía navegación) + // 2. Cookie active_company_id (setted por el team-switcher) + // 3. Primera compañía del usuario (fallback) + const companyIdParam = url.searchParams.get('company_id'); + const cookieCompanyId = cookies.get('active_company_id'); + + const companyId = companyIdParam + ? parseInt(companyIdParam) + : cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; + + if (!companyId) { + return { + error: 'No company selected', + brokers: [], + companies: parentData.companies || [] + }; + } + + // Usar authenticatedFetch para manejar automáticamente el refresh de tokens + const response = await authenticatedFetch( + `v1/a76/customs-brokers?company_id=${companyId}`, + {}, + cookies, + fetch + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('📊 [CustomsBrokers] API Error:', { + status: response.status, + statusText: response.statusText, + error: errorText + }); + + return { + error: `Error ${response.status}: ${response.statusText}`, + brokers: [], + companies: parentData.companies || [], + currentCompanyId: companyId + }; + } + + const data = await response.json(); + + // El endpoint devuelve un objeto con items, total, page, page_size + // Extraer el array de items + const brokers = Array.isArray(data) ? data : (data.items || []); + + return { + brokers: brokers, + error: null, + companies: parentData.companies || [], + currentCompanyId: companyId + }; + } catch (error) { + console.error('📊 [CustomsBrokers] Load error:', error); + return { + error: 'Error loading data', + brokers: [], + companies: parentData.companies || [] + }; + } +}; diff --git a/frontend/src/routes/dashboard/customs_brokers/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/+page.svelte new file mode 100644 index 00000000..7a8ecdae --- /dev/null +++ b/frontend/src/routes/dashboard/customs_brokers/+page.svelte @@ -0,0 +1,285 @@ + + +
+ +
+
+

Agentes Aduanales

+

+ Gestiona el catálogo de agentes aduanales +

+
+ +
+ + + + + Buscar Agente Aduanal + + Ingresa la clave del agente aduanal para buscarlo + {#if companyStore.activeCompany} + - Compañía: {companyStore.activeCompany.name} + {/if} + + + +
{ e.preventDefault(); handleSearch(); }} class="space-y-4"> +
+
+ + +
+
+ + {#if searchedBroker} + + {/if} +
+
+ + {#if searchError} +
+ {searchError} +
+ {/if} +
+
+
+ + + + +
+
+ + {#if searchedBroker} + Resultado de la Búsqueda + {:else} + Agentes Aduanales + {/if} + + + {#if searchedBroker} + Se encontró 1 agente aduanal + {:else if listLoading} + Cargando agentes aduanales... + {:else} + Total: {brokersList.length} agente{brokersList.length !== 1 ? 's' : ''} aduanal{brokersList.length !== 1 ? 'es' : ''} + {/if} + +
+
+
+ + {#if listError} +
+ {listError} +
+ {:else if listLoading} +
+
+
+ Cargando agentes aduanales... +
+
+ {:else} + + {/if} +
+
+
+ + + diff --git a/frontend/src/routes/dashboard/customs_brokers/new/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/new/+page.svelte new file mode 100644 index 00000000..8777c854 --- /dev/null +++ b/frontend/src/routes/dashboard/customs_brokers/new/+page.svelte @@ -0,0 +1,395 @@ + + +
+ +
+ +
+

Nuevo Agente Aduanal

+

+ Completa los datos para crear un nuevo agente aduanal +

+
+
+ + + + + Información del Agente Aduanal + + Todos los campos marcados con * son obligatorios + + + +
+ {#if error} +
+ {error} +
+ {/if} + + +
+

Información Básica

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+

Información de Contacto

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+

Dirección

+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+

Información Fiscal

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + + +
+
+
+
+
diff --git a/frontend/src/routes/dashboard/general_catalogs/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/+page.svelte new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/routes/dashboard/general_catalogs/classification_concepts/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/classification_concepts/+page.server.ts new file mode 100644 index 00000000..d5aa261b --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/classification_concepts/+page.server.ts @@ -0,0 +1,53 @@ +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + await parent(); + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return { error: 'No authenticated', classifications: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; + } + + try { + const page = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('pageSize')) || 50; + const filters: Record = {}; + const classification = url.searchParams.get('classification'); + const description = url.searchParams.get('description'); + + if (classification) filters.classification = classification; + if (description) filters.description = description; + + // Obtener company_id de la cookie o usar el primero disponible + const parentData = await parent(); + const cookieCompanyId = cookies.get('active_company_id'); + const companyId = cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; + + if (!companyId) { + return { + error: 'No se encontró una compañía seleccionada', + classifications: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } + }; + } + + const queryParams = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + const response = await authenticatedFetch(`v1/a76/classification-concepts?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch); + + if (!response.ok) { + return { error: 'Failed to load', classifications: { items: [], total: 0, page, page_size: pageSize, pages: 0 } }; + } + + return { classifications: await response.json() }; + } catch (error) { + console.error('Error loading classification concepts:', error); + return { error: 'Error loading', classifications: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; + } +}; diff --git a/frontend/src/routes/dashboard/general_catalogs/classification_concepts/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/classification_concepts/+page.svelte new file mode 100644 index 00000000..6b781590 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/classification_concepts/+page.svelte @@ -0,0 +1,86 @@ + + +
+
+
+

Clasificaciones de Conceptos

+

+ Gestión del catálogo de clasificaciones de conceptos +

+
+ +
+ +
+
+ +
+
+ +
+
+ + + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/company_information/+page.server.ts new file mode 100644 index 00000000..bf636cc9 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/+page.server.ts @@ -0,0 +1,79 @@ +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + // Esperar a que el layout padre valide/refresque el token + const parentData = await parent(); + + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return { + error: 'No authenticated', + companies: { + items: [], + total: 0, + page: 1, + page_size: 50, + pages: 0 + } + }; + } + + try { + const page = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('pageSize')) || 50; + + const filters: Record = {}; + const name = url.searchParams.get('name'); + const rfc = url.searchParams.get('rfc'); + + if (name) filters.name = name; + if (rfc) filters.rfc = rfc; + + // Construir URL con parámetros + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + + const response = await authenticatedFetch( + `v1/a76/company?${queryParams.toString()}`, + { method: 'GET' }, + cookies, + fetch + ); + + if (!response.ok) { + return { + error: 'Failed to load companies', + companies: { + items: [], + total: 0, + page: page, + page_size: pageSize, + pages: 0 + } + }; + } + + const data = await response.json(); + + return { + companies: data + }; + } catch (error) { + console.error('Error loading companies:', error); + return { + error: 'Error loading companies', + companies: { + items: [], + total: 0, + page: 1, + page_size: 50, + pages: 0 + } + }; + } +}; diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/+page.svelte new file mode 100644 index 00000000..eb45884d --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/+page.svelte @@ -0,0 +1,89 @@ + + +
+
+
+

Información de Empresas

+

+ Gestión de información de empresas +

+
+ + +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/new/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/new/+page.svelte new file mode 100644 index 00000000..2ff93ec1 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/new/+page.svelte @@ -0,0 +1,247 @@ + + +
+
+ +
+

Nueva Empresa

+

Crea la información de la empresa.

+
+
+ + {#if error} +
+ ⚠️ {error} +
+ {/if} + +
+ + + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + + General + Programas + Responsable + Config + + + + + +
+
+ + +
+
+ +
diff --git a/frontend/src/routes/dashboard/general_catalogs/concepts/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/concepts/+page.server.ts new file mode 100644 index 00000000..ad8d40cc --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/concepts/+page.server.ts @@ -0,0 +1,99 @@ +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + // Esperar a que el layout padre valide/refresque el token + const parentData = await parent(); + + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return { + error: 'No authenticated', + concepts: { + items: [], + total: 0, + page: 1, + page_size: 50, + pages: 0 + } + }; + } + + try { + const page = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('pageSize')) || 50; + + const filters: Record = {}; + const code = url.searchParams.get('code'); + const description = url.searchParams.get('description'); + + if (code) filters.code = code; + if (description) filters.description = description; + + // Obtener company_id de la cookie o usar el primero disponible + const cookieCompanyId = cookies.get('active_company_id'); + const companyId = cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; + + if (!companyId) { + return { + error: 'No se encontró una compañía seleccionada', + concepts: { + items: [], + total: 0, + page: 1, + page_size: 50, + pages: 0 + } + }; + } + + // Construir URL con parámetros + const queryParams = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + + const response = await authenticatedFetch( + `v1/a76/concepts?${queryParams.toString()}`, + { method: 'GET' }, + cookies, + fetch + ); + + if (!response.ok) { + return { + error: 'Failed to load concepts', + concepts: { + items: [], + total: 0, + page: page, + page_size: pageSize, + pages: 0 + } + }; + } + + const data = await response.json(); + + return { + concepts: data + }; + } catch (error) { + console.error('Error loading concepts:', error); + return { + error: 'Error loading concepts', + concepts: { + items: [], + total: 0, + page: 1, + page_size: 50, + pages: 0 + } + }; + } +}; diff --git a/frontend/src/routes/dashboard/general_catalogs/concepts/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/concepts/+page.svelte new file mode 100644 index 00000000..9a89e382 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/concepts/+page.svelte @@ -0,0 +1,86 @@ + + +
+
+
+

Conceptos

+

+ Gestión del catálogo de conceptos +

+
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/customs_broker_concepts/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/customs_broker_concepts/+page.server.ts new file mode 100644 index 00000000..b07db9ea --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/customs_broker_concepts/+page.server.ts @@ -0,0 +1,53 @@ +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + await parent(); + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return { error: 'No authenticated', concepts: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; + } + + try { + const page = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('pageSize')) || 50; + const filters: Record = {}; + const code = url.searchParams.get('code'); + const description = url.searchParams.get('description'); + + if (code) filters.code = code; + if (description) filters.description = description; + + // Obtener company_id de la cookie o usar el primero disponible + const parentData = await parent(); + const cookieCompanyId = cookies.get('active_company_id'); + const companyId = cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; + + if (!companyId) { + return { + error: 'No se encontró una compañía seleccionada', + concepts: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } + }; + } + + const queryParams = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString(), + ...filters + }); + const response = await authenticatedFetch(`v1/a76/customs-broker-concepts?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch); + + if (!response.ok) { + return { error: 'Failed to load', concepts: { items: [], total: 0, page, page_size: pageSize, pages: 0 } }; + } + + return { concepts: await response.json() }; + } catch (error) { + console.error('Error loading customs broker concepts:', error); + return { error: 'Error loading', concepts: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; + } +}; diff --git a/frontend/src/routes/dashboard/general_catalogs/customs_broker_concepts/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/customs_broker_concepts/+page.svelte new file mode 100644 index 00000000..f68c9012 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/customs_broker_concepts/+page.svelte @@ -0,0 +1,90 @@ + + +
+
+
+

Conceptos de Agente Aduanal

+

+ Gestión del catálogo de conceptos de agente aduanal +

+
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
\ No newline at end of file diff --git a/frontend/src/routes/dashboard/general_catalogs/doda/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/doda/+page.server.ts new file mode 100644 index 00000000..6b220633 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/doda/+page.server.ts @@ -0,0 +1,43 @@ +import type { PageServerLoad } from './$types'; +import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { + await parent(); + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + return { error: 'No authenticated', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; + } + + try { + const page = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('pageSize')) || 50; + const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id'); + + if (!companyId) { + return { error: 'No company selected', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; + } + + const filters: Record = {}; + const integrationNumber = url.searchParams.get('integration_number'); + + if (integrationNumber) filters.integration_number = integrationNumber; + + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId, + ...filters + }); + const response = await authenticatedFetch(`v1/a76/doda?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch); + + if (!response.ok) { + return { error: 'Failed to load', dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 } }; + } + + return { dodas: await response.json() }; + } catch (error) { + console.error('Error loading DODAs:', error); + return { error: 'Error loading', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; + } +}; diff --git a/frontend/src/routes/dashboard/general_catalogs/doda/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/doda/+page.svelte new file mode 100644 index 00000000..525c6b3c --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/doda/+page.svelte @@ -0,0 +1,76 @@ + + +
+
+
+

DODA

+

+ Gestión de Documentos de Operación de Aduana +

+
+ + +
+ +
+
+ +
+
+ +
+ +
+ + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/doda/new/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/doda/new/+page.svelte new file mode 100644 index 00000000..9483a076 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/doda/new/+page.svelte @@ -0,0 +1,277 @@ + + +
+
+ +
+

Nuevo DODA

+

Captura la información del documento.

+
+
+ + {#if error} +
+ ⚠️ {error} +
+ {/if} + +
+ + + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +