Merge pull request 'development' (#34) from development into main
Reviewed-on: ADUANASOFT/anexo76#34
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
44
README.md
44
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from alembic import context
|
||||
from core.config import settings
|
||||
import logging
|
||||
from core.database import Base
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,6 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
|
||||
def get_database_url():
|
||||
"""Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini."""
|
||||
# Intentar construir desde variables de entorno primero
|
||||
@@ -41,6 +45,7 @@ def get_database_url():
|
||||
|
||||
return url
|
||||
|
||||
|
||||
# Configurar la URL de la base de datos
|
||||
database_url = get_database_url()
|
||||
|
||||
@@ -54,7 +59,7 @@ if os.environ.get("ALEMBIC_DEBUG"):
|
||||
debug_url = before + "@" + after
|
||||
except Exception:
|
||||
debug_url = "postgresql://***:***@***"
|
||||
logger.error("Error al ocultar la contraseña en la URL para debug.")
|
||||
logger.error("Error al ocultar la contraseña en la URL para debug.")
|
||||
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
@@ -63,23 +68,20 @@ config.set_main_option("sqlalchemy.url", database_url)
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
# Ajusta la ruta para que puedas importar core y módulos
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
|
||||
from core.database import Base
|
||||
|
||||
# Configuración de Alembic
|
||||
config = context.config
|
||||
fileConfig(config.config_file_name)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def import_models_from_dir(dir_path: str):
|
||||
"""Importa recursivamente cualquier archivo models.py desde dir_path"""
|
||||
"""Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/"""
|
||||
for root, dirs, files in os.walk(dir_path):
|
||||
# Importar archivos models.py directos
|
||||
if "models.py" in files:
|
||||
module_path = os.path.join(root, "models.py")
|
||||
# Convertir path en nombre de módulo compatible
|
||||
@@ -90,12 +92,28 @@ def import_models_from_dir(dir_path: str):
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
# Importar todos los archivos .py en directorios llamados "models"
|
||||
if os.path.basename(root) == "models":
|
||||
for file in files:
|
||||
if file.endswith(".py") and not file.startswith("__"):
|
||||
module_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(module_path, BASE_DIR)
|
||||
module_name = rel_path.replace(os.sep, ".").replace(".py", "")
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, module_path
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
logger.warning(f"No se pudo importar {module_path}: {e}")
|
||||
|
||||
|
||||
# Importar todos los models dentro de api/v1/modules y api/v1/modules/uploads
|
||||
modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules")
|
||||
import_models_from_dir(modules_dir)
|
||||
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
@@ -132,10 +150,7 @@ def run_migrations_online() -> None:
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
@@ -144,4 +159,4 @@ def run_migrations_online() -> None:
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
run_migrations_online()
|
||||
|
||||
@@ -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
|
||||
@@ -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 ###
|
||||
|
||||
@@ -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;")
|
||||
|
||||
30
backend/api/v1/common/base_models.py
Normal file
30
backend/api/v1/common/base_models.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for common timestamp fields"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, default=func.now(), onupdate=func.now()
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class TenantScopedMixin:
|
||||
"""Mixin for tenant and company scoped entities"""
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
|
||||
company_id: Mapped[int] = mapped_column(Integer, ForeignKey("a76.company.id"), nullable=False, index=True)
|
||||
|
||||
|
||||
class PedimentoRelatedMixin(TenantScopedMixin):
|
||||
"""Mixin for entities related to pedimentos"""
|
||||
|
||||
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
121
backend/api/v1/common/crud_routes.py
Normal file
121
backend/api/v1/common/crud_routes.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from typing import Any, Callable, Generic, Type, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
ModelType = TypeVar("ModelType")
|
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
|
||||
|
||||
|
||||
class CRUDRouterFactory(
|
||||
Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType]
|
||||
):
|
||||
"""Factory to create standard CRUD routes"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Type[ModelType],
|
||||
create_schema: Type[CreateSchemaType],
|
||||
update_schema: Type[UpdateSchemaType],
|
||||
response_schema: Type[ResponseSchemaType],
|
||||
db_dependency: Callable,
|
||||
auth_dependency: Callable,
|
||||
prefix: str,
|
||||
tags: list[str],
|
||||
id_field: str = "key",
|
||||
):
|
||||
self.model = model
|
||||
self.create_schema = create_schema
|
||||
self.update_schema = update_schema
|
||||
self.response_schema = response_schema
|
||||
self.db_dependency = db_dependency
|
||||
self.auth_dependency = auth_dependency
|
||||
self.id_field = id_field
|
||||
self.router = APIRouter(prefix=prefix, tags=tags)
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self):
|
||||
"""Register all CRUD routes"""
|
||||
|
||||
@self.router.get("/", response_model=list[self.response_schema])
|
||||
def list_items(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
):
|
||||
items = db.query(self.model).offset(skip).limit(limit).all()
|
||||
return items
|
||||
|
||||
@self.router.get(f"/{{{self.id_field}}}", response_model=self.response_schema)
|
||||
def get_item(
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
**kwargs,
|
||||
):
|
||||
item_id = kwargs.get(self.id_field)
|
||||
obj = (
|
||||
db.query(self.model)
|
||||
.filter(getattr(self.model, self.id_field) == item_id)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return obj
|
||||
|
||||
@self.router.post("/", response_model=self.response_schema)
|
||||
def create_item(
|
||||
data: Any,
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
):
|
||||
obj = self.model(**data.dict())
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
@self.router.put(f"/{{{self.id_field}}}", response_model=self.response_schema)
|
||||
def update_item(
|
||||
data: Any,
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
**kwargs,
|
||||
):
|
||||
item_id = kwargs.get(self.id_field)
|
||||
obj = (
|
||||
db.query(self.model)
|
||||
.filter(getattr(self.model, self.id_field) == item_id)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
for field, value in data.dict(exclude_unset=True).items():
|
||||
setattr(obj, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
@self.router.delete(f"/{{{self.id_field}}}", status_code=204)
|
||||
def delete_item(
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
**kwargs,
|
||||
):
|
||||
item_id = kwargs.get(self.id_field)
|
||||
obj = (
|
||||
db.query(self.model)
|
||||
.filter(getattr(self.model, self.id_field) == item_id)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return None
|
||||
31
backend/api/v1/common/dto_mixins.py
Normal file
31
backend/api/v1/common/dto_mixins.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class CurrencyMixin:
|
||||
"""Mixin for currency-related fields"""
|
||||
|
||||
currency: Optional[str] = Field(None, max_length=3, description="Currency")
|
||||
currency_factor: Optional[Decimal] = Field(None, description="Currency factor")
|
||||
|
||||
|
||||
class AffectValueMixin:
|
||||
"""Mixin for value affect flags"""
|
||||
|
||||
not_affect_usd_value: Optional[int] = Field(
|
||||
None, description="Not affect USD value"
|
||||
)
|
||||
not_affect_customs_value: Optional[int] = Field(
|
||||
None, description="Not affect customs value"
|
||||
)
|
||||
|
||||
|
||||
class UpdateFlagsMixin:
|
||||
"""Mixin for update flags"""
|
||||
|
||||
update_vat: Optional[int] = Field(None, description="Update VAT")
|
||||
update_advalorem: Optional[int] = Field(None, description="Update advalorem")
|
||||
update_cc: Optional[int] = Field(None, description="Update CC")
|
||||
update_ieps: Optional[int] = Field(None, description="Update IEPS")
|
||||
457
backend/api/v1/common/tenant_crud_routes.py
Normal file
457
backend/api/v1/common/tenant_crud_routes.py
Normal file
@@ -0,0 +1,457 @@
|
||||
from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar, Union
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Type variables for generic types
|
||||
ModelType = TypeVar("ModelType")
|
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
|
||||
ServiceType = TypeVar("ServiceType")
|
||||
|
||||
|
||||
class TenantCRUDRoutes(
|
||||
Generic[CreateSchemaType, UpdateSchemaType,
|
||||
ResponseSchemaType, ServiceType]
|
||||
):
|
||||
"""
|
||||
Generic CRUD routes factory for tenant-scoped resources
|
||||
|
||||
Supports both parent resources (with list/pagination) and child resources (nested under parent).
|
||||
|
||||
Usage examples:
|
||||
|
||||
1. Parent resource with list (e.g., /pedimentos):
|
||||
router = TenantCRUDRoutes(
|
||||
service=PedimentosService,
|
||||
create_schema=PedimentosCreate,
|
||||
update_schema=PedimentosUpdate,
|
||||
response_schema=PedimentosResponse,
|
||||
prefix="/pedimentos",
|
||||
tags=["Pedimentos"],
|
||||
resource_name="Pedimento",
|
||||
id_name="pedimento_id",
|
||||
enable_list=True,
|
||||
).router
|
||||
|
||||
2. Child resource (e.g., /pedimentos/{pedimento_id}/config-additional):
|
||||
router = TenantCRUDRoutes(
|
||||
service=PedimentoConfigAdditionalService,
|
||||
create_schema=PedimentoConfigAdditionalCreate,
|
||||
update_schema=PedimentoConfigAdditionalUpdate,
|
||||
response_schema=PedimentoConfigAdditionalResponse,
|
||||
prefix="/{pedimento_id}/config-additional",
|
||||
tags=["Pedimento Config Additional"],
|
||||
resource_name="Config additional",
|
||||
parent_id_name="pedimento_id",
|
||||
enable_list=False,
|
||||
).router
|
||||
|
||||
3. Parent resource with string ID (e.g., /vehicles with vehicle_key):
|
||||
router = TenantCRUDRoutes(
|
||||
service=VehicleService,
|
||||
create_schema=VehicleCreate,
|
||||
update_schema=VehicleUpdate,
|
||||
response_schema=VehicleResponse,
|
||||
prefix="/vehicles",
|
||||
tags=["Vehicles"],
|
||||
resource_name="Vehicle",
|
||||
id_name="vehicle_key",
|
||||
id_type=str, # Specify string type for vehicle_key
|
||||
enable_list=True,
|
||||
).router
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: Type[ServiceType],
|
||||
create_schema: Type[CreateSchemaType],
|
||||
update_schema: Type[UpdateSchemaType],
|
||||
response_schema: Type[ResponseSchemaType],
|
||||
prefix: str,
|
||||
tags: list[str],
|
||||
resource_name: str = "Resource",
|
||||
# For parent resources (e.g., "pedimento_id")
|
||||
id_name: Optional[str] = None,
|
||||
id_type: Type = int, # Type of the ID (int, str, etc.)
|
||||
parent_id_name: Optional[
|
||||
str
|
||||
] = None, # For child resources (e.g., "pedimento_id")
|
||||
db_dependency: Callable = get_core_db,
|
||||
auth_dependency: Callable = get_current_user,
|
||||
validate_parent_match: bool = True, # Validate parent_id matches in create
|
||||
enable_list: bool = False, # Enable GET list endpoint with pagination
|
||||
enable_filters: bool = False, # Enable custom filters in list endpoint
|
||||
default_page_size: int = 50,
|
||||
max_page_size: int = 100,
|
||||
):
|
||||
self.service = service
|
||||
self.create_schema = create_schema
|
||||
self.update_schema = update_schema
|
||||
self.response_schema = response_schema
|
||||
self.resource_name = resource_name
|
||||
self.id_name = id_name or parent_id_name or "id"
|
||||
self.id_type = id_type
|
||||
self.parent_id_name = parent_id_name
|
||||
self.db_dependency = db_dependency
|
||||
self.auth_dependency = auth_dependency
|
||||
self.validate_parent_match = validate_parent_match
|
||||
self.enable_list = enable_list
|
||||
self.enable_filters = enable_filters
|
||||
self.default_page_size = default_page_size
|
||||
self.max_page_size = max_page_size
|
||||
|
||||
self.router = APIRouter(prefix=prefix, tags=tags)
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self):
|
||||
"""Register all CRUD routes"""
|
||||
|
||||
# LIST route (optional, for parent resources)
|
||||
if self.enable_list:
|
||||
if self.enable_filters:
|
||||
|
||||
@self.router.get(
|
||||
"/",
|
||||
response_model=Dict[str, Any],
|
||||
summary=f"List {self.resource_name}s",
|
||||
description=f"Get paginated list of {self.resource_name}s with optional filters",
|
||||
)
|
||||
async def list_resources(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(
|
||||
self.default_page_size,
|
||||
ge=1,
|
||||
le=self.max_page_size,
|
||||
description="Page size",
|
||||
),
|
||||
status: Optional[str] = Query(
|
||||
None, description="Filter by status"),
|
||||
operation_type: Optional[str] = Query(
|
||||
None, description="Filter by operation type"),
|
||||
invoice_type: Optional[str] = Query(
|
||||
None, description="Filter by invoice type"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(
|
||||
self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user
|
||||
)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
filters = {}
|
||||
if status:
|
||||
filters["status"] = status
|
||||
if operation_type:
|
||||
filters["operation_type"] = operation_type
|
||||
if invoice_type:
|
||||
filters["invoice_type"] = invoice_type
|
||||
|
||||
items, total = self.service.get_all(
|
||||
db, tenant_id, company_id, skip, page_size, filters
|
||||
)
|
||||
|
||||
return {
|
||||
"items": [
|
||||
self.response_schema.model_validate(item) for item in items
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
else:
|
||||
|
||||
@self.router.get(
|
||||
"/",
|
||||
response_model=Dict[str, Any],
|
||||
summary=f"List {self.resource_name}s",
|
||||
description=f"Get paginated list of {self.resource_name}s",
|
||||
)
|
||||
async def list_resources(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(
|
||||
self.default_page_size,
|
||||
ge=1,
|
||||
le=self.max_page_size,
|
||||
description="Page size",
|
||||
),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(
|
||||
self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user
|
||||
)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
items, total = self.service.get_all(
|
||||
db, tenant_id, company_id, skip, page_size, None
|
||||
)
|
||||
|
||||
return {
|
||||
"items": [
|
||||
self.response_schema.model_validate(item) for item in items
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
# GET single resource route
|
||||
# For parent resources: GET /{id}
|
||||
# For child resources: GET / (parent_id comes from path)
|
||||
if self.parent_id_name:
|
||||
# Child resource - single GET without ID in path
|
||||
@self.router.get(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Get {self.resource_name}",
|
||||
description=f"Get {self.resource_name} by {self.parent_id_name}",
|
||||
)
|
||||
async def get_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
**path_params,
|
||||
):
|
||||
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
parent_id = path_params.get(self.parent_id_name)
|
||||
|
||||
# Try method with 4 params (pedimento_id, tenant_id, company_id)
|
||||
if hasattr(self.service, "get_by_pedimento_id"):
|
||||
resource = self.service.get_by_pedimento_id(
|
||||
db, parent_id, tenant_id, company_id
|
||||
)
|
||||
# Fallback to method with 3 params
|
||||
elif hasattr(self.service, "get_by_id"):
|
||||
resource = self.service.get_by_id(
|
||||
db, parent_id, tenant_id, company_id
|
||||
)
|
||||
else:
|
||||
resource = self.service.get(
|
||||
db, parent_id, tenant_id, company_id)
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
else:
|
||||
# Parent resource - GET by ID in path
|
||||
@self.router.get(
|
||||
f"/{{{self.id_name}}}",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Get {self.resource_name} by ID",
|
||||
description=f"Get a specific {self.resource_name} by {self.id_name}",
|
||||
)
|
||||
async def get_resource_by_id(
|
||||
resource_id: Union[int, str] = Path(
|
||||
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||
),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
|
||||
resource = self.service.get_by_id(
|
||||
db, resource_id, tenant_id, company_id
|
||||
)
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
# POST route
|
||||
if self.parent_id_name:
|
||||
# Child resource - needs parent_id from path
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
create_schema = self.create_schema
|
||||
|
||||
@self.router.post(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
status_code=201,
|
||||
summary=f"Create {self.resource_name}",
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_child_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
|
||||
# For child resources, parent_id validation would go here
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
return resource
|
||||
else:
|
||||
# Parent resource - no parent_id needed
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
create_schema = self.create_schema
|
||||
|
||||
@self.router.post(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
status_code=201,
|
||||
summary=f"Create {self.resource_name}",
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_parent_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
return resource
|
||||
|
||||
# PUT route
|
||||
# For parent resources: PUT /{id}
|
||||
# For child resources: PUT / (parent_id comes from path)
|
||||
if self.parent_id_name:
|
||||
# Child resource
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
update_schema = self.update_schema
|
||||
|
||||
@self.router.put(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Update {self.resource_name}",
|
||||
description=f"Update an existing {self.resource_name}",
|
||||
)
|
||||
async def update_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: update_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
**path_params,
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
parent_id = path_params.get(self.parent_id_name)
|
||||
|
||||
resource = self.service.update(
|
||||
db, parent_id, tenant_id, data, company_id
|
||||
)
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
else:
|
||||
# Parent resource
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
update_schema = self.update_schema
|
||||
|
||||
@self.router.put(
|
||||
f"/{{{self.id_name}}}",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Update {self.resource_name}",
|
||||
description=f"Update an existing {self.resource_name} by {self.id_name}",
|
||||
)
|
||||
async def update_resource_by_id(
|
||||
resource_id: Union[int, str] = Path(
|
||||
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||
),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: update_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
f"""Update {self.resource_name}"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
|
||||
resource = self.service.update(
|
||||
db, resource_id, tenant_id, data, company_id
|
||||
)
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
# DELETE route
|
||||
# For parent resources: DELETE /{id}
|
||||
# For child resources: DELETE / (parent_id comes from path)
|
||||
if self.parent_id_name:
|
||||
# Child resource
|
||||
@self.router.delete(
|
||||
"/",
|
||||
status_code=204,
|
||||
summary=f"Delete {self.resource_name}",
|
||||
description=f"Delete an existing {self.resource_name}",
|
||||
)
|
||||
async def delete_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
**path_params,
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
parent_id = path_params.get(self.parent_id_name)
|
||||
|
||||
success = self.service.delete(
|
||||
db, parent_id, tenant_id, company_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return None
|
||||
|
||||
else:
|
||||
# Parent resource
|
||||
@self.router.delete(
|
||||
f"/{{{self.id_name}}}",
|
||||
status_code=204,
|
||||
summary=f"Delete {self.resource_name}",
|
||||
description=f"Delete an existing {self.resource_name} by {self.id_name}",
|
||||
)
|
||||
async def delete_resource_by_id(
|
||||
resource_id: Union[int, str] = Path(
|
||||
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||
),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user)
|
||||
|
||||
success = self.service.delete(
|
||||
db, resource_id, tenant_id, company_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return None
|
||||
34
backend/api/v1/modules/a24/fa/fa_classes/models.py
Normal file
34
backend/api/v1/modules/a24/fa/fa_classes/models.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
Numeric,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class QClasses(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "fa_classes" # QClases
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="qclases_pk"),
|
||||
ForeignKeyConstraint(["class_id"], ["a76.classes.id"], name="fk_qclasses_classes"),
|
||||
{"schema": "a24"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
class_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
import_tariff_code: Mapped[str] = mapped_column(String(10)) # FRACCIONIMPO
|
||||
import_tariff_type: Mapped[str] = mapped_column(String(6)) # TIPOFRACIMPO
|
||||
export_tariff_code: Mapped[str] = mapped_column(String(10)) # FRACCIONEXPO
|
||||
export_tariff_type: Mapped[str] = mapped_column(String(6)) # TIPOFRACEXPO
|
||||
depreciation_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2)) # TASADEPRECIA
|
||||
fda_code: Mapped[str] = mapped_column(String(20)) # FDA
|
||||
eccn_code: Mapped[str] = mapped_column(String(20)) # ECCN
|
||||
class_enabled: Mapped[bool] = mapped_column(Boolean) # HABILITADESHABILITACLASE
|
||||
34
backend/api/v1/modules/a24/fa/fa_item_lines/models.py
Normal file
34
backend/api/v1/modules/a24/fa/fa_item_lines/models.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from core.database import Base
|
||||
|
||||
class LineItem(Base):
|
||||
__tablename__ = "line_items"
|
||||
__table_args__ = (
|
||||
{"schema": "a24"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("a76.items.id"))
|
||||
|
||||
# Asset information (SCAF specific)
|
||||
asset_number: Mapped[Optional[str]] = mapped_column(String(25)) # ASSETNUMBER
|
||||
asset_photo: Mapped[Optional[str]] = mapped_column(String(255)) # FOTOACTIVOFIJO
|
||||
equipment_message: Mapped[Optional[str]] = mapped_column(String(40)) # EQI_MENSAJE
|
||||
invoice_type_asset: Mapped[Optional[str]] = mapped_column(String(6)) # TIPOFACTURAASSET
|
||||
return_import_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAIMPORET
|
||||
return_import_date: Mapped[Optional[int]] = mapped_column(Integer) # FECHAFACIMPORET
|
||||
movement_type_import: Mapped[Optional[str]] = mapped_column(String(3)) # TIPOMOVIMPO
|
||||
|
||||
# Cross-references IN CASE OF IMPORT REPAIR
|
||||
search_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURAEXPO (when line is import) / FACTURAIMPO (when line is export)
|
||||
search_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAEXPO (when line is import) / LINEAIMPO (when line is export)
|
||||
|
||||
# Search type
|
||||
search_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOBUSQUEDA
|
||||
|
||||
# Special flags
|
||||
download: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGA
|
||||
own_equipment: Mapped[Optional[bool]] = mapped_column(Boolean) # EQUIPOPROPIO
|
||||
omit_annex31: Mapped[Optional[bool]] = mapped_column(Boolean) # OMITITENANEXO31
|
||||
20
backend/api/v1/modules/a24/inv/inv_classes/models.py
Normal file
20
backend/api/v1/modules/a24/inv/inv_classes/models.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class SClasses(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "inv_classes" # SClases
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["class_id"], ["a76.clases.class_id"], name="fk_sclasses_classes"
|
||||
),
|
||||
PrimaryKeyConstraint("id", name="sclases_pk"),
|
||||
{"schema": "a24"},
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
class_id: Mapped[int] = mapped_column(Integer, nullable=False) # Id de clase
|
||||
|
||||
stock_um: Mapped[str] = mapped_column(String(5)) # Unidad de medida para existencia
|
||||
us_tariff_code: Mapped[str] = mapped_column(String(19)) # Fracción americana (USA)
|
||||
3
backend/api/v1/modules/a24/inv/location/__init__.py
Normal file
3
backend/api/v1/modules/a24/inv/location/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de localización
|
||||
"""
|
||||
43
backend/api/v1/modules/a24/inv/location/dto.py
Normal file
43
backend/api/v1/modules/a24/inv/location/dto.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de localización
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LocationCreateDTO(BaseModel):
|
||||
"""DTO para crear una localización"""
|
||||
|
||||
code: str = Field(..., max_length=5, description="Location code")
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=200, description="Location description"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LocationUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una localización"""
|
||||
|
||||
code: Optional[str] = Field(
|
||||
None, max_length=5, description="Location code")
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=200, description="Location description"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LocationResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de una localización"""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
36
backend/api/v1/modules/a24/inv/location/models.py
Normal file
36
backend/api/v1/modules/a24/inv/location/models.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Modelos ORM para gestión de localización
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import Integer, PrimaryKeyConstraint, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class Location(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla Location - Localización
|
||||
"""
|
||||
|
||||
__tablename__ = "location" # SLocalizacion
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="location_pkey"),
|
||||
UniqueConstraint("code", name="location_code_unique"),
|
||||
{"schema": "a24"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Location code (unique)
|
||||
code: Mapped[str] = mapped_column(String(5), nullable=False, unique=True)
|
||||
|
||||
# Location description
|
||||
description: Mapped[Optional[str]] = mapped_column(String(200))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Location(id={self.id}, code={self.code}, description={self.description})>"
|
||||
136
backend/api/v1/modules/a24/inv/location/routes.py
Normal file
136
backend/api/v1/modules/a24/inv/location/routes.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Rutas para gestión de localización
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
|
||||
from .models import Location
|
||||
from .service import LocationService
|
||||
|
||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="Get all locations",
|
||||
)
|
||||
async def get_all_locations(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
code: str = Query(None),
|
||||
description: str = Query(None),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get all locations with optional filtering and pagination"""
|
||||
filters = {}
|
||||
if code:
|
||||
filters["code"] = code
|
||||
if description:
|
||||
filters["description"] = description
|
||||
|
||||
locations, total = LocationService.get_all(db, skip, limit, filters)
|
||||
|
||||
return {
|
||||
"data": [LocationResponseDTO.model_validate(location) for location in locations],
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{location_id}",
|
||||
response_model=LocationResponseDTO,
|
||||
summary="Get location by ID",
|
||||
)
|
||||
async def get_location(
|
||||
location_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get a location by its ID"""
|
||||
location = LocationService.get_by_id(db, location_id)
|
||||
if not location:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/code/{code}",
|
||||
response_model=LocationResponseDTO,
|
||||
summary="Get location by code",
|
||||
)
|
||||
async def get_location_by_code(
|
||||
code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get a location by its code"""
|
||||
location = LocationService.get_by_code(db, code)
|
||||
if not location:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=LocationResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create location",
|
||||
)
|
||||
async def create_location(
|
||||
location_data: LocationCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Create a new location"""
|
||||
location = LocationService.create(db, location_data)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{location_id}",
|
||||
response_model=LocationResponseDTO,
|
||||
summary="Update location",
|
||||
)
|
||||
async def update_location(
|
||||
location_id: int,
|
||||
location_data: LocationUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Update a location"""
|
||||
location = LocationService.update(db, location_id, location_data)
|
||||
if not location:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return LocationResponseDTO.model_validate(location)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{location_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete location",
|
||||
)
|
||||
async def delete_location(
|
||||
location_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Delete a location"""
|
||||
success = LocationService.delete(db, location_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found",
|
||||
)
|
||||
return None
|
||||
136
backend/api/v1/modules/a24/inv/location/service.py
Normal file
136
backend/api/v1/modules/a24/inv/location/service.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de localización
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
|
||||
from .models import Location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocationService:
|
||||
"""Servicio para gestión de localización"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[List[Location], int]:
|
||||
"""Get all locations with pagination"""
|
||||
query = db.query(Location)
|
||||
|
||||
if filters:
|
||||
if filters.get("code"):
|
||||
query = query.filter(
|
||||
Location.code.ilike(f"%{filters['code']}%"))
|
||||
if filters.get("description"):
|
||||
query = query.filter(
|
||||
Location.description.ilike(f"%{filters['description']}%")
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
locations = query.offset(skip).limit(limit).all()
|
||||
|
||||
return locations, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, location_id: int) -> Optional[Location]:
|
||||
"""Get location by ID"""
|
||||
return db.query(Location).filter(Location.id == location_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_code(db: Session, code: str) -> Optional[Location]:
|
||||
"""Get location by code"""
|
||||
return db.query(Location).filter(Location.code == code).first()
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, location_data: LocationCreateDTO) -> Location:
|
||||
"""Create a new location"""
|
||||
try:
|
||||
db_location = Location(
|
||||
**location_data.model_dump(exclude_unset=True))
|
||||
|
||||
db.add(db_location)
|
||||
db.commit()
|
||||
db.refresh(db_location)
|
||||
|
||||
return db_location
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError creating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Location code already exists",
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error creating location")
|
||||
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session, location_id: int, location_data: LocationUpdateDTO
|
||||
) -> Optional[Location]:
|
||||
"""Update a location"""
|
||||
try:
|
||||
db_location = db.query(Location).filter(
|
||||
Location.id == location_id).first()
|
||||
|
||||
if not db_location:
|
||||
return None
|
||||
|
||||
for key, value in location_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_location, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_location)
|
||||
|
||||
return db_location
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError updating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Error updating location",
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error updating location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error updating location")
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, location_id: int) -> bool:
|
||||
"""Delete a location"""
|
||||
try:
|
||||
db_location = db.query(Location).filter(
|
||||
Location.id == location_id).first()
|
||||
|
||||
if not db_location:
|
||||
return False
|
||||
|
||||
db.delete(db_location)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting location: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error deleting location")
|
||||
7
backend/api/v1/modules/a76/classes/__init__.py
Normal file
7
backend/api/v1/modules/a76/classes/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de Class
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
150
backend/api/v1/modules/a76/classes/dto.py
Normal file
150
backend/api/v1/modules/a76/classes/dto.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de clases SCAII y SCAF
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ClassCreateDTO(BaseModel):
|
||||
"""DTO para crear una clase"""
|
||||
|
||||
client_id: int = Field(..., description="Client key")
|
||||
class_code: str = Field(..., max_length=8, description="Class code")
|
||||
description_es: Optional[str] = Field(
|
||||
None, max_length=500, description="Description in Spanish"
|
||||
)
|
||||
description_en: Optional[str] = Field(
|
||||
None, max_length=500, description="Description in English"
|
||||
)
|
||||
material_key: Optional[str] = Field(
|
||||
None,
|
||||
max_length=10,
|
||||
description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)",
|
||||
)
|
||||
unit_of_measure: Optional[str] = Field(
|
||||
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
|
||||
)
|
||||
fraction: Optional[str] = Field(
|
||||
None, max_length=10, description="Mexican tariff fraction"
|
||||
)
|
||||
us_fraction: Optional[str] = Field(
|
||||
None, max_length=16, description="US tariff fraction"
|
||||
)
|
||||
sub_key: Optional[str] = Field(
|
||||
None, max_length=5, description="Sub classification key"
|
||||
)
|
||||
physical_review: Optional[int] = Field(
|
||||
None, description="Physical review indicator"
|
||||
)
|
||||
iva_exempt_fraction: Optional[str] = Field(
|
||||
None, max_length=4, description="IVA exempt fraction"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una clase"""
|
||||
|
||||
description_es: Optional[str] = Field(
|
||||
None, max_length=500, description="Description in Spanish"
|
||||
)
|
||||
description_en: Optional[str] = Field(
|
||||
None, max_length=500, description="Description in English"
|
||||
)
|
||||
material_key: Optional[str] = Field(
|
||||
None,
|
||||
max_length=10,
|
||||
description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)",
|
||||
)
|
||||
unit_of_measure: Optional[str] = Field(
|
||||
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
|
||||
)
|
||||
fraction: Optional[str] = Field(
|
||||
None, max_length=10, description="Mexican tariff fraction"
|
||||
)
|
||||
us_fraction: Optional[str] = Field(
|
||||
None, max_length=16, description="US tariff fraction"
|
||||
)
|
||||
sub_key: Optional[str] = Field(
|
||||
None, max_length=5, description="Sub classification key"
|
||||
)
|
||||
physical_review: Optional[int] = Field(
|
||||
None, description="Physical review indicator"
|
||||
)
|
||||
iva_exempt_fraction: Optional[str] = Field(
|
||||
None, max_length=4, description="IVA exempt fraction"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de clase"""
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
client_id: int
|
||||
class_code: str
|
||||
description_es: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
material_key: Optional[str] = None
|
||||
unit_of_measure: Optional[str] = None
|
||||
fraction: Optional[str] = None
|
||||
us_fraction: Optional[str] = None
|
||||
sub_key: Optional[str] = None
|
||||
physical_review: Optional[int] = None
|
||||
iva_exempt_fraction: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ClassBasicDTO(BaseModel):
|
||||
"""DTO para información básica de clase"""
|
||||
|
||||
client_id: int
|
||||
class_code: str
|
||||
description_es: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
material_key: Optional[str] = None
|
||||
fraction: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassListDTO(BaseModel):
|
||||
"""DTO para lista de clases"""
|
||||
|
||||
classes: list[ClassBasicDTO]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassSearchDTO(BaseModel):
|
||||
"""DTO para búsqueda de clases"""
|
||||
|
||||
client_id: Optional[int] = Field(None, description="Filter by client key")
|
||||
class_code: Optional[str] = Field(None, description="Search by class code")
|
||||
description: Optional[str] = Field(None, description="Search in descriptions")
|
||||
material_key: Optional[str] = Field(None, description="Filter by material key")
|
||||
fraction: Optional[str] = Field(None, description="Filter by tariff fraction")
|
||||
physical_review: Optional[int] = Field(
|
||||
None, description="Filter by physical review indicator"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
108
backend/api/v1/modules/a76/classes/models.py
Normal file
108
backend/api/v1/modules/a76/classes/models.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Modelos ORM para gestión de clases SCAII y SCAF
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
SmallInteger,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.public.reference_data.material_types.models import MaterialType
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
|
||||
|
||||
class Class(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla GClases - Información de clases en sistemas SCAII y SCAF
|
||||
"""
|
||||
|
||||
__tablename__ = "classes"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="classes_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["client_id"], ["a76.clients_and_providers.id"], name="fk_classes_client"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["material_key"],
|
||||
["public.material_types.key"],
|
||||
name="fk_classes_material_type",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["unit_of_measure", "tenant_id", "company_id"],
|
||||
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
|
||||
"a76.units_of_measure.company_id"],
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"company_id",
|
||||
"client_id",
|
||||
"class_code",
|
||||
name="ufa_classes_client_id_class_code",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
client_id: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
# Unique constraint compuesta
|
||||
class_code: Mapped[str] = mapped_column(String(8)) # CLASE
|
||||
|
||||
# Basic information
|
||||
description_es: Mapped[Optional[str]] = mapped_column(
|
||||
String(500)) # DESCRIPCIONE
|
||||
description_en: Mapped[Optional[str]] = mapped_column(
|
||||
String(500)) # DESCRIPCIONI
|
||||
|
||||
# Material and measurement
|
||||
material_key: Mapped[Optional[str]] = mapped_column(
|
||||
String(10)
|
||||
) # CLAVEMAT - homologated from TIPOMAT/TIPOMATEQUIPO
|
||||
unit_of_measure: Mapped[Optional[str]] = mapped_column(
|
||||
String(5)
|
||||
) # UNIMED - homologated from UNIMEDIDA
|
||||
|
||||
# Tariff fractions
|
||||
fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION
|
||||
us_fraction: Mapped[Optional[str]] = mapped_column(
|
||||
String(16)
|
||||
) # FRACCIONAME - US tariff fraction
|
||||
|
||||
# Additional classification
|
||||
sub_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVESUB
|
||||
physical_review: Mapped[Optional[int]] = mapped_column(
|
||||
SmallInteger) # REVFISICA
|
||||
iva_exempt_fraction: Mapped[Optional[str]] = mapped_column(
|
||||
String(4)
|
||||
) # FRACCIONEXENTAIVA
|
||||
|
||||
# Relationships
|
||||
material_type: Mapped[Optional["MaterialType"]] = relationship(
|
||||
foreign_keys=[material_key]
|
||||
)
|
||||
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
|
||||
foreign_keys=[unit_of_measure]
|
||||
)
|
||||
|
||||
# Inverse relationship with GParts that have this class
|
||||
parts: Mapped[list["Part"]] = relationship(
|
||||
primaryjoin="and_(Class.client_id == Part.client_id, Class.class_code == Part.part_class)",
|
||||
foreign_keys="[Part.client_id, Part.part_class]",
|
||||
viewonly=True,
|
||||
back_populates="part_class_info",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Class(client_id={self.client_id}, class_code='{self.class_code}', description='{self.description_es}')>"
|
||||
24
backend/api/v1/modules/a76/classes/routes.py
Normal file
24
backend/api/v1/modules/a76/classes/routes.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Endpoints API para gestión de clases SCAII y SCAF
|
||||
"""
|
||||
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
|
||||
from .dto import ClassCreateDTO, ClassResponseDTO, ClassUpdateDTO
|
||||
from .service import ClassService
|
||||
|
||||
# Create router with generic CRUD routes
|
||||
router = TenantCRUDRoutes(
|
||||
service=ClassService,
|
||||
create_schema=ClassCreateDTO,
|
||||
update_schema=ClassUpdateDTO,
|
||||
response_schema=ClassResponseDTO,
|
||||
prefix="/classes",
|
||||
tags=["a76 / classes"],
|
||||
resource_name="Class",
|
||||
id_name="class_id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
default_page_size=50,
|
||||
max_page_size=100,
|
||||
).router
|
||||
482
backend/api/v1/modules/a76/classes/service.py
Normal file
482
backend/api/v1/modules/a76/classes/service.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de clases SCAII y SCAF
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
ClassBasicDTO,
|
||||
ClassCreateDTO,
|
||||
ClassListDTO,
|
||||
ClassResponseDTO,
|
||||
ClassSearchDTO,
|
||||
ClassUpdateDTO,
|
||||
)
|
||||
from .models import Class
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClassService:
|
||||
"""Servicio para gestión de clases SCAII y SCAF"""
|
||||
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple[List[Class], int]:
|
||||
"""
|
||||
Get all classes for a tenant with pagination and filters
|
||||
"""
|
||||
query = db.query(Class).filter(
|
||||
Class.tenant_id == tenant_id, Class.company_id == company_id
|
||||
)
|
||||
|
||||
if filters:
|
||||
if filters.get("client_id"):
|
||||
query = query.filter(Class.client_id == filters["client_id"])
|
||||
if filters.get("class_code"):
|
||||
query = query.filter(
|
||||
Class.class_code.ilike(f"%{filters['class_code']}%")
|
||||
)
|
||||
if filters.get("description"):
|
||||
description_pattern = f"%{filters['description']}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Class.description_es.ilike(description_pattern),
|
||||
Class.description_en.ilike(description_pattern),
|
||||
)
|
||||
)
|
||||
if filters.get("material_key"):
|
||||
query = query.filter(
|
||||
Class.material_key.ilike(f"%{filters['material_key']}%")
|
||||
)
|
||||
if filters.get("fraction"):
|
||||
query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%"))
|
||||
if filters.get("physical_review") is not None:
|
||||
query = query.filter(
|
||||
Class.physical_review == filters["physical_review"]
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(
|
||||
db: Session, class_id: int, tenant_id: int, company_id: int
|
||||
) -> Optional[Class]:
|
||||
"""Get a class by ID"""
|
||||
return (
|
||||
db.query(Class)
|
||||
.filter(
|
||||
Class.id == class_id,
|
||||
Class.tenant_id == tenant_id,
|
||||
Class.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session, class_data: ClassCreateDTO, tenant_id: int, company_id: int
|
||||
) -> Class:
|
||||
"""Create a new class"""
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
data_dict = class_data.model_dump()
|
||||
|
||||
# Check if class_code already exists for this tenant and company
|
||||
existing = db.query(Class).filter(
|
||||
Class.tenant_id == tenant_id,
|
||||
Class.company_id == company_id,
|
||||
Class.client_id == data_dict["client_id"],
|
||||
Class.class_code == data_dict["class_code"]
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Class with code '{data_dict['class_code']}' already exists for this tenant and company"
|
||||
)
|
||||
|
||||
# Validate material_key exists if provided
|
||||
if data_dict.get("material_key"):
|
||||
from api.v1.modules.public.reference_data.material_types.models import MaterialType
|
||||
material_exists = db.query(MaterialType).filter(
|
||||
MaterialType.key == data_dict["material_key"]
|
||||
).first()
|
||||
if not material_exists:
|
||||
# Set to None if material_key doesn't exist
|
||||
data_dict["material_key"] = None
|
||||
|
||||
class_obj = Class(**data_dict)
|
||||
class_obj.tenant_id = tenant_id
|
||||
class_obj.company_id = company_id
|
||||
|
||||
try:
|
||||
db.add(class_obj)
|
||||
db.commit()
|
||||
db.refresh(class_obj)
|
||||
return class_obj
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to create class: {str(e.orig)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session,
|
||||
class_id: int,
|
||||
tenant_id: int,
|
||||
class_data: ClassUpdateDTO,
|
||||
company_id: int,
|
||||
) -> Optional[Class]:
|
||||
"""Update a class"""
|
||||
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
|
||||
if not class_obj:
|
||||
return None
|
||||
|
||||
update_data = class_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate material_key exists if provided
|
||||
if "material_key" in update_data and update_data["material_key"]:
|
||||
from api.v1.modules.public.reference_data.material_types.models import MaterialType
|
||||
material_exists = db.query(MaterialType).filter(
|
||||
MaterialType.key == update_data["material_key"]
|
||||
).first()
|
||||
if not material_exists:
|
||||
# Set to None if material_key doesn't exist
|
||||
update_data["material_key"] = None
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(class_obj, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(class_obj)
|
||||
return class_obj
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, class_id: int, tenant_id: int, company_id: int) -> bool:
|
||||
"""Delete a class"""
|
||||
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
|
||||
if not class_obj:
|
||||
return False
|
||||
|
||||
db.delete(class_obj)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_class(self, class_data: ClassCreateDTO) -> ClassResponseDTO:
|
||||
"""
|
||||
Crea una nueva clase en el sistema
|
||||
|
||||
Args:
|
||||
class_data: Datos de la clase a crear
|
||||
|
||||
Returns:
|
||||
ClassResponseDTO con información de la clase creada
|
||||
|
||||
Raises:
|
||||
HTTPException: Si la clase ya existe o error en la creación
|
||||
"""
|
||||
try:
|
||||
# Verificar que no exista la clase
|
||||
existing = (
|
||||
self.db.query(Class)
|
||||
.filter(
|
||||
and_(
|
||||
Class.client_id == class_data.client_id,
|
||||
Class.class_code == class_data.class_code,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Class with client_id '{class_data.client_id}' and class_code '{class_data.class_code}' already exists",
|
||||
)
|
||||
|
||||
# Crear clase
|
||||
db_class = Class(
|
||||
client_id=class_data.client_id,
|
||||
class_code=class_data.class_code,
|
||||
description_spanish=class_data.description_spanish,
|
||||
description_english=class_data.description_english,
|
||||
material_key=class_data.material_key,
|
||||
unit_of_measure=class_data.unit_of_measure,
|
||||
fraction=class_data.fraction,
|
||||
us_fraction=class_data.us_fraction,
|
||||
sub_key=class_data.sub_key,
|
||||
physical_review=class_data.physical_review,
|
||||
iva_exempt_fraction=class_data.iva_exempt_fraction,
|
||||
)
|
||||
|
||||
self.db.add(db_class)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_class)
|
||||
|
||||
return ClassResponseDTO.model_validate(db_class)
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating class: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Class with this client_id and class_code already exists",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating class: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error creating class")
|
||||
|
||||
def get_class(self, client_id: int, class_code: str) -> Optional[ClassResponseDTO]:
|
||||
"""
|
||||
Obtiene una clase por clave compuesta
|
||||
|
||||
Args:
|
||||
client_id: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
Returns:
|
||||
ClassResponseDTO o None si no existe
|
||||
"""
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not class_obj:
|
||||
return None
|
||||
return ClassResponseDTO.model_validate(class_obj)
|
||||
|
||||
def list_classes(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search_params: Optional[ClassSearchDTO] = None,
|
||||
) -> ClassListDTO:
|
||||
"""
|
||||
Lista clases con filtros
|
||||
|
||||
Args:
|
||||
skip: Número de registros a omitir
|
||||
limit: Número máximo de registros a retornar
|
||||
search_params: Parámetros de búsqueda
|
||||
|
||||
Returns:
|
||||
ClassListDTO con la lista paginada
|
||||
"""
|
||||
query = self.db.query(Class)
|
||||
|
||||
# Aplicar filtros si se proporcionan
|
||||
if search_params:
|
||||
if search_params.client_id:
|
||||
query = query.filter(Class.client_id == search_params.client_id)
|
||||
|
||||
if search_params.class_code:
|
||||
query = query.filter(
|
||||
Class.class_code.ilike(f"%{search_params.class_code}%")
|
||||
)
|
||||
|
||||
if search_params.description:
|
||||
description_pattern = f"%{search_params.description}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Class.description_spanish.ilike(description_pattern),
|
||||
Class.description_english.ilike(description_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if search_params.material_key:
|
||||
query = query.filter(
|
||||
Class.material_key.ilike(f"%{search_params.material_key}%")
|
||||
)
|
||||
|
||||
if search_params.fraction:
|
||||
query = query.filter(
|
||||
Class.fraction.ilike(f"%{search_params.fraction}%")
|
||||
)
|
||||
|
||||
if search_params.physical_review is not None:
|
||||
query = query.filter(
|
||||
Class.physical_review == search_params.physical_review
|
||||
)
|
||||
|
||||
# Contar total
|
||||
total = query.count()
|
||||
|
||||
# Aplicar paginación
|
||||
classes = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Convertir a DTOs básicos
|
||||
class_dtos = [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
return ClassListDTO(
|
||||
classes=class_dtos,
|
||||
total=total,
|
||||
page=(skip // limit) + 1 if limit > 0 else 1,
|
||||
size=len(class_dtos),
|
||||
)
|
||||
|
||||
def update_class(
|
||||
self, client_id: int, class_code: str, class_data: ClassUpdateDTO
|
||||
) -> Optional[ClassResponseDTO]:
|
||||
"""
|
||||
Actualiza una clase
|
||||
|
||||
Args:
|
||||
client_id: Clave del cliente
|
||||
class_code: Código de clase
|
||||
class_data: Datos a actualizar
|
||||
|
||||
Returns:
|
||||
ClassResponseDTO actualizado o None si no existe
|
||||
"""
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not class_obj:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Actualizar solo campos proporcionados
|
||||
update_data = class_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(class_obj, field, value)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(class_obj)
|
||||
|
||||
return ClassResponseDTO.model_validate(class_obj)
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error updating class {client_id}-{class_code}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating class")
|
||||
|
||||
def delete_class(self, client_id: int, class_code: str) -> bool:
|
||||
"""
|
||||
Elimina una clase
|
||||
|
||||
Args:
|
||||
client_id: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
Returns:
|
||||
True si se eliminó, False si no existe
|
||||
"""
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not class_obj:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.db.delete(class_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting class {client_id}-{class_code}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting class")
|
||||
|
||||
def search_by_fraction(self, fraction: str) -> List[ClassBasicDTO]:
|
||||
"""Busca clases por fracción arancelaria"""
|
||||
classes = (
|
||||
self.db.query(Class).filter(Class.fraction.ilike(f"%{fraction}%")).all()
|
||||
)
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def search_by_client(
|
||||
self, client_id: int, skip: int = 0, limit: int = 100
|
||||
) -> List[ClassBasicDTO]:
|
||||
"""Obtiene todas las clases de un cliente específico"""
|
||||
classes = (
|
||||
self.db.query(Class)
|
||||
.filter(Class.client_id == client_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def search_by_material(self, material_key: str) -> List[ClassBasicDTO]:
|
||||
"""Busca clases por clave de material"""
|
||||
classes = (
|
||||
self.db.query(Class)
|
||||
.filter(Class.material_key.ilike(f"%{material_key}%"))
|
||||
.all()
|
||||
)
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def get_classes_by_physical_review(
|
||||
self, physical_review: int
|
||||
) -> List[ClassBasicDTO]:
|
||||
"""Obtiene clases por indicador de revisión física"""
|
||||
classes = (
|
||||
self.db.query(Class).filter(Class.physical_review == physical_review).all()
|
||||
)
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def get_classes_statistics(self) -> dict:
|
||||
"""Obtiene estadísticas básicas de clases"""
|
||||
total_classes = self.db.query(Class).count()
|
||||
|
||||
# Contar por clientes
|
||||
clients_count = self.db.query(Class.client_id).distinct().count()
|
||||
|
||||
# Contar por revisión física
|
||||
physical_review_stats = {}
|
||||
for i in range(3): # Asumiendo valores 0, 1, 2
|
||||
count = self.db.query(Class).filter(Class.physical_review == i).count()
|
||||
physical_review_stats[f"physical_review_{i}"] = count
|
||||
|
||||
# Contar clases con fracciones
|
||||
with_fraction = self.db.query(Class).filter(Class.fraction.isnot(None)).count()
|
||||
with_us_fraction = (
|
||||
self.db.query(Class).filter(Class.us_fraction.isnot(None)).count()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_classes": total_classes,
|
||||
"clients_with_classes": clients_count,
|
||||
"classes_with_fraction": with_fraction,
|
||||
"classes_with_us_fraction": with_us_fraction,
|
||||
**physical_review_stats,
|
||||
}
|
||||
|
||||
def get_classes_by_unit_measure(self, unit_of_measure: str) -> List[ClassBasicDTO]:
|
||||
"""Obtiene clases por unidad de medida"""
|
||||
classes = (
|
||||
self.db.query(Class).filter(Class.unit_of_measure == unit_of_measure).all()
|
||||
)
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
36
backend/api/v1/modules/a76/classes/test_classes.py
Normal file
36
backend/api/v1/modules/a76/classes/test_classes.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from .routes import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "access_token")
|
||||
def test_list_classes(client, access_token):
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = client.get("/classes/", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert "items" in response.json()
|
||||
assert "page" in response.json()
|
||||
assert "page_size" in response.json()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "access_token")
|
||||
def test_get_class_not_found(client, access_token):
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = client.get("/classes/invalid_id", headers=headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_class_forbidden():
|
||||
response = client.post("/classes/", json={"name": "Test Class"})
|
||||
assert response.status_code in (403, 405, 404)
|
||||
|
||||
|
||||
def test_update_class_forbidden():
|
||||
response = client.put("/classes/1", json={"name": "Updated Class"})
|
||||
assert response.status_code in (403, 405, 404)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de Client & Provider
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
245
backend/api/v1/modules/a76/clients_and_providers/dto.py
Normal file
245
backend/api/v1/modules/a76/clients_and_providers/dto.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de clientes y proveedores
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# DTOs para dirección
|
||||
class ClientProviderAddressDTO(BaseModel):
|
||||
"""DTO para dirección de cliente/proveedor"""
|
||||
|
||||
municipality: Optional[str] = Field(
|
||||
None, max_length=150, description="Municipality"
|
||||
)
|
||||
streets: Optional[str] = Field(None, max_length=100, description="Streets")
|
||||
neighborhood: Optional[str] = Field(None, max_length=40, description="Neighborhood")
|
||||
interior_number: Optional[str] = Field(
|
||||
None, max_length=20, description="Interior number"
|
||||
)
|
||||
exterior_number: Optional[str] = Field(
|
||||
None, max_length=20, description="Exterior number"
|
||||
)
|
||||
postal_code: Optional[str] = Field(None, max_length=15, description="Postal code")
|
||||
city: Optional[str] = Field(None, max_length=30, description="City")
|
||||
state: Optional[str] = Field(None, max_length=30, description="State")
|
||||
country: Optional[str] = Field(None, max_length=3, description="Country code")
|
||||
phone: Optional[str] = Field(None, max_length=30, description="Phone number")
|
||||
fax_number: Optional[str] = Field(None, max_length=30, description="Fax number")
|
||||
email: Optional[str] = Field(None, max_length=100, description="Email address")
|
||||
contact: Optional[str] = Field(None, max_length=50, description="Contact person")
|
||||
reference: Optional[str] = Field(None, max_length=250, description="Reference")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# DTOs para programas
|
||||
class ClientProviderProgramsDTO(BaseModel):
|
||||
"""DTO para programas de cliente/proveedor"""
|
||||
|
||||
program: Optional[str] = Field(None, max_length=7, description="Program")
|
||||
program_number: Optional[str] = Field(
|
||||
None, max_length=40, description="Program number"
|
||||
)
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(
|
||||
None, max_length=20, description="PROSEC authorization"
|
||||
)
|
||||
secon_auth_date: Optional[int] = Field(None, description="SECON authorization date")
|
||||
manufacturer_id: Optional[str] = Field(
|
||||
None, max_length=25, description="Manufacturer ID"
|
||||
)
|
||||
tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID")
|
||||
broker: Optional[str] = Field(None, max_length=6, description="Broker")
|
||||
import_broker: Optional[str] = Field(
|
||||
None, max_length=6, description="Import broker"
|
||||
)
|
||||
transfer_key: Optional[str] = Field(None, max_length=8, description="Transfer key")
|
||||
secon_authorization: Optional[str] = Field(
|
||||
None, max_length=20, description="SECON authorization"
|
||||
)
|
||||
applied_proportion: Optional[Decimal] = Field(
|
||||
None, description="Applied proportion"
|
||||
)
|
||||
is_certified_company: Optional[str] = Field(
|
||||
None, max_length=1, description="Is certified company"
|
||||
)
|
||||
certified_company_registry: Optional[str] = Field(
|
||||
None, max_length=40, description="Certified company registry"
|
||||
)
|
||||
donation_auth_number: Optional[str] = Field(
|
||||
None, max_length=50, description="Donation authorization number"
|
||||
)
|
||||
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
|
||||
tax_registry_number: Optional[str] = Field(
|
||||
None, max_length=40, description="Tax registry number"
|
||||
)
|
||||
subassembly_service: Optional[int] = Field(None, description="Subassembly service")
|
||||
autse_dates: Optional[int] = Field(None, description="AUTSE dates")
|
||||
autse_number: Optional[str] = Field(
|
||||
None, max_length=300, description="AUTSE number"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# DTOs principales
|
||||
class ClientProviderCreateDTO(BaseModel):
|
||||
"""DTO para crear cliente/proveedor"""
|
||||
|
||||
type_nat_foreign: Optional[str] = Field(
|
||||
None, max_length=1, description="Type national/foreign"
|
||||
)
|
||||
name: Optional[str] = Field(None, max_length=256, description="Name")
|
||||
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
client_or_provider: Optional[Literal["client", "provider", "both"]] = Field(
|
||||
None, description="Client or provider"
|
||||
)
|
||||
linking: Optional[str] = Field(None, max_length=1, description="Linking")
|
||||
transform_subassembly: Optional[str] = Field(
|
||||
None, max_length=1, description="Transform subassembly"
|
||||
)
|
||||
extra_information: Optional[str] = Field(
|
||||
None, max_length=399, description="Extra information"
|
||||
)
|
||||
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
|
||||
responsible: Optional[str] = Field(
|
||||
None, max_length=80, description="Responsible person"
|
||||
)
|
||||
position: Optional[str] = Field(None, max_length=30, description="Position")
|
||||
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
|
||||
is_national_provider: Optional[str] = Field(
|
||||
None, max_length=2, description="Is national provider"
|
||||
)
|
||||
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
|
||||
|
||||
# Nested DTOs
|
||||
address: Optional[ClientProviderAddressDTO] = Field(
|
||||
None, description="Address information"
|
||||
)
|
||||
programs: Optional[ClientProviderProgramsDTO] = Field(
|
||||
None, description="Programs information"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientProviderUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar cliente/proveedor"""
|
||||
|
||||
type_nat_foreign: Optional[str] = Field(
|
||||
None, max_length=1, description="Type national/foreign"
|
||||
)
|
||||
name: Optional[str] = Field(None, max_length=256, description="Name")
|
||||
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
client_or_provider: Optional[Literal["client", "provider", "both"]] = Field(
|
||||
None, description="Client or provider"
|
||||
)
|
||||
linking: Optional[str] = Field(None, max_length=1, description="Linking")
|
||||
transform_subassembly: Optional[str] = Field(
|
||||
None, max_length=1, description="Transform subassembly"
|
||||
)
|
||||
extra_information: Optional[str] = Field(
|
||||
None, max_length=399, description="Extra information"
|
||||
)
|
||||
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
|
||||
responsible: Optional[str] = Field(
|
||||
None, max_length=80, description="Responsible person"
|
||||
)
|
||||
position: Optional[str] = Field(None, max_length=30, description="Position")
|
||||
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
|
||||
is_national_provider: Optional[str] = Field(
|
||||
None, max_length=2, description="Is national provider"
|
||||
)
|
||||
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
|
||||
|
||||
# Nested DTOs
|
||||
address: Optional[ClientProviderAddressDTO] = Field(
|
||||
None, description="Address information"
|
||||
)
|
||||
programs: Optional[ClientProviderProgramsDTO] = Field(
|
||||
None, description="Programs information"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientProviderResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de cliente/proveedor"""
|
||||
|
||||
id: int
|
||||
type_nat_foreign: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
short_name: Optional[str] = None
|
||||
rfc: Optional[str] = None
|
||||
curp: Optional[str] = None
|
||||
client_or_provider: Optional[str] = None
|
||||
linking: Optional[str] = None
|
||||
transform_subassembly: Optional[str] = None
|
||||
extra_information: Optional[str] = None
|
||||
web_key: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
incoterm: Optional[str] = None
|
||||
is_national_provider: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
# Nested DTOs
|
||||
address: Optional[ClientProviderAddressDTO] = None
|
||||
programs: Optional[ClientProviderProgramsDTO] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# DTOs para respuestas específicas
|
||||
class ClientProviderBasicDTO(BaseModel):
|
||||
"""DTO para información básica de cliente/proveedor"""
|
||||
|
||||
client_id: str
|
||||
name: Optional[str] = None
|
||||
short_name: Optional[str] = None
|
||||
rfc: Optional[str] = None
|
||||
client_or_provider: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientProviderListDTO(BaseModel):
|
||||
"""DTO para lista de clientes/proveedores"""
|
||||
|
||||
clients: list[ClientProviderBasicDTO]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientProviderPaginatedResponseDTO(BaseModel):
|
||||
"""DTO para respuesta paginada de clientes/proveedores"""
|
||||
|
||||
items: List[ClientProviderResponseDTO]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
159
backend/api/v1/modules/a76/clients_and_providers/models.py
Normal file
159
backend/api/v1/modules/a76/clients_and_providers/models.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Modelos ORM para gestión de clientes y proveedores
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
Numeric,
|
||||
PrimaryKeyConstraint,
|
||||
SmallInteger,
|
||||
String,
|
||||
Enum as PgEnum,
|
||||
Boolean
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from enum import Enum
|
||||
|
||||
class ClientOrProviderEnum(str, Enum):
|
||||
CLIENT = "client"
|
||||
PROVIDER = "provider"
|
||||
BOTH = "both"
|
||||
|
||||
|
||||
class ClientProvider(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla GClientesPro - Información de clientes y proveedores
|
||||
"""
|
||||
|
||||
__tablename__ = "clients_and_providers"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="clients_and_providers_pkey"),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Basic information
|
||||
type_nat_foreign: Mapped[Optional[str]] = mapped_column(String(1)) # TIPO NACIONAL/EXTRANJERO
|
||||
name: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
short_name: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
rfc: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
curp: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
client_or_provider: Mapped[ClientOrProviderEnum] = mapped_column(PgEnum(ClientOrProviderEnum, name="entity_client_or_provider", create_type=True, native_enum=True),nullable=False)
|
||||
linking: Mapped[Optional[str]] = mapped_column(String(1))
|
||||
transform_subassembly: Mapped[Optional[str]] = mapped_column(String(1))
|
||||
extra_information: Mapped[Optional[str]] = mapped_column(String(399))
|
||||
web_key: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
responsible: Mapped[Optional[str]] = mapped_column(String(80))
|
||||
position: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
incoterm: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
is_national_provider: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
|
||||
# Relationships
|
||||
address: Mapped[Optional["ClientProviderAddress"]] = relationship(
|
||||
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
programs: Mapped[Optional["ClientProviderPrograms"]] = relationship(
|
||||
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ClientProviderAddress(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla GClientesPro_Direccion - Dirección de clientes y proveedores
|
||||
"""
|
||||
|
||||
__tablename__ = "clients_and_providers_address"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="clients_and_providers_address_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["client_id"],
|
||||
["a76.clients_and_providers.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_clients_and_providers_address_client",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key (foreign key)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
client_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
|
||||
)
|
||||
|
||||
# Address information
|
||||
municipality: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
streets: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
neighborhood: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
interior_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
exterior_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(15))
|
||||
city: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
state: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
country: Mapped[Optional[str]] = mapped_column(String(3))
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
fax_number: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
email: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
contact: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
reference: Mapped[Optional[str]] = mapped_column(String(250))
|
||||
|
||||
# Relationship
|
||||
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="address")
|
||||
|
||||
|
||||
class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla GClientesPro_Programas - Programas de clientes y proveedores
|
||||
"""
|
||||
|
||||
__tablename__ = "clients_and_providers_programs"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="clients_and_providers_programs_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["client_id"],
|
||||
["a76.clients_and_providers.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_clients_and_providers_programs_client",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key (foreign key)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
client_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
|
||||
)
|
||||
|
||||
# Program information
|
||||
program: Mapped[Optional[str]] = mapped_column(String(7))
|
||||
program_number: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
|
||||
tax_id: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
broker: Mapped[Optional[str]] = mapped_column(String(6))
|
||||
import_broker: Mapped[Optional[str]] = mapped_column(String(6))
|
||||
transfer_key: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
secon_authorization: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
applied_proportion: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2))
|
||||
is_certified_company: Mapped[Optional[str]] = mapped_column(String(1))
|
||||
certified_company_registry: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
donation_auth_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
ctpat_svi: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
tax_registry_number: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
subassembly_service: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
autse_dates: Mapped[Optional[int]] = mapped_column()
|
||||
autse_number: Mapped[Optional[str]] = mapped_column(String(300))
|
||||
|
||||
# Relationship
|
||||
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="programs")
|
||||
129
backend/api/v1/modules/a76/clients_and_providers/routes.py
Normal file
129
backend/api/v1/modules/a76/clients_and_providers/routes.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Endpoints API para gestión de clientes y proveedores
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from .models import ClientOrProviderEnum
|
||||
|
||||
from .dto import (
|
||||
ClientProviderBasicDTO,
|
||||
ClientProviderCreateDTO,
|
||||
ClientProviderResponseDTO,
|
||||
ClientProviderUpdateDTO,
|
||||
ClientProviderPaginatedResponseDTO,
|
||||
)
|
||||
from .service import ClientProviderService
|
||||
from .models import ClientProvider
|
||||
|
||||
# Create main router to add custom endpoints
|
||||
router = APIRouter(prefix="/clients-providers")
|
||||
|
||||
|
||||
@router.get("/", response_model=ClientProviderPaginatedResponseDTO)
|
||||
async def get_clients_and_providers(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
type: Optional[ClientOrProviderEnum] = Query(
|
||||
None, description="Type of entity (client or provider)"
|
||||
),
|
||||
active: Optional[bool] = Query(None, description="Active status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get clients and providers"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
query = db.query(ClientProvider).filter(
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
|
||||
if type is not None:
|
||||
query = query.filter(ClientProvider.client_or_provider == type)
|
||||
|
||||
if active is not None:
|
||||
query = query.filter(ClientProvider.is_active == active)
|
||||
|
||||
total = query.count()
|
||||
clients = query.offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
"items": [ClientProviderResponseDTO.model_validate(c) for c in clients],
|
||||
"total": total,
|
||||
"page": (skip // limit) + 1,
|
||||
"page_size": limit,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO)
|
||||
async def get_clients_and_providers_basic_info(
|
||||
client_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get basic information for a client/provider (without address and programs)"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client/Provider not found")
|
||||
|
||||
return ClientProviderBasicDTO.model_validate(client)
|
||||
|
||||
|
||||
@router.post("/", response_model=ClientProviderResponseDTO)
|
||||
async def create_client_provider(
|
||||
client_data: ClientProviderCreateDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new client/provider"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
return ClientProviderService.create(db, client_data, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/{client_id}", response_model=ClientProviderResponseDTO)
|
||||
async def update_client_provider(
|
||||
client_id: int,
|
||||
client_data: ClientProviderUpdateDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a client/provider"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
client = ClientProviderService.update(
|
||||
db, client_id, tenant_id, company_id, client_data
|
||||
)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client/Provider not found")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@router.delete("/{client_id}", response_model=bool)
|
||||
async def delete_client_provider(
|
||||
client_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a client/provider"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
success = ClientProviderService.delete(db, client_id, tenant_id, company_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Client/Provider not found")
|
||||
|
||||
return success
|
||||
480
backend/api/v1/modules/a76/clients_and_providers/service.py
Normal file
480
backend/api/v1/modules/a76/clients_and_providers/service.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de clientes y proveedores
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .dto import (
|
||||
ClientProviderBasicDTO,
|
||||
ClientProviderCreateDTO,
|
||||
ClientProviderListDTO,
|
||||
ClientProviderResponseDTO,
|
||||
ClientProviderUpdateDTO,
|
||||
)
|
||||
from .models import ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientProviderService:
|
||||
"""Servicio para gestión de clientes y proveedores"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# Métodos para TenantCRUDRoutes
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[List[ClientProvider], int]:
|
||||
"""Get all clients/providers for a tenant/company with pagination"""
|
||||
query = db.query(ClientProvider).filter(
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("search"):
|
||||
search_pattern = f"%{filters['search']}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
ClientProvider.name.ilike(search_pattern),
|
||||
ClientProvider.short_name.ilike(search_pattern),
|
||||
ClientProvider.rfc.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
if filters.get("client_or_provider"):
|
||||
query = query.filter(
|
||||
ClientProvider.client_or_provider == filters["client_or_provider"]
|
||||
)
|
||||
if filters.get("status"):
|
||||
enabled = 1 if filters["status"] == "enabled" else 0
|
||||
query = query.filter(ClientProvider.is_active == enabled)
|
||||
|
||||
total = query.count()
|
||||
clients = (
|
||||
query.options(
|
||||
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return clients, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(
|
||||
db: Session, client_id: int, tenant_id: int, company_id: int
|
||||
) -> Optional[ClientProvider]:
|
||||
"""Get client/provider by ID"""
|
||||
return (
|
||||
db.query(ClientProvider)
|
||||
.options(
|
||||
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
|
||||
)
|
||||
.filter(
|
||||
ClientProvider.id == client_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session,
|
||||
client_data: ClientProviderCreateDTO,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> ClientProvider:
|
||||
"""Create a new client/provider"""
|
||||
try:
|
||||
# Create main client/provider
|
||||
data_dict = client_data.model_dump(exclude={"address", "programs"})
|
||||
db_client = ClientProvider(
|
||||
**data_dict, tenant_id=tenant_id, company_id=company_id
|
||||
)
|
||||
|
||||
db.add(db_client)
|
||||
db.flush()
|
||||
|
||||
# Create address if provided
|
||||
if client_data.address:
|
||||
db_address = ClientProviderAddress(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
client_id=db_client.id,
|
||||
**client_data.address.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_address)
|
||||
|
||||
# Create programs if provided
|
||||
if client_data.programs:
|
||||
db_programs = ClientProviderPrograms(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
client_id=db_client.id,
|
||||
**client_data.programs.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_programs)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_client)
|
||||
|
||||
return db_client
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError creating client/provider: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Client/Provider already exists"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating client/provider: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error creating client/provider"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session,
|
||||
client_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
client_data: ClientProviderUpdateDTO,
|
||||
) -> Optional[ClientProvider]:
|
||||
"""Update a client/provider"""
|
||||
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Update main fields
|
||||
update_data = client_data.model_dump(
|
||||
exclude_unset=True, exclude={"address", "programs"}
|
||||
)
|
||||
for field, value in update_data.items():
|
||||
setattr(client, field, value)
|
||||
|
||||
# Update address
|
||||
if client_data.address:
|
||||
if client.address:
|
||||
address_data = client_data.address.model_dump(exclude_unset=True)
|
||||
for field, value in address_data.items():
|
||||
setattr(client.address, field, value)
|
||||
else:
|
||||
db_address = ClientProviderAddress(
|
||||
client_id=client.id,
|
||||
tenant_id=tenant_id,
|
||||
**client_data.address.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_address)
|
||||
|
||||
# Update programs
|
||||
if client_data.programs:
|
||||
if client.programs:
|
||||
programs_data = client_data.programs.model_dump(exclude_unset=True)
|
||||
for field, value in programs_data.items():
|
||||
setattr(client.programs, field, value)
|
||||
else:
|
||||
db_programs = ClientProviderPrograms(
|
||||
client_id=client.id,
|
||||
tenant_id=tenant_id,
|
||||
**client_data.programs.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_programs)
|
||||
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
|
||||
return client
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error updating client/provider"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, client_id: int, tenant_id: int, company_id: int) -> bool:
|
||||
"""Delete a client/provider"""
|
||||
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
|
||||
if not client:
|
||||
return False
|
||||
|
||||
try:
|
||||
db.delete(client)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error deleting client/provider"
|
||||
)
|
||||
|
||||
# Legacy methods for custom endpoints
|
||||
|
||||
def create_clients_and_providers_legacy(
|
||||
self, company_id: int, client_data: ClientProviderCreateDTO
|
||||
) -> ClientProviderResponseDTO:
|
||||
"""Legacy method for creating client/provider"""
|
||||
try:
|
||||
# Create main client/provider
|
||||
data_dict = client_data.model_dump(exclude={"address", "programs"})
|
||||
db_client = ClientProvider(**data_dict)
|
||||
self.db.add(db_client)
|
||||
self.db.flush()
|
||||
|
||||
# Create address if provided
|
||||
if client_data.address:
|
||||
db_address = ClientProviderAddress(
|
||||
company_id=company_id,
|
||||
client_id=db_client.id,
|
||||
**client_data.address.model_dump(exclude_unset=True),
|
||||
)
|
||||
self.db.add(db_address)
|
||||
|
||||
# Create programs if provided
|
||||
if client_data.programs:
|
||||
db_programs = ClientProviderPrograms(
|
||||
company_id=company_id,
|
||||
client_id=db_client.id,
|
||||
**client_data.programs.model_dump(exclude_unset=True),
|
||||
)
|
||||
self.db.add(db_programs)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return self._get_client_with_relations(db_client.id)
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating client/provider: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Client/Provider with this ID already exists"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating client/provider: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error creating client/provider"
|
||||
)
|
||||
|
||||
def get_clients_and_providers(
|
||||
self, client_id: str
|
||||
) -> Optional[ClientProviderResponseDTO]:
|
||||
"""
|
||||
Obtiene un cliente/proveedor por ID
|
||||
|
||||
Args:
|
||||
client_id: ID del cliente/proveedor
|
||||
|
||||
Returns:
|
||||
ClientProviderResponseDTO o None si no existe
|
||||
"""
|
||||
return self._get_client_with_relations(client_id)
|
||||
|
||||
def _get_client_with_relations(
|
||||
self, client_id: str
|
||||
) -> Optional[ClientProviderResponseDTO]:
|
||||
"""Método privado para obtener cliente con relaciones"""
|
||||
client = (
|
||||
self.db.query(ClientProvider)
|
||||
.options(
|
||||
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
|
||||
)
|
||||
.filter(ClientProvider.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not client:
|
||||
return None
|
||||
return ClientProviderResponseDTO.model_validate(client)
|
||||
|
||||
def list_clients_providers(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None,
|
||||
client_or_provider: Optional[str] = None,
|
||||
enabled_only: bool = False,
|
||||
) -> ClientProviderListDTO:
|
||||
"""
|
||||
Lista clientes/proveedores con filtros
|
||||
|
||||
Args:
|
||||
skip: Número de registros a omitir
|
||||
limit: Número máximo de registros a retornar
|
||||
search: Texto de búsqueda (nombre, RFC, ID)
|
||||
client_or_provider: Filtrar por tipo (client=Cliente, provider=Proveedor)
|
||||
enabled_only: Si True, solo retorna activos
|
||||
|
||||
Returns:
|
||||
ClientProviderListDTO con la lista paginada
|
||||
"""
|
||||
query = self.db.query(ClientProvider)
|
||||
|
||||
# Aplicar filtros
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
ClientProvider.name.ilike(search_pattern),
|
||||
ClientProvider.short_name.ilike(search_pattern),
|
||||
ClientProvider.rfc.ilike(search_pattern),
|
||||
ClientProvider.client_id.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if client_or_provider:
|
||||
query = query.filter(
|
||||
ClientProvider.client_or_provider == client_or_provider
|
||||
)
|
||||
|
||||
if enabled_only:
|
||||
query = query.filter(ClientProvider.is_active == 1)
|
||||
|
||||
# Contar total
|
||||
total = query.count()
|
||||
|
||||
# Aplicar paginación
|
||||
clients = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Convertir a DTOs básicos
|
||||
client_dtos = [
|
||||
ClientProviderBasicDTO.model_validate(client) for client in clients
|
||||
]
|
||||
|
||||
return ClientProviderListDTO(
|
||||
clients=client_dtos,
|
||||
total=total,
|
||||
page=(skip // limit) + 1 if limit > 0 else 1,
|
||||
size=len(client_dtos),
|
||||
)
|
||||
|
||||
def update_clients_and_providers(
|
||||
self, client_id: str, client_data: ClientProviderUpdateDTO
|
||||
) -> Optional[ClientProviderResponseDTO]:
|
||||
"""
|
||||
Actualiza un cliente/proveedor
|
||||
|
||||
Args:
|
||||
client_id: ID del cliente/proveedor a actualizar
|
||||
client_data: Datos a actualizar
|
||||
|
||||
Returns:
|
||||
ClientProviderResponseDTO actualizado o None si no existe
|
||||
"""
|
||||
client = (
|
||||
self.db.query(ClientProvider)
|
||||
.filter(ClientProvider.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Actualizar campos del cliente principal
|
||||
update_data = client_data.model_dump(
|
||||
exclude_unset=True, exclude={"address", "programs"}
|
||||
)
|
||||
for field, value in update_data.items():
|
||||
setattr(client, field, value)
|
||||
|
||||
# Actualizar dirección
|
||||
if client_data.address:
|
||||
address = (
|
||||
self.db.query(ClientProviderAddress)
|
||||
.filter(ClientProviderAddress.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
if address:
|
||||
# Actualizar dirección existente
|
||||
address_data = client_data.address.model_dump(exclude_unset=True)
|
||||
for field, value in address_data.items():
|
||||
setattr(address, field, value)
|
||||
else:
|
||||
# Crear nueva dirección
|
||||
address = ClientProviderAddress(
|
||||
client_id=client_id,
|
||||
**client_data.address.model_dump(exclude_unset=True),
|
||||
)
|
||||
self.db.add(address)
|
||||
|
||||
# Actualizar programas
|
||||
if client_data.programs:
|
||||
programs = (
|
||||
self.db.query(ClientProviderPrograms)
|
||||
.filter(ClientProviderPrograms.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
if programs:
|
||||
# Actualizar programas existentes
|
||||
programs_data = client_data.programs.model_dump(exclude_unset=True)
|
||||
for field, value in programs_data.items():
|
||||
setattr(programs, field, value)
|
||||
else:
|
||||
# Crear nuevos programas
|
||||
programs = ClientProviderPrograms(
|
||||
client_id=client_id,
|
||||
**client_data.programs.model_dump(exclude_unset=True),
|
||||
)
|
||||
self.db.add(programs)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return self._get_client_with_relations(client_id)
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error updating client/provider"
|
||||
)
|
||||
|
||||
def delete_clients_and_providers(self, client_id: str) -> bool:
|
||||
"""
|
||||
Elimina un cliente/proveedor
|
||||
|
||||
Args:
|
||||
client_id: ID del cliente/proveedor a eliminar
|
||||
|
||||
Returns:
|
||||
True si se eliminó, False si no existe
|
||||
"""
|
||||
client = (
|
||||
self.db.query(ClientProvider)
|
||||
.filter(ClientProvider.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
if not client:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.db.delete(client) # Las relaciones se eliminan en cascada
|
||||
self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error deleting client/provider"
|
||||
)
|
||||
@@ -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)
|
||||
21
backend/api/v1/modules/a76/country_rule_oct/dto.py
Normal file
21
backend/api/v1/modules/a76/country_rule_oct/dto.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
DTOs for CountryRuleOct.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CountryRuleOctBaseDTO(BaseModel):
|
||||
permission: str
|
||||
line: int
|
||||
fraction: str
|
||||
country_code: str
|
||||
|
||||
|
||||
class CountryRuleOctCreateDTO(CountryRuleOctBaseDTO):
|
||||
pass
|
||||
|
||||
|
||||
class CountryRuleOctResponseDTO(CountryRuleOctBaseDTO):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
46
backend/api/v1/modules/a76/country_rule_oct/models.py
Normal file
46
backend/api/v1/modules/a76/country_rule_oct/models.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class CountryRuleOct(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "country_rule_oct"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="country_rule_oct_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "company_id", "permission", "line", "fraction"],
|
||||
[
|
||||
"a76.fraction_rule_octave.tenant_id",
|
||||
"a76.fraction_rule_octave.company_id",
|
||||
"a76.fraction_rule_octave.permission",
|
||||
"a76.fraction_rule_octave.line",
|
||||
"a76.fraction_rule_octave.fraction",
|
||||
],
|
||||
ondelete="CASCADE",
|
||||
name="fk_country_rule_oct_frac_octava",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"company_id",
|
||||
"permission",
|
||||
"line",
|
||||
"fraction",
|
||||
"country_code",
|
||||
name="uq_country_rule_oct_permission_line_fraction_country",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
permission: Mapped[str] = mapped_column(String(20))
|
||||
line: Mapped[int] = mapped_column()
|
||||
fraction: Mapped[str] = mapped_column(String(10))
|
||||
country_code: Mapped[str] = mapped_column(String(3))
|
||||
98
backend/api/v1/modules/a76/country_rule_oct/routes.py
Normal file
98
backend/api/v1/modules/a76/country_rule_oct/routes.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from typing import List
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import CountryRuleOctCreateDTO, CountryRuleOctResponseDTO
|
||||
from .services import CountryRuleOctService
|
||||
|
||||
router = APIRouter(prefix="/country-rule-oct")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[CountryRuleOctResponseDTO])
|
||||
async def list_countries(
|
||||
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List all CountryRuleOct entries.
|
||||
"""
|
||||
# Validate access to the tenant and company
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id = current_user.get("company_id")
|
||||
|
||||
if not tenant_id or not company_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: Tenant or Company not found"
|
||||
)
|
||||
|
||||
return db.query(CountryRuleOctService).all()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{permission}/{line}/{fraction}/{country_code}",
|
||||
response_model=CountryRuleOctResponseDTO,
|
||||
)
|
||||
async def read_country_rule(
|
||||
permission: str,
|
||||
line: int,
|
||||
fraction: str,
|
||||
country_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific CountryRuleOct by its composite key.
|
||||
"""
|
||||
# Validate access to the tenant and company
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id = current_user.get("company_id")
|
||||
|
||||
if not tenant_id or not company_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: Tenant or Company not found"
|
||||
)
|
||||
|
||||
country = CountryRuleOctService.get_country_by_keys(
|
||||
db, permission, line, fraction, country_code
|
||||
)
|
||||
if not country:
|
||||
raise HTTPException(status_code=404, detail="CountryRuleOct not found")
|
||||
return country
|
||||
|
||||
|
||||
@router.post(
|
||||
"/", response_model=CountryRuleOctResponseDTO, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_country_rule(
|
||||
country_data: CountryRuleOctCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new CountryRuleOct entry.
|
||||
"""
|
||||
return CountryRuleOctService.create_country_rule(db, country_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{permission}/{line}/{fraction}/{country_code}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_country_rule(
|
||||
permission: str,
|
||||
line: int,
|
||||
fraction: str,
|
||||
country_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a CountryRuleOct by its composite key.
|
||||
"""
|
||||
country = CountryRuleOctService.delete_country_rule(
|
||||
db, permission, line, fraction, country_code
|
||||
)
|
||||
if not country:
|
||||
raise HTTPException(status_code=404, detail="CountryRuleOct not found")
|
||||
44
backend/api/v1/modules/a76/country_rule_oct/services.py
Normal file
44
backend/api/v1/modules/a76/country_rule_oct/services.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Service layer for CountryRuleOct.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import dto, models
|
||||
|
||||
|
||||
class CountryRuleOctService:
|
||||
@staticmethod
|
||||
def get_country_by_keys(
|
||||
db: Session, permission: str, line: int, fraction: str, country_code: str
|
||||
):
|
||||
return (
|
||||
db.query(models.CountryRuleOct)
|
||||
.filter(
|
||||
models.CountryRuleOct.permission == permission,
|
||||
models.CountryRuleOct.line == line,
|
||||
models.CountryRuleOct.fraction == fraction,
|
||||
models.CountryRuleOct.country_code == country_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_country_rule(db: Session, country_data: dto.CountryRuleOctCreateDTO):
|
||||
new_country = models.CountryRuleOct(**country_data.dict())
|
||||
db.add(new_country)
|
||||
db.commit()
|
||||
db.refresh(new_country)
|
||||
return new_country
|
||||
|
||||
@staticmethod
|
||||
def delete_country_rule(
|
||||
db: Session, permission: str, line: int, fraction: str, country_code: str
|
||||
):
|
||||
country = CountryRuleOctService.get_country_by_keys(
|
||||
db, permission, line, fraction, country_code
|
||||
)
|
||||
if country:
|
||||
db.delete(country)
|
||||
db.commit()
|
||||
return country
|
||||
@@ -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)
|
||||
111
backend/api/v1/modules/a76/customs_brokers/dto.py
Normal file
111
backend/api/v1/modules/a76/customs_brokers/dto.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CustomsBrokerBaseDTO(BaseModel):
|
||||
"""Base fields for CustomsBroker"""
|
||||
type: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
fax: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
tax_id: Optional[str] = None
|
||||
personal_id: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
contact: Optional[str] = None
|
||||
|
||||
|
||||
class CustomsBrokerCreateDTO(CustomsBrokerBaseDTO):
|
||||
"""Schema for creating a new CustomsBroker"""
|
||||
broker_key: str
|
||||
|
||||
|
||||
class CustomsBrokerUpdateDTO(CustomsBrokerBaseDTO):
|
||||
"""Schema for updating an existing CustomsBroker"""
|
||||
pass
|
||||
|
||||
|
||||
class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
|
||||
"""Schema for CustomsBroker response"""
|
||||
broker_key: str
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Legacy DTO for backwards compatibility (if needed elsewhere)
|
||||
class CustomsBrokerDTO(BaseModel):
|
||||
type: Optional[str] = None
|
||||
broker_key: str
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
fax: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
tax_id: Optional[str] = None
|
||||
personal_id: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
contact: Optional[str] = None
|
||||
tenant_id: str
|
||||
company_id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CustomsBrokerVUCreateDTO(BaseModel):
|
||||
certificate_path: Optional[str]
|
||||
key_path: Optional[str]
|
||||
access_key: Optional[str]
|
||||
fiel_format: Optional[str]
|
||||
signature_read_path: Optional[str]
|
||||
archive_path: Optional[str]
|
||||
fiel_access_key: Optional[str]
|
||||
web_service_user: Optional[str]
|
||||
web_service_access_key: Optional[str]
|
||||
vu_email: Optional[str]
|
||||
vu_figure_type: Optional[str]
|
||||
xml_files_path: Optional[str]
|
||||
query_tax_id: Optional[str]
|
||||
doda_certificate_path: Optional[str]
|
||||
doda_key_path: Optional[str]
|
||||
doda_web_service_user: Optional[str]
|
||||
doda_web_service_access_key: Optional[str]
|
||||
doda_fiel_access_key: Optional[str]
|
||||
doda_xml_files_path: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CustomsBrokerPersonnelDTO(BaseModel):
|
||||
broker_key: str
|
||||
line: int
|
||||
name: Optional[str]
|
||||
tax_id: Optional[str]
|
||||
personal_id: Optional[str]
|
||||
position: Optional[str]
|
||||
license: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
middle_name: Optional[str]
|
||||
email: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
94
backend/api/v1/modules/a76/customs_brokers/models.py
Normal file
94
backend/api/v1/modules/a76/customs_brokers/models.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String, UniqueConstraint, PrimaryKeyConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
||||
class CustomsBroker(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "customs_brokers"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="customs_brokers_pkey"),
|
||||
UniqueConstraint("broker_key", "tenant_id", "company_id", name="uq_broker_key_tenant_company"),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
type = Column(String(9), nullable=True)
|
||||
broker_key = Column(String(5), nullable=False)
|
||||
name = Column(String(80), nullable=True)
|
||||
address = Column(String(1500), nullable=True)
|
||||
postal_code = Column(String(15), nullable=True)
|
||||
city = Column(String(30), nullable=True)
|
||||
state = Column(String(30), nullable=True)
|
||||
phone = Column(String(30), nullable=True)
|
||||
fax = Column(String(30), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
country = Column(String(3), nullable=True)
|
||||
tax_id = Column(String(30), nullable=True)
|
||||
personal_id = Column(String(20), nullable=True)
|
||||
position = Column(String(30), nullable=True)
|
||||
license = Column(String(4), nullable=True)
|
||||
company = Column(String(200), nullable=True)
|
||||
contact = Column(String(80), nullable=True)
|
||||
|
||||
vu = relationship(
|
||||
"CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete"
|
||||
)
|
||||
personnel = relationship(
|
||||
"CustomsBrokerPersonnel", back_populates="customs_broker", cascade="all, delete"
|
||||
)
|
||||
|
||||
|
||||
class CustomsBrokerVU(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "customs_brokers_vu"
|
||||
__table_args__ = {"schema": "a76"}
|
||||
|
||||
customs_broker_id = Column(
|
||||
Integer,
|
||||
ForeignKey("a76.customs_brokers.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
certificate_path = Column(String(1499), nullable=True)
|
||||
key_path = Column(String(1499), nullable=True)
|
||||
access_key = Column(String(50), nullable=True)
|
||||
fiel_format = Column(String(19), nullable=True)
|
||||
signature_read_path = Column(String(1499), nullable=True)
|
||||
archive_path = Column(String(1499), nullable=True)
|
||||
fiel_access_key = Column(String(50), nullable=True)
|
||||
web_service_user = Column(String(100), nullable=True)
|
||||
web_service_access_key = Column(String(100), nullable=True)
|
||||
vu_email = Column(String(800), nullable=True)
|
||||
vu_figure_type = Column(String(29), nullable=True)
|
||||
xml_files_path = Column(String(1499), nullable=True)
|
||||
query_tax_id = Column(String(30), nullable=True)
|
||||
doda_certificate_path = Column(String(1499), nullable=True)
|
||||
doda_key_path = Column(String(1499), nullable=True)
|
||||
doda_web_service_user = Column(String(100), nullable=True)
|
||||
doda_web_service_access_key = Column(String(100), nullable=True)
|
||||
doda_fiel_access_key = Column(String(50), nullable=True)
|
||||
doda_xml_files_path = Column(String(1499), nullable=True)
|
||||
|
||||
customs_broker = relationship("CustomsBroker", back_populates="vu")
|
||||
|
||||
|
||||
class CustomsBrokerPersonnel(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "customs_brokers_personnel"
|
||||
__table_args__ = {"schema": "a76"}
|
||||
|
||||
customs_broker_id = Column(
|
||||
Integer,
|
||||
ForeignKey("a76.customs_brokers.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
line = Column(Integer, primary_key=True, nullable=False)
|
||||
name = Column(String(80), nullable=True)
|
||||
tax_id = Column(String(30), nullable=True)
|
||||
personal_id = Column(String(20), nullable=True)
|
||||
position = Column(String(30), nullable=True)
|
||||
license = Column(String(4), nullable=True)
|
||||
first_name = Column(String(80), nullable=True)
|
||||
last_name = Column(String(80), nullable=True)
|
||||
middle_name = Column(String(80), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
|
||||
customs_broker = relationship("CustomsBroker", back_populates="personnel")
|
||||
85
backend/api/v1/modules/a76/customs_brokers/routes.py
Normal file
85
backend/api/v1/modules/a76/customs_brokers/routes.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from typing import Dict, Any
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import dto, services
|
||||
|
||||
# Create main router
|
||||
router = APIRouter()
|
||||
|
||||
# Create CRUD routes for CustomsBroker using TenantCRUDRoutes
|
||||
customs_broker_crud = TenantCRUDRoutes(
|
||||
service=services.CustomsBrokerService,
|
||||
create_schema=dto.CustomsBrokerCreateDTO,
|
||||
update_schema=dto.CustomsBrokerUpdateDTO,
|
||||
response_schema=dto.CustomsBrokerResponseDTO,
|
||||
prefix="/customs-brokers", # No prefix since it's already in the parent router
|
||||
tags=[],
|
||||
resource_name="Customs Broker",
|
||||
id_name="broker_key",
|
||||
id_type=str,
|
||||
enable_list=True, # Enable list endpoint with pagination
|
||||
)
|
||||
|
||||
# Include the CRUD routes
|
||||
router.include_router(customs_broker_crud.router)
|
||||
|
||||
|
||||
# Additional routes for child resources (CustomsBrokerVU and CustomsBrokerPersonnel)
|
||||
# These remain as manual routes since they have different patterns
|
||||
|
||||
@router.put(
|
||||
"/customs-broker-vu/{broker_key}",
|
||||
response_model=dto.CustomsBrokerVUCreateDTO,
|
||||
)
|
||||
def update_customs_broker_vu(
|
||||
broker_key: str,
|
||||
vu_data: dto.CustomsBrokerVUCreateDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
# Verify the broker exists and belongs to the tenant/company
|
||||
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data)
|
||||
if not updated_vu:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker VU not found")
|
||||
return updated_vu
|
||||
|
||||
|
||||
@router.put(
|
||||
"/customs-broker-personnel/{broker_key}/{line}",
|
||||
response_model=dto.CustomsBrokerPersonnelDTO,
|
||||
)
|
||||
def update_customs_broker_personnel(
|
||||
broker_key: str,
|
||||
line: int,
|
||||
personnel_data: dto.CustomsBrokerPersonnelDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
# Verify the broker exists and belongs to the tenant/company
|
||||
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_personnel = services.CustomsBrokerPersonnelService.update_personnel(
|
||||
db, broker_key, line, personnel_data
|
||||
)
|
||||
if not updated_personnel:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Customs Broker Personnel not found"
|
||||
)
|
||||
return updated_personnel
|
||||
|
||||
155
backend/api/v1/modules/a76/customs_brokers/services.py
Normal file
155
backend/api/v1/modules/a76/customs_brokers/services.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import dto, models
|
||||
|
||||
|
||||
class CustomsBrokerService:
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, broker_key: str, tenant_id: int, company_id: int):
|
||||
"""Get a customs broker by broker_key with tenant/company validation"""
|
||||
return (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
filters: dict = None,
|
||||
):
|
||||
"""Get all customs brokers for a tenant/company with pagination"""
|
||||
query = db.query(models.CustomsBroker).filter(
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, broker_data: dto.CustomsBrokerCreateDTO, tenant_id: int, company_id: int):
|
||||
"""Create a new customs broker"""
|
||||
broker_dict = broker_data.model_dump()
|
||||
broker_dict["tenant_id"] = tenant_id
|
||||
broker_dict["company_id"] = company_id
|
||||
|
||||
new_broker = models.CustomsBroker(**broker_dict)
|
||||
db.add(new_broker)
|
||||
db.commit()
|
||||
db.refresh(new_broker)
|
||||
return new_broker
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, broker_key: str, tenant_id: int, broker_data: dto.CustomsBrokerUpdateDTO, company_id: int):
|
||||
"""Update an existing customs broker"""
|
||||
broker = CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if broker:
|
||||
for key, value in broker_data.model_dump(exclude_unset=True).items():
|
||||
setattr(broker, key, value)
|
||||
db.commit()
|
||||
db.refresh(broker)
|
||||
return broker
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, broker_key: str, tenant_id: int, company_id: int):
|
||||
"""Delete a customs broker"""
|
||||
broker = CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if broker:
|
||||
db.delete(broker)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class CustomsBrokerVUService:
|
||||
@staticmethod
|
||||
def get_by_broker_key(db: Session, broker_key: str):
|
||||
return (
|
||||
db.query(models.CustomsBrokerVU)
|
||||
.filter(models.CustomsBrokerVU.broker_key == broker_key)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_vu(db: Session, vu_data: dto.CustomsBrokerVUCreateDTO):
|
||||
new_vu = models.CustomsBrokerVU(**vu_data.dict())
|
||||
db.add(new_vu)
|
||||
db.commit()
|
||||
db.refresh(new_vu)
|
||||
return new_vu
|
||||
|
||||
@staticmethod
|
||||
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO):
|
||||
vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key)
|
||||
if vu:
|
||||
for key, value in vu_data.dict(exclude_unset=True).items():
|
||||
setattr(vu, key, value)
|
||||
db.commit()
|
||||
db.refresh(vu)
|
||||
return vu
|
||||
|
||||
@staticmethod
|
||||
def delete_vu(db: Session, broker_key: str):
|
||||
vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key)
|
||||
if vu:
|
||||
db.delete(vu)
|
||||
db.commit()
|
||||
return vu
|
||||
|
||||
|
||||
class CustomsBrokerPersonnelService:
|
||||
@staticmethod
|
||||
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int):
|
||||
return (
|
||||
db.query(models.CustomsBrokerPersonnel)
|
||||
.filter(
|
||||
models.CustomsBrokerPersonnel.broker_key == broker_key,
|
||||
models.CustomsBrokerPersonnel.line == line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_personnel(db: Session, personnel_data: dto.CustomsBrokerPersonnelDTO):
|
||||
new_personnel = models.CustomsBrokerPersonnel(**personnel_data.dict())
|
||||
db.add(new_personnel)
|
||||
db.commit()
|
||||
db.refresh(new_personnel)
|
||||
return new_personnel
|
||||
|
||||
@staticmethod
|
||||
def update_personnel(
|
||||
db: Session,
|
||||
broker_key: str,
|
||||
line: int,
|
||||
personnel_data: dto.CustomsBrokerPersonnelDTO,
|
||||
):
|
||||
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
|
||||
db, broker_key, line
|
||||
)
|
||||
if personnel:
|
||||
for key, value in personnel_data.dict(exclude_unset=True).items():
|
||||
setattr(personnel, key, value)
|
||||
db.commit()
|
||||
db.refresh(personnel)
|
||||
return personnel
|
||||
|
||||
@staticmethod
|
||||
def delete_personnel(db: Session, broker_key: str, line: int):
|
||||
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
|
||||
db, broker_key, line
|
||||
)
|
||||
if personnel:
|
||||
db.delete(personnel)
|
||||
db.commit()
|
||||
return personnel
|
||||
28
backend/api/v1/modules/a76/fraction_rule_octave/dto.py
Normal file
28
backend/api/v1/modules/a76/fraction_rule_octave/dto.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
DTOs for FractionRuleOctave.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FractionRuleOctaveBaseDTO(BaseModel):
|
||||
PERMISSION: str
|
||||
LINE: int
|
||||
FRACTION: str
|
||||
QUOTA_AMOUNT: Optional[float]
|
||||
USED_AMOUNT: Optional[float]
|
||||
QUOTA_VALUE: Optional[float]
|
||||
USED_VALUE: Optional[float]
|
||||
UNIT_COST_ME: Optional[float]
|
||||
UNIT_MEASURE: Optional[str]
|
||||
|
||||
|
||||
class FractionRuleOctaveCreateDTO(FractionRuleOctaveBaseDTO):
|
||||
pass
|
||||
|
||||
|
||||
class FractionRuleOctaveResponseDTO(FractionRuleOctaveBaseDTO):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
32
backend/api/v1/modules/a76/fraction_rule_octave/models.py
Normal file
32
backend/api/v1/modules/a76/fraction_rule_octave/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class FractionRuleOctave(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "fraction_rule_octave"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="fraction_rule_octave_pkey"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"company_id",
|
||||
"permission",
|
||||
"line",
|
||||
"fraction",
|
||||
name="uq_fraction_rule_octave_permission_line_fraction",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
permission: Mapped[str] = mapped_column(String(20))
|
||||
line: Mapped[int] = mapped_column(Integer)
|
||||
fraction: Mapped[str] = mapped_column(String(10))
|
||||
112
backend/api/v1/modules/a76/fraction_rule_octave/routes.py
Normal file
112
backend/api/v1/modules/a76/fraction_rule_octave/routes.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from typing import List
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import FractionRuleOctaveCreateDTO, FractionRuleOctaveResponseDTO
|
||||
from .services import FractionRuleOctaveService
|
||||
|
||||
router = APIRouter(prefix="/fraction_rule_octave")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[FractionRuleOctaveResponseDTO])
|
||||
async def list_fractions(
|
||||
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List all FractionRuleOctave entries.
|
||||
"""
|
||||
# Validate access to the tenant and company
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id = current_user.get("company_id")
|
||||
|
||||
if not tenant_id or not company_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: Tenant or Company not found"
|
||||
)
|
||||
|
||||
return db.query(FractionRuleOctaveService).all()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{permission}/{line}/{fraction}", response_model=FractionRuleOctaveResponseDTO
|
||||
)
|
||||
async def read_fraction(
|
||||
permission: str,
|
||||
line: int,
|
||||
fraction: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific FractionRuleOctave by its composite key.
|
||||
"""
|
||||
# Validate access to the tenant and company
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id = current_user.get("company_id")
|
||||
|
||||
if not tenant_id or not company_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: Tenant or Company not found"
|
||||
)
|
||||
|
||||
frac = FractionRuleOctaveService.get_fraction_by_permission_line(
|
||||
db, permission, line, fraction
|
||||
)
|
||||
if not frac:
|
||||
raise HTTPException(status_code=404, detail="FractionRuleOctave not found")
|
||||
return frac
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=FractionRuleOctaveResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_frac(
|
||||
frac_data: FractionRuleOctaveCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new FractionRuleOctave entry.
|
||||
"""
|
||||
# Validate access to the tenant and company
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id = current_user.get("company_id")
|
||||
|
||||
if not tenant_id or not company_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: Tenant or Company not found"
|
||||
)
|
||||
|
||||
return FractionRuleOctaveService.create_frac(db, frac_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{permission}/{line}/{fraction}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def delete_fraction(
|
||||
permission: str,
|
||||
line: int,
|
||||
fraction: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a FractionRuleOctave by its composite key.
|
||||
"""
|
||||
# Validate access to the tenant and company
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id = current_user.get("company_id")
|
||||
|
||||
if not tenant_id or not company_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Access denied: Tenant or Company not found"
|
||||
)
|
||||
|
||||
frac = FractionRuleOctaveService.delete_fraction(db, permission, line, fraction)
|
||||
if not frac:
|
||||
raise HTTPException(status_code=404, detail="FractionRuleOctave not found")
|
||||
41
backend/api/v1/modules/a76/fraction_rule_octave/services.py
Normal file
41
backend/api/v1/modules/a76/fraction_rule_octave/services.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import dto, models
|
||||
|
||||
"""
|
||||
Service layer for FractionRuleOctave.
|
||||
"""
|
||||
|
||||
|
||||
class FractionRuleOctaveService:
|
||||
@staticmethod
|
||||
def get_fraction_by_permission_line(
|
||||
db: Session, permission: str, line: int, fraction: str
|
||||
):
|
||||
return (
|
||||
db.query(models.FractionRuleOctave)
|
||||
.filter(
|
||||
models.FractionRuleOctave.permission == permission,
|
||||
models.FractionRuleOctave.line == line,
|
||||
models.FractionRuleOctave.fraction == fraction,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_frac(db: Session, frac_data: dto.FractionRuleOctaveCreateDTO):
|
||||
new_frac = models.FractionRuleOctave(**frac_data.model_dump())
|
||||
db.add(new_frac)
|
||||
db.commit()
|
||||
db.refresh(new_frac)
|
||||
return new_frac
|
||||
|
||||
@staticmethod
|
||||
def delete_fraction(db: Session, permission: str, line: int, fraction: str):
|
||||
frac = FractionRuleOctaveService.get_fraction_by_permission_line(
|
||||
db, permission, line, fraction
|
||||
)
|
||||
if frac:
|
||||
db.delete(frac)
|
||||
db.commit()
|
||||
return frac
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de Company
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
223
backend/api/v1/modules/a76/general_catalogs/company/dto.py
Normal file
223
backend/api/v1/modules/a76/general_catalogs/company/dto.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de empresa
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CompanyCreateDTO(BaseModel):
|
||||
"""DTO para crear una empresa"""
|
||||
|
||||
name: Optional[str] = Field(None, max_length=255, description="Company name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
|
||||
main_activity: Optional[str] = Field(
|
||||
None, max_length=255, description="Main activity"
|
||||
)
|
||||
|
||||
# Program information
|
||||
program: Optional[str] = Field(None, max_length=10, description="Program")
|
||||
program_number: Optional[str] = Field(
|
||||
None, max_length=40, description="Program number"
|
||||
)
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(
|
||||
None, max_length=20, description="PROSEC authorization"
|
||||
)
|
||||
|
||||
# Identifiers
|
||||
manufacturer_id: Optional[str] = Field(
|
||||
None, max_length=25, description="Manufacturer ID"
|
||||
)
|
||||
broker_company: Optional[str] = Field(
|
||||
None, max_length=10, description="Broker company"
|
||||
)
|
||||
|
||||
# Responsible person
|
||||
responsible: Optional[str] = Field(
|
||||
None, max_length=80, description="Responsible person"
|
||||
)
|
||||
responsible_name: Optional[str] = Field(
|
||||
None, max_length=20, description="Responsible first name"
|
||||
)
|
||||
responsible_last_name: Optional[str] = Field(
|
||||
None, max_length=20, description="Responsible last name"
|
||||
)
|
||||
responsible_mother_last_name: Optional[str] = Field(
|
||||
None, max_length=20, description="Responsible mother's last name"
|
||||
)
|
||||
responsible_rfc: Optional[str] = Field(
|
||||
None, max_length=30, description="Responsible RFC"
|
||||
)
|
||||
position: Optional[str] = Field(
|
||||
None, max_length=30, description="Responsible position"
|
||||
)
|
||||
|
||||
# Configuration
|
||||
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
|
||||
has_express_line: Optional[bool] = Field(None, description="Has express line")
|
||||
order_format_type: Optional[str] = Field(
|
||||
None, max_length=19, description="Order format type"
|
||||
)
|
||||
previous_code: Optional[int] = Field(None, description="Previous code")
|
||||
is_service_company: Optional[bool] = Field(None, description="Is service company")
|
||||
|
||||
# Client and subassembly
|
||||
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
|
||||
subassembly_mode: Optional[str] = Field(
|
||||
None, max_length=7, description="Subassembly mode"
|
||||
)
|
||||
|
||||
# Additional information
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
inter_db_name: Optional[str] = Field(
|
||||
None, max_length=100, description="Inter DB name"
|
||||
)
|
||||
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
|
||||
trusted_exporter_number: Optional[str] = Field(
|
||||
None, max_length=50, description="Trusted exporter number"
|
||||
)
|
||||
prevalidator_key: Optional[str] = Field(
|
||||
None, max_length=20, description="Prevalidator key"
|
||||
)
|
||||
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CompanyUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una empresa"""
|
||||
|
||||
name: Optional[str] = Field(None, max_length=255, description="Company name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
|
||||
main_activity: Optional[str] = Field(
|
||||
None, max_length=255, description="Main activity"
|
||||
)
|
||||
|
||||
# Program information
|
||||
program: Optional[str] = Field(None, max_length=10, description="Program")
|
||||
program_number: Optional[str] = Field(
|
||||
None, max_length=40, description="Program number"
|
||||
)
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(
|
||||
None, max_length=20, description="PROSEC authorization"
|
||||
)
|
||||
|
||||
# Identifiers
|
||||
manufacturer_id: Optional[str] = Field(
|
||||
None, max_length=25, description="Manufacturer ID"
|
||||
)
|
||||
broker_company: Optional[str] = Field(
|
||||
None, max_length=10, description="Broker company"
|
||||
)
|
||||
|
||||
# Responsible person
|
||||
responsible: Optional[str] = Field(
|
||||
None, max_length=80, description="Responsible person"
|
||||
)
|
||||
responsible_name: Optional[str] = Field(
|
||||
None, max_length=20, description="Responsible first name"
|
||||
)
|
||||
responsible_last_name: Optional[str] = Field(
|
||||
None, max_length=20, description="Responsible last name"
|
||||
)
|
||||
responsible_mother_last_name: Optional[str] = Field(
|
||||
None, max_length=20, description="Responsible mother's last name"
|
||||
)
|
||||
responsible_rfc: Optional[str] = Field(
|
||||
None, max_length=30, description="Responsible RFC"
|
||||
)
|
||||
position: Optional[str] = Field(
|
||||
None, max_length=30, description="Responsible position"
|
||||
)
|
||||
|
||||
# Configuration
|
||||
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
|
||||
has_express_line: Optional[bool] = Field(None, description="Has express line")
|
||||
order_format_type: Optional[str] = Field(
|
||||
None, max_length=19, description="Order format type"
|
||||
)
|
||||
previous_code: Optional[int] = Field(None, description="Previous code")
|
||||
is_service_company: Optional[bool] = Field(None, description="Is service company")
|
||||
|
||||
# Client and subassembly
|
||||
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
|
||||
subassembly_mode: Optional[str] = Field(
|
||||
None, max_length=7, description="Subassembly mode"
|
||||
)
|
||||
|
||||
# Additional information
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
inter_db_name: Optional[str] = Field(
|
||||
None, max_length=100, description="Inter DB name"
|
||||
)
|
||||
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
|
||||
trusted_exporter_number: Optional[str] = Field(
|
||||
None, max_length=50, description="Trusted exporter number"
|
||||
)
|
||||
prevalidator_key: Optional[str] = Field(
|
||||
None, max_length=20, description="Prevalidator key"
|
||||
)
|
||||
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CompanyResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de empresa"""
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
name: Optional[str] = None
|
||||
rfc: Optional[str] = None
|
||||
main_activity: Optional[str] = None
|
||||
|
||||
# Program information
|
||||
program: Optional[str] = None
|
||||
program_number: Optional[str] = None
|
||||
prosec: Optional[int] = None
|
||||
prosec_authorization: Optional[str] = None
|
||||
|
||||
# Identifiers
|
||||
manufacturer_id: Optional[str] = None
|
||||
broker_company: Optional[str] = None
|
||||
|
||||
# Responsible person
|
||||
responsible: Optional[str] = None
|
||||
responsible_name: Optional[str] = None
|
||||
responsible_last_name: Optional[str] = None
|
||||
responsible_mother_last_name: Optional[str] = None
|
||||
responsible_rfc: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
|
||||
# Configuration
|
||||
logo: Optional[str] = None
|
||||
has_express_line: Optional[bool] = None
|
||||
order_format_type: Optional[str] = None
|
||||
previous_code: Optional[int] = None
|
||||
is_service_company: Optional[bool] = None
|
||||
|
||||
# Client and subassembly
|
||||
client_name: Optional[str] = None
|
||||
subassembly_mode: Optional[str] = None
|
||||
|
||||
# Additional information
|
||||
curp: Optional[str] = None
|
||||
inter_db_name: Optional[str] = None
|
||||
ctpat_svi: Optional[str] = None
|
||||
trusted_exporter_number: Optional[str] = None
|
||||
prevalidator_key: Optional[str] = None
|
||||
seventh_amendment: Optional[bool] = None
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -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
|
||||
326
backend/api/v1/modules/a76/general_catalogs/company/routes.py
Normal file
326
backend/api/v1/modules/a76/general_catalogs/company/routes.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Rutas para gestión de empresa
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from .....common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
|
||||
from .models import Company
|
||||
from .service import CompanyService
|
||||
|
||||
# Main router that includes base CRUD
|
||||
router = APIRouter(prefix="/company")
|
||||
|
||||
@router.post(
|
||||
"", # Se suma al prefix, queda POST /api/v1/a76/company
|
||||
response_model=CompanyResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new company",
|
||||
)
|
||||
async def create_company(
|
||||
data: CompanyCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
service = CompanyService(db)
|
||||
return service.create_company_manually(data, tenant_id=tenant_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"", # GET /api/v1/a76/company with pagination
|
||||
response_model=dict,
|
||||
summary="Get companies with pagination",
|
||||
)
|
||||
async def list_companies(
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
name: Optional[str] = None,
|
||||
rfc: Optional[str] = None,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get paginated list of companies for current tenant with optional filters"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
filters = {}
|
||||
if name:
|
||||
filters["name"] = name
|
||||
if rfc:
|
||||
filters["rfc"] = rfc
|
||||
|
||||
service = CompanyService(db)
|
||||
items, total = service.get_all(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id=0, # Not used for companies
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters=filters if filters else None
|
||||
)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
"items": [CompanyResponseDTO.model_validate(item) for item in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"pages": total_pages,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/my-companies",
|
||||
response_model=List[CompanyResponseDTO],
|
||||
summary="Get all companies for current tenant",
|
||||
)
|
||||
async def get_my_companies(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get all companies that belong to the current user's tenant"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
service = CompanyService(db)
|
||||
companies = service.get_companies_by_tenant(tenant_id)
|
||||
|
||||
return [CompanyResponseDTO.model_validate(company) for company in companies]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status/exists",
|
||||
response_model=dict,
|
||||
summary="Check if company exists for tenant",
|
||||
)
|
||||
async def check_company_exists(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Check if a company exists for the current tenant"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
service = CompanyService(db)
|
||||
exists = service.exists_company(tenant_id)
|
||||
|
||||
return {"exists": exists}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/info/basic/{company_id}",
|
||||
response_model=dict,
|
||||
summary="Get basic company info",
|
||||
)
|
||||
async def get_basic_info(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get basic information about a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id_from_user = current_user.get("company_id")
|
||||
|
||||
# Validate access
|
||||
validate_access_to_resource(
|
||||
db, tenant_id, company_id_from_user, Company, company_id, "id"
|
||||
)
|
||||
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"id": company.id,
|
||||
"name": company.name,
|
||||
"rfc": company.rfc,
|
||||
"program": company.program,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/info/responsible/{company_id}",
|
||||
response_model=dict,
|
||||
summary="Get responsible person info",
|
||||
)
|
||||
async def get_responsible_info(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get responsible person information for a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id_from_user = current_user.get("company_id")
|
||||
|
||||
# Validate access
|
||||
validate_access_to_resource(
|
||||
db, tenant_id, company_id_from_user, Company, company_id, "id"
|
||||
)
|
||||
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"responsible": company.responsible,
|
||||
"responsible_name": company.responsible_name,
|
||||
"responsible_last_name": company.responsible_last_name,
|
||||
"responsible_mother_last_name": company.responsible_mother_last_name,
|
||||
"responsible_rfc": company.responsible_rfc,
|
||||
"position": company.position,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/info/program/{company_id}",
|
||||
response_model=dict,
|
||||
summary="Get program information",
|
||||
)
|
||||
async def get_program_info(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get program information for a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
company_id_from_user = current_user.get("company_id")
|
||||
|
||||
# Validate access
|
||||
validate_access_to_resource(
|
||||
db, tenant_id, company_id_from_user, Company, company_id, "id"
|
||||
)
|
||||
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_from_user)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"program": company.program,
|
||||
"program_number": company.program_number,
|
||||
"prosec": company.prosec,
|
||||
"prosec_authorization": company.prosec_authorization,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{company_id}",
|
||||
response_model=CompanyResponseDTO,
|
||||
summary="Get company by ID",
|
||||
)
|
||||
async def get_company(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a specific company by ID"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return CompanyResponseDTO.model_validate(company)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{company_id}",
|
||||
response_model=CompanyResponseDTO,
|
||||
summary="Update company",
|
||||
)
|
||||
async def update_company(
|
||||
company_id: int,
|
||||
data: CompanyUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
updated_company = CompanyService.update(
|
||||
db, company_id, tenant_id, 0, data
|
||||
)
|
||||
if not updated_company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return CompanyResponseDTO.model_validate(updated_company)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{company_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete company",
|
||||
)
|
||||
async def delete_company(
|
||||
company_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a company"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
success = CompanyService.delete(db, company_id, tenant_id, 0)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
return None
|
||||
200
backend/api/v1/modules/a76/general_catalogs/company/service.py
Normal file
200
backend/api/v1/modules/a76/general_catalogs/company/service.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de empresa
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
|
||||
from .models import Company
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompanyService:
|
||||
"""Servicio para gestión de empresa"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# Métodos para TenantCRUDRoutes
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[List[Company], int]:
|
||||
"""Get all companies for a tenant with pagination"""
|
||||
query = db.query(Company).filter(Company.tenant_id == tenant_id)
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
if filters.get("name"):
|
||||
query = query.filter(
|
||||
Company.name.ilike(f"%{filters['name']}%")
|
||||
)
|
||||
if filters.get("rfc"):
|
||||
query = query.filter(
|
||||
Company.rfc.ilike(f"%{filters['rfc']}%")
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
companies = query.offset(skip).limit(limit).all()
|
||||
|
||||
return companies, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(
|
||||
db: Session, company_id: int, tenant_id: int, company_id_unused: int
|
||||
) -> Optional[Company]:
|
||||
"""Get company by ID"""
|
||||
return (
|
||||
db.query(Company)
|
||||
.filter(
|
||||
Company.id == company_id,
|
||||
Company.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# ESTE ES EL MÉTODO VIEJO QUE CAUSABA PROBLEMAS (Lo dejamos por si acaso)
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session,
|
||||
company_data: CompanyCreateDTO,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> Company:
|
||||
"""Create a new company (MÉTODO GENÉRICO - NO USAR PARA CREACIÓN MANUAL)"""
|
||||
try:
|
||||
db_company = Company(
|
||||
**company_data.model_dump(exclude_unset=True),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
db.add(db_company)
|
||||
db.commit()
|
||||
db.refresh(db_company)
|
||||
|
||||
return db_company
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError creating company: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Company already exists",
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating company: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error creating company")
|
||||
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session,
|
||||
company_id: int,
|
||||
tenant_id: int,
|
||||
company_id_unused: int,
|
||||
company_data: CompanyUpdateDTO,
|
||||
) -> Optional[Company]:
|
||||
"""Update a company"""
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
|
||||
if not company:
|
||||
return None
|
||||
|
||||
# Update only provided fields
|
||||
update_data = company_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(company, field, value)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(company)
|
||||
return company
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error updating company {company_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating company")
|
||||
|
||||
@staticmethod
|
||||
def delete(
|
||||
db: Session, company_id: int, tenant_id: int, company_id_unused: int
|
||||
) -> bool:
|
||||
"""Delete a company"""
|
||||
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
|
||||
if not company:
|
||||
return False
|
||||
|
||||
try:
|
||||
db.delete(company)
|
||||
db.commit()
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError deleting company {company_id}: {str(e)}")
|
||||
# Check if it's a foreign key constraint
|
||||
if "foreign key constraint" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No se puede eliminar la empresa porque tiene registros relacionados (facturas, conceptos, etc.)"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Error al eliminar la empresa")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting company {company_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error al eliminar la empresa")
|
||||
|
||||
# Custom methods
|
||||
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
|
||||
"""Get all companies for a tenant"""
|
||||
return (
|
||||
self.db.query(Company)
|
||||
.filter(Company.tenant_id == tenant_id)
|
||||
.order_by(Company.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
def exists_company(self, tenant_id: int) -> bool:
|
||||
"""Check if a company exists for a tenant"""
|
||||
return (
|
||||
self.db.query(Company)
|
||||
.filter(Company.tenant_id == tenant_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company:
|
||||
|
||||
try:
|
||||
# 1. Preparar datos
|
||||
obj_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# 2. Crear objeto SQLAlchemy
|
||||
db_obj = Company(**obj_data, tenant_id=tenant_id)
|
||||
|
||||
# 3. Guardar
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating company manually: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Error de integridad: Es posible que esta empresa ya exista.",
|
||||
)
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating company manually: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}")
|
||||
@@ -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)
|
||||
46
backend/api/v1/modules/a76/general_catalogs/concepts/dto.py
Normal file
46
backend/api/v1/modules/a76/general_catalogs/concepts/dto.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class ConceptBase(BaseModel):
|
||||
code: str = Field(..., max_length=15, description="Concept Code (CLAVE)")
|
||||
company_id: int = Field(..., description="Company ID")
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=120, description="Description")
|
||||
description_en: Optional[str] = Field(
|
||||
None, max_length=120, description="English Description")
|
||||
detailed_description: Optional[str] = Field(
|
||||
None, max_length=1000, description="Detailed Description")
|
||||
priority: Optional[int] = Field(None, description="Priority")
|
||||
priority_ame: Optional[int] = Field(None, description="American Priority")
|
||||
first_total: Optional[bool] = Field(None, description="First Total")
|
||||
type: Optional[str] = Field(None, max_length=9, description="Type")
|
||||
is_printed: Optional[bool] = Field(None, description="Is Printed")
|
||||
section: Optional[int] = Field(None, description="Section")
|
||||
classification: Optional[str] = Field(
|
||||
None, max_length=30, description="Classification")
|
||||
|
||||
|
||||
class ConceptCreate(ConceptBase):
|
||||
pass
|
||||
|
||||
|
||||
class ConceptUpdate(BaseModel):
|
||||
code: Optional[str] = Field(None, max_length=15)
|
||||
description: Optional[str] = Field(None, max_length=120)
|
||||
description_en: Optional[str] = Field(None, max_length=120)
|
||||
detailed_description: Optional[str] = Field(None, max_length=1000)
|
||||
priority: Optional[int] = None
|
||||
priority_ame: Optional[int] = None
|
||||
first_total: Optional[bool] = None
|
||||
type: Optional[str] = Field(None, max_length=9)
|
||||
is_printed: Optional[bool] = None
|
||||
section: Optional[int] = None
|
||||
classification: Optional[str] = Field(None, max_length=30)
|
||||
|
||||
|
||||
class ConceptResponse(ConceptBase):
|
||||
id: int
|
||||
tenant_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de DODA (Documentos de Operación de Aduana)
|
||||
"""
|
||||
423
backend/api/v1/modules/a76/general_catalogs/doda/dto.py
Normal file
423
backend/api/v1/modules/a76/general_catalogs/doda/dto.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de DODA
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============ DODA CONTAINER SEAL DTOS ============
|
||||
class DodaContainerSealCreateDTO(BaseModel):
|
||||
"""DTO para crear un candado de contenedor"""
|
||||
|
||||
seal_value: Optional[str] = Field(
|
||||
None, max_length=21, description="Seal value")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaContainerSealResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un candado"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
seal_line: int
|
||||
seal_value: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ DODA CONTAINER DTOS ============
|
||||
class DodaContainerCreateDTO(BaseModel):
|
||||
"""DTO para crear un contenedor"""
|
||||
|
||||
container_value: Optional[str] = Field(
|
||||
None, max_length=20, description="Container value")
|
||||
seals: Optional[str] = Field(None, max_length=254, description="Seals")
|
||||
seals_detail: Optional[List[DodaContainerSealCreateDTO]] = Field(
|
||||
None, description="Container seals"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaContainerUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar un contenedor"""
|
||||
|
||||
container_value: Optional[str] = Field(
|
||||
None, max_length=20, description="Container value")
|
||||
seals: Optional[str] = Field(None, max_length=254, description="Seals")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaContainerResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un contenedor"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
container_line: int
|
||||
container_value: Optional[str] = None
|
||||
seals: Optional[str] = None
|
||||
seals_detail: Optional[List[DodaContainerSealResponseDTO]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ DODA AMERICAN PEDIMENTO DTOS ============
|
||||
class DodaAmericanPedimentoCreateDTO(BaseModel):
|
||||
"""DTO para crear un pedimento americano"""
|
||||
|
||||
american_pedimento_type: Optional[str] = Field(
|
||||
None, max_length=2, description="American pedimento type"
|
||||
)
|
||||
american_pedimento_value: Optional[str] = Field(
|
||||
None, max_length=20, description="American pedimento value"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaAmericanPedimentoUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar un pedimento americano"""
|
||||
|
||||
american_pedimento_type: Optional[str] = Field(
|
||||
None, max_length=2, description="American pedimento type"
|
||||
)
|
||||
american_pedimento_value: Optional[str] = Field(
|
||||
None, max_length=20, description="American pedimento value"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaAmericanPedimentoResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un pedimento americano"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
american_pedimento_line: int
|
||||
american_pedimento_type: Optional[str] = None
|
||||
american_pedimento_value: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ DODA PEDIMENTO DTOS ============
|
||||
class DodaPedimentoCreateDTO(BaseModel):
|
||||
"""DTO para crear un pedimento DODA"""
|
||||
|
||||
authorization_patent: Optional[str] = Field(
|
||||
None, max_length=10, description="Authorization patent"
|
||||
)
|
||||
document: Optional[str] = Field(
|
||||
None, max_length=50, description="Document")
|
||||
shipment: Optional[str] = Field(
|
||||
None, max_length=11, description="Shipment")
|
||||
cove: Optional[str] = Field(None, max_length=50, description="COVE")
|
||||
umc: Optional[str] = Field(None, max_length=20, description="UMC")
|
||||
effective_amount_usd: Optional[Decimal] = Field(
|
||||
None, description="Effective amount USD")
|
||||
difference_amount_usd: Optional[Decimal] = Field(
|
||||
None, description="Difference amount USD")
|
||||
dta_niu: Optional[str] = Field(None, max_length=20, description="DTA NIU")
|
||||
article_7: Optional[bool] = Field(None, description="Article 7")
|
||||
pedimento_sys_id: Optional[int] = Field(
|
||||
None, description="Pedimento system ID")
|
||||
invoice_line: Optional[int] = Field(None, description="Invoice line")
|
||||
part_ii_line: Optional[int] = Field(None, description="Part II line")
|
||||
pedimento_type: Optional[str] = Field(
|
||||
None, max_length=20, description="Pedimento type")
|
||||
zero_packaging_validation: Optional[bool] = Field(
|
||||
None, description="Zero packaging validation"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaPedimentoUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar un pedimento DODA"""
|
||||
|
||||
authorization_patent: Optional[str] = Field(
|
||||
None, max_length=10, description="Authorization patent"
|
||||
)
|
||||
document: Optional[str] = Field(
|
||||
None, max_length=50, description="Document")
|
||||
shipment: Optional[str] = Field(
|
||||
None, max_length=11, description="Shipment")
|
||||
cove: Optional[str] = Field(None, max_length=50, description="COVE")
|
||||
umc: Optional[str] = Field(None, max_length=20, description="UMC")
|
||||
effective_amount_usd: Optional[Decimal] = Field(
|
||||
None, description="Effective amount USD")
|
||||
difference_amount_usd: Optional[Decimal] = Field(
|
||||
None, description="Difference amount USD")
|
||||
dta_niu: Optional[str] = Field(None, max_length=20, description="DTA NIU")
|
||||
article_7: Optional[bool] = Field(None, description="Article 7")
|
||||
pedimento_sys_id: Optional[int] = Field(
|
||||
None, description="Pedimento system ID")
|
||||
invoice_line: Optional[int] = Field(None, description="Invoice line")
|
||||
part_ii_line: Optional[int] = Field(None, description="Part II line")
|
||||
pedimento_type: Optional[str] = Field(
|
||||
None, max_length=20, description="Pedimento type")
|
||||
zero_packaging_validation: Optional[bool] = Field(
|
||||
None, description="Zero packaging validation"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaPedimentoResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un pedimento DODA"""
|
||||
|
||||
id: int
|
||||
doda_sys_id: int
|
||||
pedimento_line: int
|
||||
authorization_patent: Optional[str] = None
|
||||
document: Optional[str] = None
|
||||
shipment: Optional[str] = None
|
||||
cove: Optional[str] = None
|
||||
umc: Optional[str] = None
|
||||
effective_amount_usd: Optional[Decimal] = None
|
||||
difference_amount_usd: Optional[Decimal] = None
|
||||
dta_niu: Optional[str] = None
|
||||
article_7: Optional[bool] = None
|
||||
pedimento_sys_id: Optional[int] = None
|
||||
invoice_line: Optional[int] = None
|
||||
part_ii_line: Optional[int] = None
|
||||
pedimento_type: Optional[str] = None
|
||||
zero_packaging_validation: Optional[bool] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ MAIN DODA DTOS ============
|
||||
class DodaCreateDTO(BaseModel):
|
||||
"""DTO para crear un DODA"""
|
||||
|
||||
integration_number: Optional[str] = Field(
|
||||
None, max_length=30, description="Integration number")
|
||||
doda_date: Optional[int] = Field(None, description="DODA date")
|
||||
doda_time: Optional[int] = Field(None, description="DODA time")
|
||||
dispatch_customs: Optional[str] = Field(
|
||||
None, max_length=3, description="Dispatch customs")
|
||||
customs_sections: Optional[str] = Field(
|
||||
None, max_length=3, description="Customs sections")
|
||||
patent: Optional[str] = Field(None, max_length=4, description="Patent")
|
||||
pedimentos: Optional[str] = Field(
|
||||
None, max_length=80, description="Pedimentos")
|
||||
caat: Optional[str] = Field(None, max_length=10, description="CAAT")
|
||||
transport_identification: Optional[str] = Field(
|
||||
None, max_length=20, description="Transport identification"
|
||||
)
|
||||
fast_id: Optional[str] = Field(None, max_length=20, description="FAST ID")
|
||||
operation_type: Optional[str] = Field(
|
||||
None, max_length=1, description="Operation type")
|
||||
selected: Optional[bool] = Field(None, description="Selected")
|
||||
user_selected: Optional[str] = Field(
|
||||
None, max_length=30, description="User selected")
|
||||
last_user: Optional[str] = Field(
|
||||
None, max_length=30, description="Last user")
|
||||
responsible: Optional[str] = Field(
|
||||
None, max_length=14, description="Responsible")
|
||||
carrier: Optional[str] = Field(None, max_length=8, description="Carrier")
|
||||
shipments: Optional[str] = Field(
|
||||
None, max_length=80, description="Shipments")
|
||||
pedimento_type: Optional[str] = Field(
|
||||
None, max_length=30, description="Pedimento type")
|
||||
original_chain: Optional[str] = Field(
|
||||
None, max_length=5000, description="Original chain")
|
||||
serial_number: Optional[str] = Field(
|
||||
None, max_length=21, description="Serial number")
|
||||
electronic_signature: Optional[str] = Field(
|
||||
None, max_length=2000, description="Electronic signature"
|
||||
)
|
||||
transaction_number: Optional[str] = Field(
|
||||
None, max_length=30, description="Transaction number")
|
||||
status: Optional[str] = Field(None, max_length=30, description="Status")
|
||||
linq_sat_qr: Optional[str] = Field(
|
||||
None, max_length=1000, description="LINQ SAT QR")
|
||||
sat_certificate: Optional[str] = Field(
|
||||
None, max_length=2001, description="SAT certificate")
|
||||
sat_digital_seal: Optional[str] = Field(
|
||||
None, description="SAT digital seal")
|
||||
xml_doda_sent_path: Optional[str] = Field(
|
||||
None, max_length=1000, description="XML DODA sent path")
|
||||
xml_doda_response_path: Optional[str] = Field(
|
||||
None, max_length=1000, description="XML DODA response path"
|
||||
)
|
||||
sat_original_chain: Optional[str] = Field(
|
||||
None, description="SAT original chain")
|
||||
customs_clearance: Optional[int] = Field(
|
||||
None, description="Customs clearance")
|
||||
unique_badge_number: Optional[str] = Field(
|
||||
None, max_length=250, description="Unique badge number"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar un DODA"""
|
||||
|
||||
integration_number: Optional[str] = Field(
|
||||
None, max_length=30, description="Integration number")
|
||||
doda_date: Optional[int] = Field(None, description="DODA date")
|
||||
doda_time: Optional[int] = Field(None, description="DODA time")
|
||||
dispatch_customs: Optional[str] = Field(
|
||||
None, max_length=3, description="Dispatch customs")
|
||||
customs_sections: Optional[str] = Field(
|
||||
None, max_length=3, description="Customs sections")
|
||||
patent: Optional[str] = Field(None, max_length=4, description="Patent")
|
||||
pedimentos: Optional[str] = Field(
|
||||
None, max_length=80, description="Pedimentos")
|
||||
caat: Optional[str] = Field(None, max_length=10, description="CAAT")
|
||||
transport_identification: Optional[str] = Field(
|
||||
None, max_length=20, description="Transport identification"
|
||||
)
|
||||
fast_id: Optional[str] = Field(None, max_length=20, description="FAST ID")
|
||||
operation_type: Optional[str] = Field(
|
||||
None, max_length=1, description="Operation type")
|
||||
selected: Optional[bool] = Field(None, description="Selected")
|
||||
user_selected: Optional[str] = Field(
|
||||
None, max_length=30, description="User selected")
|
||||
last_user: Optional[str] = Field(
|
||||
None, max_length=30, description="Last user")
|
||||
responsible: Optional[str] = Field(
|
||||
None, max_length=14, description="Responsible")
|
||||
carrier: Optional[str] = Field(None, max_length=8, description="Carrier")
|
||||
shipments: Optional[str] = Field(
|
||||
None, max_length=80, description="Shipments")
|
||||
pedimento_type: Optional[str] = Field(
|
||||
None, max_length=30, description="Pedimento type")
|
||||
original_chain: Optional[str] = Field(
|
||||
None, max_length=5000, description="Original chain")
|
||||
serial_number: Optional[str] = Field(
|
||||
None, max_length=21, description="Serial number")
|
||||
electronic_signature: Optional[str] = Field(
|
||||
None, max_length=2000, description="Electronic signature"
|
||||
)
|
||||
transaction_number: Optional[str] = Field(
|
||||
None, max_length=30, description="Transaction number")
|
||||
status: Optional[str] = Field(None, max_length=30, description="Status")
|
||||
linq_sat_qr: Optional[str] = Field(
|
||||
None, max_length=1000, description="LINQ SAT QR")
|
||||
sat_certificate: Optional[str] = Field(
|
||||
None, max_length=2001, description="SAT certificate")
|
||||
sat_digital_seal: Optional[str] = Field(
|
||||
None, description="SAT digital seal")
|
||||
xml_doda_sent_path: Optional[str] = Field(
|
||||
None, max_length=1000, description="XML DODA sent path")
|
||||
xml_doda_response_path: Optional[str] = Field(
|
||||
None, max_length=1000, description="XML DODA response path"
|
||||
)
|
||||
sat_original_chain: Optional[str] = Field(
|
||||
None, description="SAT original chain")
|
||||
customs_clearance: Optional[int] = Field(
|
||||
None, description="Customs clearance")
|
||||
unique_badge_number: Optional[str] = Field(
|
||||
None, max_length=250, description="Unique badge number"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaResponseDTO(BaseModel):
|
||||
"""DTO para responder con datos de un DODA"""
|
||||
|
||||
id: int
|
||||
integration_number: Optional[str] = None
|
||||
doda_date: Optional[int] = None
|
||||
doda_time: Optional[int] = None
|
||||
dispatch_customs: Optional[str] = None
|
||||
customs_sections: Optional[str] = None
|
||||
patent: Optional[str] = None
|
||||
pedimentos: Optional[str] = None
|
||||
caat: Optional[str] = None
|
||||
transport_identification: Optional[str] = None
|
||||
fast_id: Optional[str] = None
|
||||
operation_type: Optional[str] = None
|
||||
selected: Optional[bool] = None
|
||||
user_selected: Optional[str] = None
|
||||
last_user: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
carrier: Optional[str] = None
|
||||
shipments: Optional[str] = None
|
||||
pedimento_type: Optional[str] = None
|
||||
original_chain: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
electronic_signature: Optional[str] = None
|
||||
transaction_number: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
linq_sat_qr: Optional[str] = None
|
||||
sat_certificate: Optional[str] = None
|
||||
sat_digital_seal: Optional[str] = None
|
||||
xml_doda_sent_path: Optional[str] = None
|
||||
xml_doda_response_path: Optional[str] = None
|
||||
sat_original_chain: Optional[str] = None
|
||||
customs_clearance: Optional[int] = None
|
||||
unique_badge_number: Optional[str] = None
|
||||
containers: Optional[List[DodaContainerResponseDTO]] = None
|
||||
american_pedimentos: Optional[List[DodaAmericanPedimentoResponseDTO]] = None
|
||||
pedimentos_detail: Optional[List[DodaPedimentoResponseDTO]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DodaDetailResponseDTO(BaseModel):
|
||||
"""DTO detallado para responder con todos los datos de un DODA"""
|
||||
|
||||
id: int
|
||||
integration_number: Optional[str] = None
|
||||
doda_date: Optional[int] = None
|
||||
doda_time: Optional[int] = None
|
||||
dispatch_customs: Optional[str] = None
|
||||
customs_sections: Optional[str] = None
|
||||
patent: Optional[str] = None
|
||||
pedimentos: Optional[str] = None
|
||||
caat: Optional[str] = None
|
||||
transport_identification: Optional[str] = None
|
||||
fast_id: Optional[str] = None
|
||||
operation_type: Optional[str] = None
|
||||
selected: Optional[bool] = None
|
||||
user_selected: Optional[str] = None
|
||||
last_user: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
carrier: Optional[str] = None
|
||||
shipments: Optional[str] = None
|
||||
pedimento_type: Optional[str] = None
|
||||
original_chain: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
electronic_signature: Optional[str] = None
|
||||
transaction_number: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
linq_sat_qr: Optional[str] = None
|
||||
sat_certificate: Optional[str] = None
|
||||
sat_digital_seal: Optional[str] = None
|
||||
xml_doda_sent_path: Optional[str] = None
|
||||
xml_doda_response_path: Optional[str] = None
|
||||
sat_original_chain: Optional[str] = None
|
||||
customs_clearance: Optional[int] = None
|
||||
unique_badge_number: Optional[str] = None
|
||||
containers: List[DodaContainerResponseDTO] = []
|
||||
american_pedimentos: List[DodaAmericanPedimentoResponseDTO] = []
|
||||
pedimentos_detail: List[DodaPedimentoResponseDTO] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
263
backend/api/v1/modules/a76/general_catalogs/doda/models.py
Normal file
263
backend/api/v1/modules/a76/general_catalogs/doda/models.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Modelos ORM para gestión de DODA (Documentos de Operación de Aduana)
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
Text,
|
||||
LargeBinary,
|
||||
Numeric,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Doda(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla Doda - Documentos de Operación de Aduana
|
||||
"""
|
||||
|
||||
__tablename__ = "doda" # gDODA
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="doda_pkey"),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Integration and timestamps
|
||||
integration_number: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
doda_date: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
doda_time: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
# Customs information
|
||||
dispatch_customs: Mapped[Optional[str]] = mapped_column(String(3))
|
||||
customs_sections: Mapped[Optional[str]] = mapped_column(String(3))
|
||||
patent: Mapped[Optional[str]] = mapped_column(String(4))
|
||||
pedimentos: Mapped[Optional[str]] = mapped_column(String(80))
|
||||
caat: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
|
||||
# Transport and identifiers
|
||||
transport_identification: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
fast_id: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
operation_type: Mapped[Optional[str]] = mapped_column(String(1))
|
||||
|
||||
# Selection info
|
||||
selected: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
user_selected: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
last_user: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
|
||||
# Responsible parties
|
||||
responsible: Mapped[Optional[str]] = mapped_column(String(14))
|
||||
carrier: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
shipments: Mapped[Optional[str]] = mapped_column(String(80))
|
||||
pedimento_type: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
|
||||
# Digital signatures and certificates
|
||||
original_chain: Mapped[Optional[str]] = mapped_column(String(5000))
|
||||
serial_number: Mapped[Optional[str]] = mapped_column(String(21))
|
||||
electronic_signature: Mapped[Optional[str]] = mapped_column(String(2000))
|
||||
|
||||
# Transaction info
|
||||
transaction_number: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
status: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
|
||||
# SAT information
|
||||
linq_sat_qr: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
sat_certificate: Mapped[Optional[str]] = mapped_column(String(2001))
|
||||
sat_digital_seal: Mapped[Optional[Text]] = mapped_column(Text)
|
||||
|
||||
# XML Paths
|
||||
xml_doda_sent_path: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
xml_doda_response_path: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
|
||||
# SAT original chain
|
||||
sat_original_chain: Mapped[Optional[Text]] = mapped_column(Text)
|
||||
|
||||
# Customs clearance
|
||||
customs_clearance: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
unique_badge_number: Mapped[Optional[str]] = mapped_column(String(250))
|
||||
|
||||
# Relationships
|
||||
containers: Mapped[list["DodaContainer"]] = relationship(
|
||||
"DodaContainer", back_populates="doda", cascade="all, delete-orphan"
|
||||
)
|
||||
american_pedimentos: Mapped[list["DodaAmericanPedimento"]] = relationship(
|
||||
"DodaAmericanPedimento", back_populates="doda", cascade="all, delete-orphan"
|
||||
)
|
||||
pedimentos_detail: Mapped[list["DodaPedimento"]] = relationship(
|
||||
"DodaPedimento", back_populates="doda", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Doda(id={self.id}, integration_number={self.integration_number}, status={self.status})>"
|
||||
|
||||
|
||||
class DodaContainer(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla DodaContainer - Contenedores en DODA
|
||||
"""
|
||||
|
||||
__tablename__ = "doda_containers" # gDoda_Contenedores
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="doda_containers_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["doda_id"], ["a76.doda.id"], name="fk_doda_containers_doda"
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign key and line number
|
||||
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
container_line: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# Container information
|
||||
container_value: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
seals: Mapped[Optional[str]] = mapped_column(String(254))
|
||||
|
||||
# Relationships
|
||||
doda: Mapped["Doda"] = relationship("Doda", back_populates="containers")
|
||||
seals_detail: Mapped[list["DodaContainerSeal"]] = relationship(
|
||||
"DodaContainerSeal", back_populates="container", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DodaContainer(id={self.id}, doda_id={self.doda_id}, container_line={self.container_line})>"
|
||||
|
||||
|
||||
class DodaContainerSeal(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla DodaContainerSeal - Candados de Contenedores
|
||||
"""
|
||||
|
||||
__tablename__ = "doda_container_seals" # gDoda_Contenedores_Candados
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="doda_container_seals_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["container_id"], ["a76.doda_containers.id"], name="fk_doda_container_seals_container"
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
|
||||
container_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
seal_line: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
seal_value: Mapped[Optional[str]] = mapped_column(String(21))
|
||||
|
||||
|
||||
container: Mapped["DodaContainer"] = relationship(
|
||||
"DodaContainer", back_populates="seals_detail"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DodaContainerSeal(id={self.id}, doda_id={self.doda_id}, seal_line={self.seal_line})>"
|
||||
|
||||
|
||||
class DodaAmericanPedimento(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla DodaAmericanPedimento - Pedimentos Americanos en DODA
|
||||
"""
|
||||
|
||||
__tablename__ = "doda_american_pedimentos" # gDoda_PedimentoAmericano
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="doda_american_pedimentos_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["doda_id"], ["a76.doda.id"], name="fk_doda_american_pedimentos_doda"
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign key and line number
|
||||
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
american_pedimento_line: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False)
|
||||
|
||||
# American pedimento information
|
||||
american_pedimento_type: Mapped[Optional[str]] = mapped_column(String(2))
|
||||
american_pedimento_value: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
# Relationships
|
||||
doda: Mapped["Doda"] = relationship(
|
||||
"Doda", back_populates="american_pedimentos")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DodaAmericanPedimento(id={self.id}, doda_id={self.doda_id}, american_pedimento_line={self.american_pedimento_line})>"
|
||||
|
||||
|
||||
class DodaPedimento(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla DodaPedimento - Pedimentos en DODA
|
||||
"""
|
||||
|
||||
__tablename__ = "doda_pedimentos" # gDoda_Pedimentos
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="doda_pedimentos_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["doda_id"], ["a76.doda.id"], name="fk_doda_pedimentos_doda"
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign key and line number
|
||||
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
pedimento_line: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# Authorization and document info
|
||||
authorization_patent: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
document: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
shipment: Mapped[Optional[str]] = mapped_column(String(11))
|
||||
|
||||
# Commercial information
|
||||
cove: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
umc: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
# Financial information
|
||||
effective_amount_usd: Mapped[Optional[Numeric]
|
||||
] = mapped_column(Numeric(15, 2))
|
||||
difference_amount_usd: Mapped[Optional[Numeric]
|
||||
] = mapped_column(Numeric(15, 2))
|
||||
|
||||
# Additional identifiers
|
||||
dta_niu: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
article_7: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
pedimento_id: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
invoice_line: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
part_ii_line: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
# Type and validation
|
||||
pedimento_type: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
zero_packaging_validation: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
|
||||
# Relationships
|
||||
doda: Mapped["Doda"] = relationship(
|
||||
"Doda", back_populates="pedimentos_detail")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DodaPedimento(id={self.id}, doda_id={self.doda_id}, pedimento_line={self.pedimento_line})>"
|
||||
199
backend/api/v1/modules/a76/general_catalogs/doda/routes.py
Normal file
199
backend/api/v1/modules/a76/general_catalogs/doda/routes.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Rutas para gestión de DODA (Documentos de Operación de Aduana)
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from .dto import (
|
||||
DodaCreateDTO,
|
||||
DodaResponseDTO,
|
||||
DodaUpdateDTO,
|
||||
DodaDetailResponseDTO,
|
||||
DodaContainerCreateDTO,
|
||||
DodaContainerResponseDTO,
|
||||
DodaContainerUpdateDTO,
|
||||
DodaAmericanPedimentoCreateDTO,
|
||||
DodaAmericanPedimentoResponseDTO,
|
||||
DodaAmericanPedimentoUpdateDTO,
|
||||
DodaPedimentoCreateDTO,
|
||||
DodaPedimentoResponseDTO,
|
||||
DodaPedimentoUpdateDTO,
|
||||
)
|
||||
from .models import Doda
|
||||
from .service import DodaService
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
# Create CRUD router
|
||||
crud_router = TenantCRUDRoutes(
|
||||
service=DodaService,
|
||||
create_schema=DodaCreateDTO,
|
||||
update_schema=DodaUpdateDTO,
|
||||
response_schema=DodaResponseDTO,
|
||||
prefix="/doda",
|
||||
tags=["doda"],
|
||||
resource_name="DODA",
|
||||
id_name="doda_id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
).router
|
||||
|
||||
router = crud_router
|
||||
|
||||
# ============ CUSTOM ENDPOINTS ============
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{doda_id}/detail",
|
||||
response_model=DodaDetailResponseDTO,
|
||||
summary="Get DODA by ID with all details",
|
||||
)
|
||||
async def get_doda_detail(
|
||||
doda_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
|
||||
if not doda:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="DODA not found",
|
||||
)
|
||||
return DodaDetailResponseDTO.model_validate(doda)
|
||||
|
||||
|
||||
# ============ CONTAINERS ENDPOINTS ============
|
||||
@router.get(
|
||||
"/{doda_id}/containers",
|
||||
response_model=List[DodaContainerResponseDTO],
|
||||
summary="Get containers for DODA",
|
||||
)
|
||||
async def get_doda_containers(
|
||||
doda_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get all containers for a specific DODA"""
|
||||
containers = DodaService.get_containers(db, doda_id)
|
||||
return [DodaContainerResponseDTO.model_validate(c) for c in containers]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{doda_id}/containers",
|
||||
response_model=DodaContainerResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Add container to DODA",
|
||||
)
|
||||
async def add_container(
|
||||
doda_id: int,
|
||||
container_data: DodaContainerCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Add a new container to a DODA"""
|
||||
container = DodaService.add_container(db, doda_id, container_data)
|
||||
if not container:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="DODA not found",
|
||||
)
|
||||
return DodaContainerResponseDTO.model_validate(container)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{doda_id}/containers/{container_line}",
|
||||
response_model=DodaContainerResponseDTO,
|
||||
summary="Update container",
|
||||
)
|
||||
async def update_container(
|
||||
doda_id: int,
|
||||
container_line: int,
|
||||
container_data: DodaContainerUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Update a container"""
|
||||
container = DodaService.update_container(
|
||||
db, doda_id, container_line, container_data
|
||||
)
|
||||
if not container:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Container not found",
|
||||
)
|
||||
return DodaContainerResponseDTO.model_validate(container)
|
||||
|
||||
|
||||
# ============ AMERICAN PEDIMENTOS ENDPOINTS ============
|
||||
@router.get(
|
||||
"/{doda_id}/american-pedimentos",
|
||||
response_model=List[DodaAmericanPedimentoResponseDTO],
|
||||
summary="Get American pedimentos for DODA",
|
||||
)
|
||||
async def get_american_pedimentos(
|
||||
doda_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get all American pedimentos for a specific DODA"""
|
||||
pedimentos = DodaService.get_american_pedimentos(db, doda_id)
|
||||
return [DodaAmericanPedimentoResponseDTO.model_validate(p) for p in pedimentos]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{doda_id}/american-pedimentos",
|
||||
response_model=DodaAmericanPedimentoResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Add American pedimento to DODA",
|
||||
)
|
||||
async def add_american_pedimento(
|
||||
doda_id: int,
|
||||
pedimento_data: DodaAmericanPedimentoCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Add a new American pedimento to a DODA"""
|
||||
pedimento = DodaService.add_american_pedimento(db, doda_id, pedimento_data)
|
||||
if not pedimento:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="DODA not found",
|
||||
)
|
||||
return DodaAmericanPedimentoResponseDTO.model_validate(pedimento)
|
||||
|
||||
|
||||
# ============ PEDIMENTOS ENDPOINTS ============
|
||||
@router.get(
|
||||
"/{doda_id}/pedimentos",
|
||||
response_model=List[DodaPedimentoResponseDTO],
|
||||
summary="Get pedimentos for DODA",
|
||||
)
|
||||
async def get_doda_pedimentos(
|
||||
doda_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Get all pedimentos for a specific DODA"""
|
||||
pedimentos = DodaService.get_pedimentos(db, doda_id)
|
||||
return [DodaPedimentoResponseDTO.model_validate(p) for p in pedimentos]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{doda_id}/pedimentos",
|
||||
response_model=DodaPedimentoResponseDTO,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Add pedimento to DODA",
|
||||
)
|
||||
async def add_pedimento(
|
||||
doda_id: int,
|
||||
pedimento_data: DodaPedimentoCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Add a new pedimento to a DODA"""
|
||||
pedimento = DodaService.add_pedimento(db, doda_id, pedimento_data)
|
||||
if not pedimento:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="DODA not found",
|
||||
)
|
||||
return DodaPedimentoResponseDTO.model_validate(pedimento)
|
||||
312
backend/api/v1/modules/a76/general_catalogs/doda/service.py
Normal file
312
backend/api/v1/modules/a76/general_catalogs/doda/service.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de DODA
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
DodaCreateDTO,
|
||||
DodaResponseDTO,
|
||||
DodaUpdateDTO,
|
||||
DodaContainerCreateDTO,
|
||||
DodaContainerUpdateDTO,
|
||||
DodaAmericanPedimentoCreateDTO,
|
||||
DodaAmericanPedimentoUpdateDTO,
|
||||
DodaPedimentoCreateDTO,
|
||||
DodaPedimentoUpdateDTO,
|
||||
)
|
||||
from .models import (
|
||||
Doda,
|
||||
DodaContainer,
|
||||
DodaContainerSeal,
|
||||
DodaAmericanPedimento,
|
||||
DodaPedimento,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DodaService:
|
||||
"""Servicio para gestión de DODA"""
|
||||
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[List[Doda], int]:
|
||||
"""Get all DODAs with pagination"""
|
||||
query = db.query(Doda).filter(
|
||||
Doda.tenant_id == tenant_id,
|
||||
Doda.company_id == company_id
|
||||
)
|
||||
|
||||
if filters:
|
||||
if filters.get("integration_number"):
|
||||
query = query.filter(
|
||||
Doda.integration_number.ilike(
|
||||
f"%{filters['integration_number']}%")
|
||||
)
|
||||
if filters.get("status"):
|
||||
query = query.filter(
|
||||
Doda.status.ilike(f"%{filters['status']}%"))
|
||||
if filters.get("patent"):
|
||||
query = query.filter(
|
||||
Doda.patent.ilike(f"%{filters['patent']}%"))
|
||||
|
||||
total = query.count()
|
||||
dodas = query.offset(skip).limit(limit).all()
|
||||
return dodas, total
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(
|
||||
db: Session, id: int, tenant_id: int, company_id: int
|
||||
) -> Optional[Doda]:
|
||||
"""Get DODA by ID"""
|
||||
return db.query(Doda).filter(
|
||||
Doda.id == id,
|
||||
Doda.tenant_id == tenant_id,
|
||||
Doda.company_id == company_id
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session, doda_data: DodaCreateDTO, tenant_id: int, company_id: int
|
||||
) -> Doda:
|
||||
"""Create a new DODA"""
|
||||
try:
|
||||
db_doda = Doda(
|
||||
**doda_data.model_dump(exclude_unset=True),
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id
|
||||
)
|
||||
db.add(db_doda)
|
||||
db.commit()
|
||||
db.refresh(db_doda)
|
||||
return db_doda
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError creating DODA: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail="Error creating DODA")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating DODA: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error creating DODA")
|
||||
|
||||
@staticmethod
|
||||
def update(
|
||||
db: Session, id: int, tenant_id: int, doda_data: DodaUpdateDTO, company_id: int
|
||||
) -> Optional[Doda]:
|
||||
"""Update a DODA"""
|
||||
try:
|
||||
db_doda = DodaService.get_by_id(db, id, tenant_id, company_id)
|
||||
if not db_doda:
|
||||
return None
|
||||
|
||||
for key, value in doda_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_doda, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_doda)
|
||||
return db_doda
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"IntegrityError updating DODA: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail="Error updating DODA")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error updating DODA: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating DODA")
|
||||
|
||||
@staticmethod
|
||||
def delete(
|
||||
db: Session, id: int, tenant_id: int, company_id: int
|
||||
) -> bool:
|
||||
"""Delete a DODA"""
|
||||
db_doda = DodaService.get_by_id(db, id, tenant_id, company_id)
|
||||
if not db_doda:
|
||||
return False
|
||||
|
||||
try:
|
||||
db.delete(db_doda)
|
||||
db.commit()
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error de integridad al eliminar DODA {id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No se puede eliminar este DODA porque tiene registros relacionados. Primero debe eliminar o reasignar esos registros."
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting DODA: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting DODA")
|
||||
|
||||
# ============ CONTAINERS ============
|
||||
@staticmethod
|
||||
def add_container(
|
||||
db: Session, doda_id: int, container_data: DodaContainerCreateDTO
|
||||
) -> Optional[DodaContainer]:
|
||||
"""Add a container to a DODA"""
|
||||
try:
|
||||
doda = db.query(Doda).filter(Doda.id == doda_id).first()
|
||||
if not doda:
|
||||
return None
|
||||
|
||||
# Get max line number
|
||||
max_line = (
|
||||
db.query(DodaContainer)
|
||||
.filter(DodaContainer.doda_id == doda_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
db_container = DodaContainer(
|
||||
doda_id=doda_id,
|
||||
container_line=max_line + 1,
|
||||
**{
|
||||
k: v
|
||||
for k, v in container_data.model_dump(exclude_unset=True).items()
|
||||
if k != "seals_detail"
|
||||
},
|
||||
)
|
||||
db.add(db_container)
|
||||
db.commit()
|
||||
db.refresh(db_container)
|
||||
return db_container
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding container: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error adding container")
|
||||
|
||||
@staticmethod
|
||||
def update_container(
|
||||
db: Session,
|
||||
doda_id: int,
|
||||
container_line: int,
|
||||
container_data: DodaContainerUpdateDTO,
|
||||
) -> Optional[DodaContainer]:
|
||||
"""Update a container"""
|
||||
try:
|
||||
db_container = (
|
||||
db.query(DodaContainer)
|
||||
.filter(
|
||||
DodaContainer.doda_id == doda_id,
|
||||
DodaContainer.container_line == container_line,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not db_container:
|
||||
return None
|
||||
|
||||
for key, value in container_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_container, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_container)
|
||||
return db_container
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error updating container: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error updating container")
|
||||
|
||||
@staticmethod
|
||||
def get_containers(db: Session, doda_id: int) -> List[DodaContainer]:
|
||||
"""Get all containers for a DODA"""
|
||||
return (
|
||||
db.query(DodaContainer)
|
||||
.filter(DodaContainer.doda_id == doda_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# ============ AMERICAN PEDIMENTOS ============
|
||||
@staticmethod
|
||||
def add_american_pedimento(
|
||||
db: Session, doda_id: int, pedimento_data: DodaAmericanPedimentoCreateDTO
|
||||
) -> Optional[DodaAmericanPedimento]:
|
||||
"""Add an American pedimento to a DODA"""
|
||||
try:
|
||||
doda = db.query(Doda).filter(Doda.id == doda_id).first()
|
||||
if not doda:
|
||||
return None
|
||||
|
||||
max_line = (
|
||||
db.query(DodaAmericanPedimento)
|
||||
.filter(DodaAmericanPedimento.doda_id == doda_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
db_pedimento = DodaAmericanPedimento(
|
||||
doda_id=doda_id,
|
||||
american_pedimento_line=max_line + 1,
|
||||
**pedimento_data.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_pedimento)
|
||||
db.commit()
|
||||
db.refresh(db_pedimento)
|
||||
return db_pedimento
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding American pedimento: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error adding American pedimento"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_american_pedimentos(db: Session, doda_id: int) -> List[DodaAmericanPedimento]:
|
||||
"""Get all American pedimentos for a DODA"""
|
||||
return (
|
||||
db.query(DodaAmericanPedimento)
|
||||
.filter(DodaAmericanPedimento.doda_id == doda_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# ============ PEDIMENTOS ============
|
||||
@staticmethod
|
||||
def add_pedimento(
|
||||
db: Session, doda_id: int, pedimento_data: DodaPedimentoCreateDTO
|
||||
) -> Optional[DodaPedimento]:
|
||||
"""Add a pedimento to a DODA"""
|
||||
try:
|
||||
doda = db.query(Doda).filter(Doda.id == doda_id).first()
|
||||
if not doda:
|
||||
return None
|
||||
|
||||
max_line = (
|
||||
db.query(DodaPedimento)
|
||||
.filter(DodaPedimento.doda_id == doda_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
db_pedimento = DodaPedimento(
|
||||
doda_id=doda_id,
|
||||
pedimento_line=max_line + 1,
|
||||
**pedimento_data.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.add(db_pedimento)
|
||||
db.commit()
|
||||
db.refresh(db_pedimento)
|
||||
return db_pedimento
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error adding pedimento: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Error adding pedimento")
|
||||
|
||||
@staticmethod
|
||||
def get_pedimentos(db: Session, doda_id: int) -> List[DodaPedimento]:
|
||||
"""Get all pedimentos for a DODA"""
|
||||
return (
|
||||
db.query(DodaPedimento).filter(
|
||||
DodaPedimento.doda_id == doda_id).all()
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de avisos electrónicos
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Modelos ORM para gestión de avisos electrónicos
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class ElectronicNotice(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla ElectronicNotice - Avisos Electrónicos
|
||||
"""
|
||||
|
||||
__tablename__ = "electronic_notices" # GAvisosElectronicos
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="electronic_notices_pkey"),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Notice identification
|
||||
notice_number: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
year: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
patent: Mapped[Optional[str]] = mapped_column(String(4))
|
||||
pedimento: Mapped[Optional[str]] = mapped_column(String(15))
|
||||
|
||||
# Files
|
||||
file_sent: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
file_response: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
|
||||
# Status and validation
|
||||
status: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
invoice: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
validation_acknowledgment: Mapped[Optional[str]] = mapped_column(
|
||||
String(20))
|
||||
|
||||
# Certificate information
|
||||
fea: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
certificate_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ElectronicNotice(id={self.id}, notice_number={self.notice_number}, status={self.status})>"
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de catálogos de errores
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Modelos ORM para gestión de catálogos de errores
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class ErrorClassification(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla ErrorClassification - Clasificación de Errores
|
||||
"""
|
||||
|
||||
__tablename__ = "error_classifications" # GCatErroresClas
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="error_classifications_pkey"),
|
||||
UniqueConstraint("code", name="error_classifications_code_unique"),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
# Classification code (unique)
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
|
||||
# Classification information
|
||||
level: Mapped[Optional[str]] = mapped_column(String(3))
|
||||
|
||||
# Relationships
|
||||
errors: Mapped[list["ErrorCatalog"]] = relationship(
|
||||
"ErrorCatalog", back_populates="classification", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ErrorClassification(id={self.id}, code={self.code}, level={self.level})>"
|
||||
|
||||
|
||||
class ErrorCatalog(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla ErrorCatalog - Catálogo de Errores
|
||||
"""
|
||||
|
||||
__tablename__ = "error_catalogs" # GCatErrores
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="error_catalogs_pkey"),
|
||||
UniqueConstraint("code", name="error_catalogs_code_unique"),
|
||||
ForeignKeyConstraint(
|
||||
["classification_id"],
|
||||
["a76.error_classifications.id"],
|
||||
name="fk_error_catalogs_classification",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
# Error code (unique)
|
||||
code: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
|
||||
|
||||
# Error information
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
# Foreign key to classification
|
||||
classification_id: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
# Relationships
|
||||
classification: Mapped[Optional["ErrorClassification"]] = relationship(
|
||||
"ErrorClassification", back_populates="errors"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ErrorCatalog(id={self.id}, code={self.code}, description={self.description}, classification_id={self.classification_id})>"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
25
backend/api/v1/modules/a76/general_catalogs/inpc/dto.py
Normal file
25
backend/api/v1/modules/a76/general_catalogs/inpc/dto.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
class INPCBase(BaseModel):
|
||||
year: str = Field(..., max_length=4, description="Year (YYYY)")
|
||||
month: str = Field(..., max_length=2, description="Month (MM)")
|
||||
value: Optional[Decimal] = Field(None, description="INPC Value")
|
||||
|
||||
|
||||
class INPCCreate(INPCBase):
|
||||
pass
|
||||
|
||||
class INPCUpdate(BaseModel):
|
||||
year: Optional[str] = Field(None, max_length=4)
|
||||
month: Optional[str] = Field(None, max_length=2)
|
||||
value: Optional[Decimal] = None
|
||||
|
||||
|
||||
class INPCResponse(INPCBase):
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
25
backend/api/v1/modules/a76/general_catalogs/inpc/models.py
Normal file
25
backend/api/v1/modules/a76/general_catalogs/inpc/models.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import Integer, String, UniqueConstraint, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class INPC(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "inpc"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("year", "month", "tenant_id",
|
||||
"company_id", name="uq_inpc_year_month"),
|
||||
{"schema": "a76"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
year: Mapped[str] = mapped_column(String(4), nullable=False) # ANIO
|
||||
|
||||
month: Mapped[str] = mapped_column(String(2), nullable=False) # MES
|
||||
|
||||
value: Mapped[Optional[Decimal]] = mapped_column(
|
||||
Numeric(19, 8), nullable=True) # VALOR
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user