feat: plantilla base workspace SaaS
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 2s
Build Producción & Push a Harbor / build (push) Has been skipped

Convierte el repositorio de Anexo76 en una plantilla limpia y reutilizable
para nuevos proyectos del ecosistema Workspace de Aduanasoft.

Cambios principales:
- Elimina módulos específicos de Anexo76: a76, a24, sitar, public
- Agrega módulo example/ con patrón CRUD de referencia (models/dto/service/routes)
- Limpia migraciones Alembic: solo quedan las 6 de core (users, tenants, permissions)
- Reemplaza todas las rutas del dashboard con stubs genéricos
- Elimina lógica de negocio aduanera: shortcuts, CSV imports, permisos, catálogos
- Simplifica variables de entorno: una sola WORKSPACE_URL deriva Hub y Keycloak
- Agrega scripts/auth-mode.sh para alternar entre auth local y workspace
- Configura docker-compose con nombres genéricos (app-*)
- Corrige flujo SSO: elimina system-gate SCAF/SCAII que bloqueaba el login
- Modo DEV_LOCAL_AUTH para desarrollo sin Keycloak ni Hub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 10:55:17 -05:00
parent 9fae110c11
commit 880999b03a
1979 changed files with 1085 additions and 359149 deletions

View File

@@ -14,9 +14,9 @@ KEYCLOAK_ADMIN_PASSWORD=admin
# ----- Keycloak Configuración ----- # ----- Keycloak Configuración -----
KEYCLOAK_REALM=master KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=anexo76-backend KEYCLOAK_CLIENT_ID=app-backend
KEYCLOAK_CLIENT_SECRET=dev-secret KEYCLOAK_CLIENT_SECRET=dev-secret
KEYCLOAK_FRONTEND_CLIENT_ID=anexo76-frontend KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend
# ----- Backend ----- # ----- Backend -----
DEBUG=True DEBUG=True
@@ -59,7 +59,7 @@ VITE_HUB_URL=http://localhost:3001
APP_PUBLIC_URL=http://localhost:5173 APP_PUBLIC_URL=http://localhost:5173
VITE_KEYCLOAK_REALM=master VITE_KEYCLOAK_REALM=master
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend VITE_KEYCLOAK_CLIENT_ID=app-frontend
#------ Celery / Valkey ---------- #------ Celery / Valkey ----------
VALKEY_URL=redis://valkey:6379/0 VALKEY_URL=redis://valkey:6379/0

View File

@@ -1,9 +1,17 @@
# Application # Application
APP_NAME=Anexo76 APP_NAME=Mi Aplicación
APP_VERSION=1.0.0 APP_VERSION=1.0.0
DEBUG=True DEBUG=True
ENVIRONMENT=development ENVIRONMENT=development
# Auth local para desarrollo (sin Keycloak/Hub)
# Cambia a True para entrar sin workspace. NUNCA en producción.
DEV_LOCAL_AUTH=False
DEV_LOCAL_AUTH_EMAIL=dev@local.test
DEV_LOCAL_AUTH_NAME=Dev User
DEV_LOCAL_AUTH_TENANT_ID=1
DEV_LOCAL_AUTH_COMPANY_ID=1
# Database - Core # Database - Core
CORE_DB_HOST=localhost CORE_DB_HOST=localhost
CORE_DB_PORT=5432 CORE_DB_PORT=5432

View File

@@ -301,7 +301,7 @@ modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules")
import_models_from_dir(modules_dir) import_models_from_dir(modules_dir)
# Tablas declaradas fuera de models.py / carpeta models/ (autogenerate) # Tablas declaradas fuera de models.py / carpeta models/ (autogenerate)
import api.v1.modules.a76.general_catalogs.doda.alta_log_models # noqa: F401 # Agrega aquí imports de models que no estén en archivos models.py estándar.
def run_migrations_offline() -> None: def run_migrations_offline() -> None:

View File

@@ -4,477 +4,21 @@ Revision ID: 8c9bad3da37f
Revises: 9db46c604463 Revises: 9db46c604463
Create Date: 2026-05-01 22:01:50.174319 Create Date: 2026-05-01 22:01:50.174319
Nota: la migración original sembraba catálogos de referencia de Anexo 76.
En la plantilla este paso es un no-op — agrega tus seeds aquí si los necesitas.
""" """
# pylint: disable=no-member
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
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.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.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.states.seed import seed as states_seed
from api.v1.modules.public.reference_data.transport_modes.seed import (
seed as transport_modes_seed,
)
from api.v1.modules.public.reference_data.pedimento_transport_catalog.seed import (
seed as pedimento_transport_catalog_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.a76.general_catalogs.units_of_measure.seed import (
seed as units_of_measure_seed,
)
from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ace import (
seed as ace_seed,
)
from api.v1.modules.a76.general_catalogs.units_of_measure.seed_oma import (
seed as oma_seed,
)
from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ame import (
seed as ame_seed,
)
from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import (
seed as adua_seed,
)
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.seed import (
seed as tariff_fractions_seed,
)
from api.v1.modules.public.reference_data.trailer_types.seed import (
seed as trailer_types_seed,
)
from api.v1.modules.core.permissions.seed_v2 import registry
from api.v1.modules.public.reference_data.license_exceptions.seed import seed_license_exceptions
from api.v1.modules.public.reference_data.agency_tariff_codes.seed import seed_agency_tariff_codes
from api.v1.modules.public.reference_data.identifiers.seed import seed_identifiers
from api.v1.modules.public.reference_data.carta_porte_codes.seed import seed_carta_porte
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision: str = "8c9bad3da37f" revision: str = "8c9bad3da37f"
down_revision: Union[str, Sequence[str], None] = "9db46c604463" down_revision: Union[str, None] = "9db46c604463"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Catálogos de referencia y permisos base (datos).""" pass
def format_value(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return f"'{str(val).replace(chr(39), chr(39)*2)}'"
# --- SEEDS PUBLIC (Tablas base) ---
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"""
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"""
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"""
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"""
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"""
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"""
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"""
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"""
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)}', '{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"""
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"""
INSERT INTO public.payment_methods (key, description) VALUES
{values_pm}
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"""
INSERT INTO public.transport_modes (key, name) VALUES
{values_tm}
ON CONFLICT (key) DO NOTHING;
"""
)
values_ptc = ", ".join(
[
f"('{code}', '{en.replace(chr(39), chr(39)*2)}', '{es.replace(chr(39), chr(39)*2)}', '{pdc}')"
for code, en, es, pdc in pedimento_transport_catalog_seed
]
)
op.execute(
f"""
INSERT INTO public.pedimento_transport_catalog (code, transport_en, transport_es, payment_date_code) VALUES
{values_ptc}
ON CONFLICT (code) DO NOTHING;
"""
)
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_trailer = ", ".join(
[
f"('{code}', '{desc.replace(chr(39), chr(39)*2)}')"
for code, desc in trailer_types_seed
]
)
op.execute(
f"""
INSERT INTO public.trailer_type (trailer_type_key, description) VALUES
{values_trailer}
ON CONFLICT (trailer_type_key) DO NOTHING;
"""
)
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;
"""
)
# --- SEEDS A76 (Unidades de Medida) ---
val_ace = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in ace_seed]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_ace (code, description) VALUES {val_ace} ON CONFLICT ON CONSTRAINT uq_uom_ace_code DO NOTHING;"
)
val_oma = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in oma_seed]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;"
)
val_ame = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in ame_seed]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_american (code, description) VALUES {val_ame} ON CONFLICT ON CONSTRAINT uq_uom_american_code DO NOTHING;"
)
val_adua = ", ".join(
[f"({format_value(code)}, {format_value(desc)}, {format_value(a76_code)})" for code, desc, a76_code in adua_seed]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_customs (code, description, a76_unit_code) VALUES {val_adua} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;"
)
additional_customs = set()
additional_american = set()
additional_ace = set()
additional_oma = set()
existing_customs = {code for code, desc, a76_code in adua_seed}
existing_american = {c for c, d in ame_seed}
existing_ace = {c for c, d in ace_seed}
existing_oma = {c for c, d in oma_seed}
for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed:
if customs and customs.strip() and customs not in existing_customs:
additional_customs.add((customs, f"Auto-generated from {code}"))
if american and american.strip() and american not in existing_american:
additional_american.add((american, f"Auto-generated from {code}"))
if ace and ace.strip() and ace not in existing_ace:
additional_ace.add((ace, f"Auto-generated from {code}"))
if oma and oma.strip() and oma not in existing_oma:
additional_oma.add((oma, f"Auto-generated from {code}"))
if additional_customs:
val_add_customs = ", ".join(
[f"({format_value(c)}, {format_value(d)}, NULL)" for c, d in additional_customs]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_customs (code, description, a76_unit_code) VALUES {val_add_customs} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;"
)
if additional_american:
val_add_american = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in additional_american]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_american (code, description) VALUES {val_add_american} ON CONFLICT ON CONSTRAINT uq_uom_american_code DO NOTHING;"
)
if additional_ace:
val_add_ace = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in additional_ace]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_ace (code, description) VALUES {val_add_ace} ON CONFLICT ON CONSTRAINT uq_uom_ace_code DO NOTHING;"
)
if additional_oma:
val_add_oma = ", ".join(
[f"({format_value(c)}, {format_value(d)})" for c, d in additional_oma]
)
op.execute(
f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;"
)
# --- SEEDS CORE (Permissions) ---
all_permissions = registry.get_all()
values_permissions = ", ".join(
[
f"({format_value(p.code)}, {format_value(p.description)}, {format_value(p.module)}, {format_value(p.action)})"
for p in all_permissions
]
)
if values_permissions:
op.execute(
f"""
INSERT INTO core.permissions (code, description, module, action)
VALUES {values_permissions}
ON CONFLICT (code) DO NOTHING;
"""
)
values_states = ", ".join(
[
f"('{m3_key}', '{description.replace(chr(39), chr(39)*2)}', {format_value(mex_key)})"
for m3_key, description, mex_key in states_seed
]
)
op.execute(
f"""
INSERT INTO public.states (m3_key, description, mex_key)
VALUES {values_states}
ON CONFLICT (m3_key, description) DO NOTHING;
"""
)
values_tariff_fractions = ", ".join(
[
f"({format_value(code)}, {format_value(fraction)}, {format_value(description)}, "
f"{format_value(nico)}, {format_value(umt)}, {format_value(adv_impo)}, {format_value(adv_expo)})"
for code, fraction, description, nico, umt, adv_impo, adv_expo in tariff_fractions_seed
]
)
op.execute(
f"""
INSERT INTO a76.tariff_fractions (code, fraction, description, nico, umt, adv_impo, adv_expo)
VALUES {values_tariff_fractions}
ON CONFLICT (code) DO NOTHING;
"""
)
bind = op.get_bind()
session = Session(bind=bind)
seed_license_exceptions(session)
seed_agency_tariff_codes(session)
seed_identifiers(session)
seed_carta_porte(session)
def downgrade() -> None: def downgrade() -> None:
"""Los datos de catálogo no se revierten automáticamente."""
pass pass

View File

@@ -1,36 +0,0 @@
"""add system column to classes and parts
Revision ID: a7b8c9d0e1f2
Revises: d2e3f4a5b6c7
Create Date: 2026-05-26 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "a7b8c9d0e1f2"
down_revision: Union[str, None] = "d2e3f4a5b6c7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Columna system en a76.classes — indica si la clase pertenece a SCAF (fixed_asset) o SCAII (inventory)
op.add_column(
"classes",
sa.Column("system", sa.String(length=12), nullable=False, server_default="fixed_asset"),
schema="a76",
)
# Columna system en a76.parts — misma discriminación por sistema
op.add_column(
"parts",
sa.Column("system", sa.String(length=12), nullable=False, server_default="fixed_asset"),
schema="a76",
)
def downgrade() -> None:
op.drop_column("classes", "system", schema="a76")
op.drop_column("parts", "system", schema="a76")

View File

@@ -1,79 +0,0 @@
"""split rfc and tax_id in clients_and_providers
Separa el identificador fiscal en dos columnas:
- rfc: RFC mexicano (nacional)
- tax_id: identificador fiscal extranjero
Antes, ambos compartían la columna `rfc` discriminados por `type_nat_foreign`.
La migración mueve el valor de los extranjeros (type_nat_foreign='E') de `rfc` a
`tax_id` y deja `rfc` en NULL para esos registros. Los nacionales no se tocan.
Revision ID: b3c4d5e6f7a8
Revises: a7b8c9d0e1f2
Create Date: 2026-06-02 10:00:00.000000
"""
import logging
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b3c4d5e6f7a8"
down_revision: Union[str, None] = "a7b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
logger = logging.getLogger("alembic.runtime.migration")
# Extranjero ⟺ type_nat_foreign empieza con 'E' (misma convención que DTOs/modelo/frontend).
_FOREIGN_PREDICATE = "UPPER(COALESCE(type_nat_foreign, '')) LIKE 'E%'"
def upgrade() -> None:
# 1) Nueva columna tax_id (nullable: un registro puede no tener identificador extranjero).
op.add_column(
"clients_and_providers",
sa.Column("tax_id", sa.String(length=30), nullable=True),
schema="a76",
)
conn = op.get_bind()
# 2) Conteo previo (visibilidad antes de mover datos; UPDATE precedido de SELECT COUNT).
to_move = conn.execute(
sa.text(
"SELECT COUNT(*) FROM a76.clients_and_providers "
f"WHERE {_FOREIGN_PREDICATE} AND rfc IS NOT NULL"
)
).scalar()
logger.info(
"split_rfc_tax_id: %s registros extranjeros con rfc serán movidos a tax_id",
to_move,
)
# 3) Mover rfc -> tax_id para extranjeros (idempotente por el guard tax_id IS NULL).
conn.execute(
sa.text(
"UPDATE a76.clients_and_providers SET tax_id = rfc "
f"WHERE {_FOREIGN_PREDICATE} AND tax_id IS NULL AND rfc IS NOT NULL"
)
)
# 4) Limpiar rfc en extranjeros (la columna rfc queda solo para RFC nacional).
conn.execute(
sa.text(
f"UPDATE a76.clients_and_providers SET rfc = NULL WHERE {_FOREIGN_PREDICATE}"
)
)
def downgrade() -> None:
# Reconsolidar: regresar el identificador extranjero a rfc antes de eliminar la columna.
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE a76.clients_and_providers SET rfc = tax_id "
f"WHERE {_FOREIGN_PREDICATE} AND rfc IS NULL AND tax_id IS NOT NULL"
)
)
op.drop_column("clients_and_providers", "tax_id", schema="a76")

View File

@@ -1,7 +1,7 @@
"""add workspace profile fields to user_tenants """add workspace profile fields to user_tenants
Revision ID: c3d4e5f6a7b Revision ID: c3d4e5f6a7b
Revises: ca7d3c4e8b2a Revises: b2c3d4e5f6a7
Create Date: 2026-05-08 00:00:00.000000 Create Date: 2026-05-08 00:00:00.000000
""" """
@@ -12,7 +12,7 @@ import sqlalchemy as sa
from alembic import op from alembic import op
revision: str = "c3d4e5f6a7b" revision: str = "c3d4e5f6a7b"
down_revision: Union[str, None] = "ca7d3c4e8b2a" down_revision: Union[str, None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None

View File

@@ -1,43 +0,0 @@
"""add stock_unit_of_measure to classes
Revision ID: c8d9e0f1a2b3
Revises: b3c4d5e6f7a8
Create Date: 2026-06-02 12:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c8d9e0f1a2b3"
down_revision: Union[str, None] = "b3c4d5e6f7a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"classes",
sa.Column("stock_unit_of_measure", sa.String(length=5), nullable=True),
schema="a76",
)
op.create_foreign_key(
"fk_classes_stock_uom",
"classes",
"units_of_measure",
["stock_unit_of_measure", "tenant_id", "company_id"],
["code", "tenant_id", "company_id"],
source_schema="a76",
referent_schema="a76",
)
def downgrade() -> None:
op.drop_constraint(
"fk_classes_stock_uom",
"classes",
schema="a76",
type_="foreignkey",
)
op.drop_column("classes", "stock_unit_of_measure", schema="a76")

View File

@@ -1,67 +0,0 @@
"""add transportation id sequences
Revision ID: ca7d3c4e8b2a
Revises: b2c3d4e5f6a7
Create Date: 2026-05-08 08:34:00.000000
Crea las secuencias `a76.<tabla>_<id>_seq` que los servicios de catálogos de
transporte (transporter, vehicle, driver, trailer) referencian vía
`SELECT nextval(...)`. La migración es idempotente y reposiciona cada
secuencia a `MAX(<id>)` para no chocar con filas pre-existentes.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "ca7d3c4e8b2a"
down_revision: Union[str, None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (tabla, columna_id_sustituto) en el esquema a76. El nombre de secuencia
# resultante es `a76.<tabla>_<columna>_seq`, que es el patrón que los
# servicios ya consumen (ver transporters/vehicles/drivers/trailers services).
_SEQUENCE_TARGETS = (
("transporter", "transporter_id"),
("vehicle", "vehicle_id"),
("driver", "driver_id"),
("trailer", "trailer_id"),
)
def _seq_name(table: str, column: str) -> str:
return f"{table}_{column}_seq"
def upgrade() -> None:
for table, column in _SEQUENCE_TARGETS:
seq = _seq_name(table, column)
op.execute(
f'CREATE SEQUENCE IF NOT EXISTS a76."{seq}" '
f"AS BIGINT START WITH 1 INCREMENT BY 1"
)
# Reposicionar la secuencia al MAX(id) actual para evitar colisiones
# con datos ya cargados antes de existir la secuencia.
op.execute(
f"SELECT setval('a76.\"{seq}\"', "
f"GREATEST((SELECT COALESCE(MAX({column}), 0) FROM a76.{table}), 1), "
f"true)"
)
op.execute(
f'ALTER SEQUENCE a76."{seq}" OWNED BY a76.{table}.{column}'
)
op.execute(
f"ALTER TABLE a76.{table} "
f"ALTER COLUMN {column} SET DEFAULT nextval('a76.\"{seq}\"')"
)
def downgrade() -> None:
for table, column in _SEQUENCE_TARGETS:
seq = _seq_name(table, column)
op.execute(
f"ALTER TABLE a76.{table} ALTER COLUMN {column} DROP DEFAULT"
)
op.execute(f'DROP SEQUENCE IF EXISTS a76."{seq}"')

View File

@@ -1,57 +0,0 @@
"""seed document_types_digitization
Revision ID: d2e3f4a5b6c7
Revises: f1a2b3c4d5e6
Create Date: 2026-05-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy import text
from sqlalchemy.orm import Session
from api.v1.modules.a76.doc_types_dig.seed import seed as doc_types_dig_seed
revision: str = "d2e3f4a5b6c7"
down_revision: Union[str, None] = "f1a2b3c4d5e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Quitar columna active (no se usa en ningún flujo)
op.drop_column('document_types_digitization', 'active', schema='a76')
def format_value(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return f"'{str(val).replace(chr(39), chr(39)*2)}'"
bind = op.get_bind()
session = Session(bind=bind)
companies = session.execute(
text("SELECT tenant_id, id FROM a76.company WHERE deleted_at IS NULL")
).fetchall()
for tenant_id, company_id in companies:
values = ", ".join([
f"({format_value(code)}, {format_value(description)}, {tenant_id}, {company_id})"
for code, description in doc_types_dig_seed
])
if values:
session.execute(text(f"""
INSERT INTO a76.document_types_digitization (code, description, tenant_id, company_id)
VALUES {values}
ON CONFLICT (tenant_id, company_id, code) DO NOTHING;
"""))
session.commit()
def downgrade() -> None:
op.add_column(
'document_types_digitization',
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
schema='a76',
)

View File

@@ -1,63 +0,0 @@
"""seed sectors for existing companies
Siembra el catálogo de sectores PROSEC (a76.sectors) para todas las empresas
existentes. Los sectores son por empresa y solo se sembraban dentro de
_seed_company_data() al crear una empresa; las empresas que no pasaron por ese
flujo (BD recreada o creadas por otra vía) quedaban sin sectores y el selector
del frontend aparecía vacío. Idempotente: ON CONFLICT DO NOTHING.
Revision ID: e1f2a3b4c5d6
Revises: c8d9e0f1a2b3
Create Date: 2026-06-02 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.sectors.seed import seed as sectors_seed
revision: str = "e1f2a3b4c5d6"
down_revision: Union[str, None] = "c8d9e0f1a2b3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
def format_value(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return f"'{str(val).replace(chr(39), chr(39) * 2)}'"
bind = op.get_bind()
session = Session(bind=bind)
companies = session.execute(
text("SELECT tenant_id, id FROM a76.company WHERE deleted_at IS NULL")
).fetchall()
for tenant_id, company_id in companies:
values = ", ".join(
[
f"({format_value(key)}, {format_value(description)}, {str(authorized).upper()}, {tenant_id}, {company_id})"
for key, description, authorized in sectors_seed
]
)
if values:
session.execute(
text(
f"""
INSERT INTO a76.sectors (key, description, authorized, tenant_id, company_id)
VALUES {values}
ON CONFLICT (key, tenant_id, company_id) DO NOTHING;
"""
)
)
session.commit()
def downgrade() -> None:
# Backfill de datos: no se eliminan sectores en el downgrade porque podrían
# haberse editado/agregado manualmente tras la siembra. No-op intencional.
pass

View File

@@ -1,30 +0,0 @@
"""add incoterm DAF legacy catalog row
Revision ID: e4f5a6b7c8d9
Revises: c3d4e5f6a7b
Create Date: 2026-05-08
Incoterm histórico DAF (Delivered At Frontier) para CSV y referencias legacy.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "e4f5a6b7c8d9"
down_revision: Union[str, None] = "c3d4e5f6a7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
INSERT INTO public.incoterms (code, description_es, description_en) VALUES
('DAF', 'ENTREGADO EN FRONTERA', 'DELIVERED AT FRONTIER')
ON CONFLICT (code) DO NOTHING;
"""
)
def downgrade() -> None:
op.execute("DELETE FROM public.incoterms WHERE code = 'DAF';")

View File

@@ -1,75 +0,0 @@
"""isolate equivalency items
Revision ID: f1a2b3c4d5e6
Revises: e4f5a6b7c8d9
Create Date: 2026-05-12 17:18:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f1a2b3c4d5e6"
down_revision: Union[str, None] = "e4f5a6b7c8d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add equivalency_id column
op.add_column(
'equivalency_items',
sa.Column(
'equivalency_id',
sa.Integer(),
sa.ForeignKey('a76.equivalencies.id', ondelete='CASCADE'),
nullable=True
),
schema='a76'
)
# 2. Drop old unique constraint
# Note: In some DBs we might need to specify the type, but for Postgres 'unique' or 'foreignkey' works.
op.drop_constraint('uq_equivalency_item_fields', 'equivalency_items', schema='a76', type_='unique')
# 3. Create new unique constraint including equivalency_id
op.create_unique_constraint(
'uq_equivalency_item_fields',
'equivalency_items',
['equivalency_id', 'original_field', 'external_field', 'tenant_id', 'company_id'],
schema='a76'
)
# 4. Increase identifier length in equivalencies table
op.alter_column(
'equivalencies',
'identifier',
type_=sa.String(50),
existing_type=sa.String(10),
schema='a76'
)
def downgrade() -> None:
# 1. Drop new unique constraint
op.drop_constraint('uq_equivalency_item_fields', 'equivalency_items', schema='a76', type_='unique')
# 2. Re-create old unique constraint
op.create_unique_constraint(
'uq_equivalency_item_fields',
'equivalency_items',
['original_field', 'external_field', 'tenant_id', 'company_id'],
schema='a76'
)
# 4. Revert identifier length in equivalencies table
op.alter_column(
'equivalencies',
'identifier',
type_=sa.String(10),
existing_type=sa.String(50),
schema='a76'
)
# 5. Drop equivalency_id column
op.drop_column('equivalency_items', 'equivalency_id', schema='a76')

View File

@@ -1,7 +1,7 @@
"""add invite codes """add invite codes
Revision ID: g2h3i4j5k6l7 Revision ID: g2h3i4j5k6l7
Revises: e1f2a3b4c5d6 Revises: c3d4e5f6a7b
Create Date: 2026-06-02 00:00:00.000000 Create Date: 2026-06-02 00:00:00.000000
""" """
@@ -11,7 +11,7 @@ import sqlalchemy as sa
from alembic import op from alembic import op
revision: str = "g2h3i4j5k6l7" revision: str = "g2h3i4j5k6l7"
down_revision: str = "e1f2a3b4c5d6" down_revision: str = "c3d4e5f6a7b"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None

View File

@@ -23,13 +23,11 @@ class TimestampMixin(BaseTimestampMixin):
class TenantScopedMixin: class TenantScopedMixin:
"""Mixin for tenant and company scoped entities""" """Mixin para entidades multi-tenant.
company_id no tiene FK declarada aquí — agrégala en cada modelo
apuntando a la tabla de compañías de tu proyecto.
"""
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True) 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) company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
class PedimentoRelatedMixin(TenantScopedMixin):
"""Mixin for entities related to pedimentos"""
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)

View File

@@ -1,352 +0,0 @@
"""
Annex 24 - Balance Management Core
SQLAlchemy v2
Plugs into the existing schema:
invoice_header (a76.invoice_header) ← already exists
item_lines (a76.item_lines) ← already exists
These 4 tables are ALL you need for balances:
a24.balance_movement ← the ledger (append-only, never UPDATE)
a24.discharge_header ← one discharge per export/SM/CTM event
a24.discharge_detail ← one row per (export line x import lot consumed)
a24.discharge_scrap ← mermas, desperdicios, destrucciones
Design rules:
1. NEVER update balance_movement rows — only INSERT
2. Balance = SUM of movements. No cached balance columns.
3. Every discharge_detail row MUST reference a balance_movement row
4. PEPS order is enforced via order_peps (monotonic, set on INSERT)
"""
from __future__ import annotations
import datetime
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from ..discharges.models import DischargeDetail
from sqlalchemy import (
BigInteger,
CheckConstraint,
Date,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.parts.models import Part
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MovementType(str, Enum):
# ── Positive (add to balance) ──────────────────────────────────────────
ENTRY = "entry" # Normal import entry
RETURN = "return" # Material returned to balance
POSITIVE_ADJUSTMENT = "pos_adj" # Physical inventory adjustment (+)
TRANSFER_IN = "transfer_in" # CTM/SM received (the receiving side)
REGIME_CHANGE_IN = "regime_chg_in" # Eg. temporary → definitive (entry side)
# ── Negative (consume from balance) ───────────────────────────────────
CONSUMPTION = "consumption" # Consumed in export (most common)
WASTE = "waste" # Merma de proceso
SCRAP = "scrap" # Desperdicio / scrap
DESTRUCTION = "destruction" # Destrucción oficial ante aduana
NEGATIVE_ADJUSTMENT = "neg_adj" # Physical inventory adjustment (-)
TRANSFER_OUT = "transfer_out" # CTM/SM sent (the sending side)
EXPIRATION = "expiration" # Balance cancelled due to deadline
REGIME_CHANGE_OUT = "regime_chg_out" # Eg. temporary → definitive (exit side)
# ── Reversal (annuls a prior ENTRY — used when un-processing an invoice) ─
# Inserting ENTRY_VOID with the same quantity as the original ENTRY leaves
# the net balance at zero, preventing any further discharges against that
# lot. A fresh ENTRY is created when the invoice is re-processed.
ENTRY_VOID = "entry_void"
# Which movement types reduce the balance (sign = -1)
NEGATIVE_MOVEMENTS = {
MovementType.CONSUMPTION,
MovementType.WASTE,
MovementType.SCRAP,
MovementType.DESTRUCTION,
MovementType.NEGATIVE_ADJUSTMENT,
MovementType.TRANSFER_OUT,
MovementType.EXPIRATION,
MovementType.REGIME_CHANGE_OUT,
MovementType.ENTRY_VOID,
}
# Which types count toward "used" (CANTUSADA in Anexo 24 report)
USED_MOVEMENTS = {
MovementType.CONSUMPTION,
MovementType.WASTE,
MovementType.SCRAP,
MovementType.DESTRUCTION,
}
# ---------------------------------------------------------------------------
# 1. BalanceMovement (the ledger — APPEND ONLY)
#
# One row per atomic change to a specific import lot.
# Current balance of any lot = SUM of (signed quantity) over its rows.
#
# import_item_line_id → a76.item_lines.id (the import line = the "lot")
# import_invoice_id → a76.invoice_header.id (the import invoice)
# part_number_id → a76.parts.id (denormalized for PEPS index)
# source_item_line_id → a76.item_lines.id (export/SM/CTM line, if any)
# source_invoice_id → a76.invoice_header.id (export/SM/CTM invoice, if any)
#
# NOTE: No discharge_detail_id column. Navigate the other direction via
# DischargeDetail.movement_id to avoid a circular FK and keep this
# table truly append-only (no UPDATE ever needed).
# ---------------------------------------------------------------------------
class BalanceMovement(Base, TenantScopedMixin, TimestampMixin):
"""
Core ledger table. NEVER update existing rows — only INSERT.
Balance of a lot = SUM(quantity) WHERE sign=+1 (entries)
- SUM(quantity) WHERE sign=-1 (exits)
Append-only rule is enforced at the application layer.
"""
__tablename__ = "balance_movement"
__table_args__ = (
UniqueConstraint(
"import_item_line_id", "order_peps",
name="uq_balance_movement_lot_peps",
),
CheckConstraint("quantity > 0", name="ck_balance_movement_qty_positive"),
# PEPS lookup: "give me available lots for this part+regime, oldest first"
# part_number_id is denormalized here so this index is self-contained.
Index(
"ix_balmov_peps_lookup",
"tenant_id", "part_number_id", "movement_type", "order_peps",
postgresql_include=["import_item_line_id", "quantity", "value_me", "value_mn"],
),
# Balance calculation per lot
Index("ix_balmov_lot", "import_item_line_id"),
# "What did this export consume?"
Index("ix_balmov_source", "source_invoice_id", "source_item_line_id"),
# Anexo 24 period reports
Index("ix_balmov_operation_date", "tenant_id", "operation_date", "movement_type"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# ── The import lot this movement belongs to ────────────────────────────
import_invoice_id: Mapped[int] = mapped_column(
ForeignKey("a76.invoice_header.id"),
comment="Import invoice (cabecera de importación)",
)
import_item_line_id: Mapped[int] = mapped_column(
ForeignKey("a76.item_lines.id"),
comment="Import line item = the PEPS lot",
)
# ── Denormalized part reference — enables efficient PEPS index ─────────
# Must equal import_line.part_number_id. Set on INSERT, never changed.
part_number_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.parts.id"),
comment="Denormalized from item_lines.part_number_id. Enables PEPS index without joins.",
)
# ── What kind of movement ─────────────────────────────────────────────
movement_type: Mapped[MovementType] = mapped_column(
String(20),
comment="See MovementType enum. Determines sign and whether qty counts as used.",
)
# ── Quantity and value (always stored positive) ───────────────────────
quantity: Mapped[Decimal] = mapped_column(
Numeric(19, 8),
comment="Always positive. Sign is inferred from movement_type via NEGATIVE_MOVEMENTS.",
)
value_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8), comment="USD")
value_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8), comment="MXN")
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
# ── Document that caused this movement ────────────────────────────────
# NULL for ENTRY movements (the import invoice itself is the cause)
source_invoice_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.invoice_header.id"),
comment="Export / SM / CTM invoice. NULL for entries.",
)
source_item_line_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.item_lines.id"),
comment="Specific line in the export / SM / CTM invoice.",
)
# ── PEPS ordering ─────────────────────────────────────────────────────
# Simplest approach: set order_peps = id (globally monotonic).
# Finer approach: use a per-part sequence or epoch-based value.
order_peps: Mapped[int] = mapped_column(
BigInteger,
comment="PEPS order within this lot. Lower = older = consumed first.",
)
# ── Business date of the operation ───────────────────────────────────
operation_date: Mapped[datetime.date] = mapped_column(
Date, comment="Date of the actual business event, not DB insert."
)
notes: Mapped[Optional[str]] = mapped_column(String(300))
# ── Relationships ─────────────────────────────────────────────────────
import_invoice: Mapped["InvoiceHeader"] = relationship(
foreign_keys=[import_invoice_id],
)
import_line: Mapped["LineItem"] = relationship(
foreign_keys=[import_item_line_id],
)
part: Mapped[Optional["Part"]] = relationship(
foreign_keys=[part_number_id],
)
source_invoice: Mapped[Optional["InvoiceHeader"]] = relationship(
foreign_keys=[source_invoice_id],
)
source_line: Mapped[Optional["LineItem"]] = relationship(
foreign_keys=[source_item_line_id],
)
# Back-reference: navigate to the detail that consumed this movement.
# Use viewonly=True — ownership lives on DischargeDetail.movement_id.
discharge_detail: Mapped[Optional["DischargeDetail"]] = relationship(
back_populates="movement",
foreign_keys="[DischargeDetail.movement_id]",
primaryjoin="BalanceMovement.id == DischargeDetail.movement_id",
viewonly=True,
)
# ---------------------------------------------------------------------------
# Repository helpers (copy to your service/repository layer)
# ---------------------------------------------------------------------------
#
#
# ── Current balance of a lot ─────────────────────────────────────────────
#
# from sqlalchemy import case, func, select
#
# def current_balance(session, import_item_line_id: int):
# sign = case(
# (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), -1),
# else_=1,
# )
# affects_used = BalanceMovement.movement_type.in_(USED_MOVEMENTS)
# return session.execute(
# select(
# func.sum(sign * BalanceMovement.quantity).label("current_balance"),
# func.sum(
# case((affects_used, BalanceMovement.quantity), else_=0)
# ).label("quantity_used"),
# func.sum(
# case((affects_used, BalanceMovement.value_me), else_=0)
# ).label("value_used_me"),
# ).where(BalanceMovement.import_item_line_id == import_item_line_id)
# ).one()
#
#
# ── PEPS resolver — call BEFORE inserting a CONSUMPTION movement ─────────
#
# def peps_lots_for(session, tenant_id, part_number_id, operation_type, qty_needed):
# """
# Returns import lots in FIFO order with their available balance.
# Walk the list and consume until qty_needed is satisfied.
# """
# sign = case(
# (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), -1),
# else_=1,
# )
# lot_balances = (
# select(
# BalanceMovement.import_item_line_id,
# func.sum(sign * BalanceMovement.quantity).label("available"),
# func.min(BalanceMovement.order_peps).label("oldest_peps"),
# )
# .where(
# BalanceMovement.tenant_id == tenant_id,
# BalanceMovement.part_number_id == part_number_id,
# )
# .group_by(BalanceMovement.import_item_line_id)
# .having(func.sum(sign * BalanceMovement.quantity) > 0)
# .order_by("oldest_peps")
# .subquery()
# )
# return session.execute(select(lot_balances)).all()
#
#
# ── Transaction flow for a new discharge ─────────────────────────────────
#
# def apply_discharge(session, export_invoice_id, lines):
# """
# lines = [{"export_line_id": X, "part_number_id": Y, "quantity": Z}, ...]
#
# Two-step INSERT: movement first, then detail referencing it.
# No UPDATE on balance_movement — design rule 1 is preserved.
# """
# header = DischargeHeader(source_invoice_id=export_invoice_id, ...)
# session.add(header)
# session.flush() # get header.id
#
# for line in lines:
# lots = peps_lots_for(session, ..., line["part_number_id"], qty_needed=line["quantity"])
# remaining = line["quantity"]
#
# for lot in lots:
# consume = min(lot.available, remaining)
#
# # Step 1: insert movement
# mov = BalanceMovement(
# import_item_line_id = lot.import_item_line_id,
# part_number_id = line["part_number_id"],
# movement_type = MovementType.CONSUMPTION,
# quantity = consume,
# source_invoice_id = export_invoice_id,
# source_item_line_id = line["export_line_id"],
# order_peps = <next_sequence>,
# operation_date = datetime.date.today(),
# )
# session.add(mov)
# session.flush() # get mov.id
#
# # Step 2: insert detail referencing the movement
# det = DischargeDetail(
# discharge_header_id = header.id,
# export_item_line_id = line["export_line_id"],
# import_item_line_id = lot.import_item_line_id,
# movement_id = mov.id, # NOT NULL — set immediately
# quantity_discharged = consume,
# )
# session.add(det)
#
# remaining -= consume
# if remaining <= 0:
# break
#
# session.commit()

View File

@@ -1,369 +0,0 @@
"""
Annex 24 - Balance Management Core
SQLAlchemy v2
Plugs into the existing schema:
invoice_header (a76.invoice_header) ← already exists
item_lines (a76.item_lines) ← already exists
These 4 tables are ALL you need for balances:
a24.balance_movement ← the ledger (append-only, never UPDATE)
a24.discharge_header ← one discharge per export/SM/CTM event
a24.discharge_detail ← one row per (export line x import lot consumed)
a24.discharge_scrap ← mermas, desperdicios, destrucciones
Design rules:
1. NEVER update balance_movement rows — only INSERT
2. Balance = SUM of movements. No cached balance columns.
3. Every discharge_detail row MUST reference a balance_movement row
4. PEPS order is enforced via order_peps (monotonic, set on INSERT)
"""
from __future__ import annotations
import datetime
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import (
BigInteger,
CheckConstraint,
Date,
ForeignKey,
Index,
Integer,
Numeric,
String,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from ..balance_movements.models import BalanceMovement
class DischargeType(str, Enum):
TEMPORARY = "temporary" # SDescargaT — export against temp import
DEFINITIVE = "definitive" # SDescargaD — export against def import
REPAIR = "repair" # SDescargaR — repair / return
DIRECTED = "directed" # SDescargaM — Directed discharge
CTM = "ctm" # SDescargaCTM — between plants (same group)
SUBASSEMBLY = "subassembly" # SDescargaSM — to external maquiladora
WASTE_SCRAP = "waste_scrap" # SDescargaMerDes — merma / desperdicio
class DischargeStatus(str, Enum):
PENDING = "pending"
APPLIED = "applied"
PARTIAL = "partial"
CANCELLED = "cancelled"
# ---------------------------------------------------------------------------
# 2. DischargeHeader
#
# One row per discharge event (export, SM batch, CTM transfer…).
# Groups all DischargeDetail rows for the same business event.
#
# source_invoice_id → a76.invoice_header.id (the export/SM/CTM invoice)
# def_import_invoice_id → a76.invoice_header.id (DEF discharges only)
# ---------------------------------------------------------------------------
class DischargeHeader(Base, TenantScopedMixin, TimestampMixin):
"""
One discharge per export event.
Groups DischargeDetail rows (one per import lot consumed).
"""
__tablename__ = "discharge_header"
__table_args__ = (
Index("ix_dischdr_source", "source_invoice_id", "status"),
Index("ix_dischdr_date", "tenant_id", "discharge_date", "discharge_type"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# ── Export / SM / CTM document that triggers this discharge ───────────
source_invoice_id: Mapped[int] = mapped_column(
ForeignKey("a76.invoice_header.id"),
comment="Export, SM-out or CTM-send invoice that owns this discharge.",
)
# ── For DEFINITIVE discharges only ────────────────────────────────────
def_import_invoice_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.invoice_header.id"),
comment="Populated only for discharge_type=DEFINITIVE.",
)
discharge_type: Mapped[DischargeType] = mapped_column(String(15))
status: Mapped[DischargeStatus] = mapped_column(
String(15), server_default=text("'applied'")
)
discharge_date: Mapped[datetime.date] = mapped_column(Date)
# ── Control fields from legacy SDescarga* tables ──────────────────────
reference_invoice: Mapped[Optional[str]] = mapped_column(
String(19), comment="FACREFERENCIA — for rectifications"
)
discharge_subtype: Mapped[Optional[str]] = mapped_column(
String(10), comment="TIPODESC: NORMAL, PARCIAL, REPARACION, UTILERIA"
)
partial_sequence: Mapped[Optional[int]] = mapped_column(
Integer, comment="CONSECPARCIAL — for partial discharges"
)
sales_order: Mapped[Optional[str]] = mapped_column(String(20)) # ORDENVENTA
ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM
is_tooling: Mapped[bool] = mapped_column(
server_default=text("false"), comment="PORUTILERIA"
)
discharge_sm: Mapped[Optional[str]] = mapped_column(String(4)) # DESCARGASM
is_repair_update: Mapped[bool] = mapped_column(
server_default=text("false"), comment="ACTUALREPARACION"
)
material_type_expo: Mapped[Optional[str]] = mapped_column(
String(10), comment="TIPOMATEXPO"
)
# ── Cancellation trail ────────────────────────────────────────────────
cancelled_by: Mapped[Optional[str]] = mapped_column(String(100))
cancellation_reason: Mapped[Optional[str]] = mapped_column(String(300))
# ── Relationships ─────────────────────────────────────────────────────
source_invoice: Mapped["InvoiceHeader"] = relationship(
foreign_keys=[source_invoice_id],
)
def_import_invoice: Mapped[Optional["InvoiceHeader"]] = relationship(
foreign_keys=[def_import_invoice_id],
)
details: Mapped[List["DischargeDetail"]] = relationship(
back_populates="header", cascade="all, delete-orphan"
)
scraps: Mapped[List["DischargeScrap"]] = relationship(
back_populates="header", cascade="all, delete-orphan"
)
# ---------------------------------------------------------------------------
# 3. DischargeDetail
#
# The critical traceability link:
# "Export line X consumed Y units from import lot Z"
#
# One row per (export_line x import_lot) pair.
# A single export line can span multiple rows when PEPS pulls from
# more than one import lot.
#
# discharge_header_id → a24.discharge_header.id
# export_item_line_id → a76.item_lines.id (the export line)
# import_item_line_id → a76.item_lines.id (the import lot consumed)
# movement_id → a24.balance_movement.id (the ledger row)
# ---------------------------------------------------------------------------
class DischargeDetail(Base, TenantScopedMixin, TimestampMixin):
"""
One row per (export line x import lot consumed).
This is the traceability record the SAT asks for:
"Show me which import pedimento covered this export line."
"""
__tablename__ = "discharge_detail"
__table_args__ = (
CheckConstraint("movement_id IS NOT NULL", name="ck_dischdet_movement_required"),
Index("ix_dischdet_header", "discharge_header_id"),
Index("ix_dischdet_import_lot", "import_item_line_id"),
Index("ix_dischdet_export_line", "export_item_line_id"),
Index("ix_dischdet_part", "tenant_id", "part_number"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
discharge_header_id: Mapped[int] = mapped_column(
ForeignKey("a24.discharge_header.id"),
)
# ── Export side ───────────────────────────────────────────────────────
export_item_line_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.item_lines.id"),
comment="NULL for waste-only discharges.",
)
# Denormalized for report performance
part_number: Mapped[Optional[str]] = mapped_column(
String(70), comment="NUMPARTE of the export line (denormalized)"
)
export_part_number: Mapped[Optional[str]] = mapped_column(
String(70), comment="NUMPARTEEXPO — as it appears in the pedimento"
)
export_line_ref: Mapped[Optional[int]] = mapped_column(
Integer, comment="LINEAEXPOREF — for rectification references"
)
# ── Import lot side (PEPS lot consumed) ───────────────────────────────
import_item_line_id: Mapped[int] = mapped_column(
ForeignKey("a76.item_lines.id"),
)
# ── Ledger row created for this consumption (NOT NULL — design rule 3) ─
movement_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("a24.balance_movement.id"),
comment="The BalanceMovement that records this consumption. Required.",
)
# ── Consumed quantities and values ────────────────────────────────────
quantity_discharged: Mapped[Decimal] = mapped_column(Numeric(19, 8)) # CANTDESC
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5))
value_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
value_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
# ── Tariff classification of the consumed input ───────────────────────
tariff_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONIMPO
fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION
ad_valorem: Mapped[Optional[str]] = mapped_column(String(10)) # ADVALOREMIMPO
country_of_origin: Mapped[Optional[str]] = mapped_column(String(3)) # PAISMERCANCIA
sector: Mapped[Optional[str]] = mapped_column(String(8)) # SECTOR
# ── Repair-specific ───────────────────────────────────────────────────
original_part: Mapped[Optional[str]] = mapped_column(
String(70), comment="PARTEORIGINAL"
)
equivalent_quantity: Mapped[Optional[Decimal]] = mapped_column(
Numeric(19, 8), comment="CANTEQUIVALENTE"
)
equivalent_unit: Mapped[Optional[str]] = mapped_column(String(5))
returned_quantity_sm: Mapped[Optional[Decimal]] = mapped_column(
Numeric(19, 8), comment="CANTRETORNADASAM"
)
# ── Waste / scrap ─────────────────────────────────────────────────────
waste_type: Mapped[Optional[str]] = mapped_column(
String(1), comment="M=merma, D=desperdicio, S=scrap"
)
take_balance_base_pt: Mapped[Optional[str]] = mapped_column(
String(2), comment="TOMARSALDOBASEALPT"
)
# ── Tax fields (from SDescargaT) ──────────────────────────────────────
igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI
tax_payment: Mapped[Optional[str]] = mapped_column(String(1)) # PAGOIMPUESTO
has_certificate: Mapped[Optional[str]] = mapped_column(String(1)) # TIENECERT
iva_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORIVAMN
iva_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORIVAME
# ── SM origin traceability ────────────────────────────────────────────
origin_import_invoice: Mapped[Optional[str]] = mapped_column(
String(15), comment="FACTURAIMPO original (denorm for SM)"
)
procedence: Mapped[Optional[str]] = mapped_column(String(3)) # PROCEDENCIA
# ── Relationships ─────────────────────────────────────────────────────
header: Mapped["DischargeHeader"] = relationship(back_populates="details")
export_line: Mapped[Optional["LineItem"]] = relationship(
foreign_keys=[export_item_line_id],
)
import_line: Mapped["LineItem"] = relationship(
foreign_keys=[import_item_line_id],
)
movement: Mapped["BalanceMovement"] = relationship(
foreign_keys=[movement_id],
back_populates="discharge_detail",
)
# ---------------------------------------------------------------------------
# 4. DischargeScrap
#
# Mermas, desperdicios and destrucciones.
# Can be standalone (no export invoice) or linked to a DischargeHeader.
# Always generates a BalanceMovement of type WASTE / SCRAP / DESTRUCTION.
#
# Replaces: SDescargaS + SDescargaMerDes
# ---------------------------------------------------------------------------
class DischargeScrap(Base, TenantScopedMixin, TimestampMixin):
"""
Waste / scrap / destruction records.
Replaces SDescargaS and the waste portion of SDescargaMerDes.
"""
__tablename__ = "discharge_scrap"
__table_args__ = (
Index("ix_dischscrap_header", "discharge_header_id"),
Index("ix_dischscrap_import_lot", "import_item_line_id"),
Index("ix_dischscrap_date", "tenant_id", "scrap_date"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# ── Optional parent discharge ─────────────────────────────────────────
discharge_header_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a24.discharge_header.id"),
comment="NULL when scrap is registered independently (not tied to an export).",
)
# ── The import lot being scrapped ──────────────────────────────────────
import_item_line_id: Mapped[int] = mapped_column(
ForeignKey("a76.item_lines.id"),
)
# ── Ledger row recording this scrap ───────────────────────────────────
movement_id: Mapped[Optional[int]] = mapped_column(
BigInteger, ForeignKey("a24.balance_movement.id"),
)
# ── Type ──────────────────────────────────────────────────────────────
scrap_type: Mapped[str] = mapped_column(
String(1), comment="M=merma, D=desperdicio, S=scrap, X=destrucción"
)
# ── Finished-good export line that generated this scrap ───────────────
finished_good_line_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.item_lines.id"),
comment="Export line of the product whose manufacture created this scrap.",
)
finished_good_part: Mapped[Optional[str]] = mapped_column(String(70))
# ── Scrap export pedimento (if scrap is exported separately) ──────────
scrap_export_invoice_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.invoice_header.id"),
comment="If desperdicio has its own export pedimento.",
)
# ── The scrapped material ─────────────────────────────────────────────
part_number: Mapped[str] = mapped_column(String(70))
item_class: Mapped[Optional[str]] = mapped_column(String(8))
quantity: Mapped[Decimal] = mapped_column(Numeric(19, 8))
unit_of_measure: Mapped[str] = mapped_column(String(5))
value_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
value_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
procedence: Mapped[Optional[str]] = mapped_column(String(3))
scrap_date: Mapped[datetime.date] = mapped_column(Date)
# ── Relationships ─────────────────────────────────────────────────────
header: Mapped[Optional["DischargeHeader"]] = relationship(
back_populates="scraps",
)
import_line: Mapped["LineItem"] = relationship(
foreign_keys=[import_item_line_id],
)
finished_good_line: Mapped[Optional["LineItem"]] = relationship(
foreign_keys=[finished_good_line_id],
)
scrap_export_invoice: Mapped[Optional["InvoiceHeader"]] = relationship(
foreign_keys=[scrap_export_invoice_id],
)
movement: Mapped[Optional["BalanceMovement"]] = relationship(
foreign_keys=[movement_id],
)

View File

@@ -1,107 +0,0 @@
"""
DTOs (Data Transfer Objects) para módulo de clases de activos fijos (FA)
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FAClassCreateDTO(BaseModel):
"""DTO para crear una clase de activo fijo"""
class_id: int = Field(..., description="ID de la clase base en a76.classes")
import_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de importación"
)
import_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de importación"
)
export_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de exportación"
)
export_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de exportación"
)
depreciation_rate: Optional[Decimal] = Field(
None, description="Tasa de depreciación anual", ge=0, le=100
)
fda_code: Optional[str] = Field(
None, max_length=20, description="Código FDA"
)
eccn_code: Optional[str] = Field(
None, max_length=20, description="Código ECCN (Export Control Classification Number)"
)
class_enabled: Optional[bool] = Field(
True, description="Indica si la clase está habilitada"
)
class Config:
from_attributes = True
class FAClassUpdateDTO(BaseModel):
"""DTO para actualizar una clase de activo fijo"""
import_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de importación"
)
import_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de importación"
)
export_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de exportación"
)
export_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de exportación"
)
depreciation_rate: Optional[Decimal] = Field(
None, description="Tasa de depreciación anual", ge=0, le=100
)
fda_code: Optional[str] = Field(
None, max_length=20, description="Código FDA"
)
eccn_code: Optional[str] = Field(
None, max_length=20, description="Código ECCN"
)
class_enabled: Optional[bool] = Field(
None, description="Indica si la clase está habilitada"
)
class Config:
from_attributes = True
class FAClassResponseDTO(BaseModel):
"""DTO para respuesta de clase de activo fijo"""
id: int
tenant_id: int
company_id: int
class_id: int
import_tariff_code: Optional[str] = None
import_tariff_type: Optional[str] = None
export_tariff_code: Optional[str] = None
export_tariff_type: Optional[str] = None
depreciation_rate: Optional[Decimal] = None
fda_code: Optional[str] = None
eccn_code: Optional[str] = None
class_enabled: Optional[bool] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class FAClassListResponseDTO(BaseModel):
"""DTO para respuesta de lista paginada de clases de activos fijos"""
items: list[FAClassResponseDTO]
total: int
page: int
page_size: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,49 +0,0 @@
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 (
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[Optional[str]] = mapped_column(
String(10), nullable=True
) # FRACCIONIMPO
import_tariff_type: Mapped[Optional[str]] = mapped_column(
String(6), nullable=True
) # TIPOFRACIMPO
export_tariff_code: Mapped[Optional[str]] = mapped_column(
String(10), nullable=True
) # FRACCIONEXPO
export_tariff_type: Mapped[Optional[str]] = mapped_column(
String(6), nullable=True
) # TIPOFRACEXPO
depreciation_rate: Mapped[Optional[Decimal]] = mapped_column(
Numeric(5, 2), nullable=True
) # TASADEPRECIA
fda_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # FDA
eccn_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # ECCN
class_enabled: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true", nullable=False
) # HABILITADESHABILITACLASE

View File

@@ -1,32 +0,0 @@
"""
Endpoints API para gestión de clases de activos fijos (FA)
"""
from typing import Any, Dict
from fastapi import Depends, Query
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, validate_access_to_resource
from .dto import FAClassCreateDTO, FAClassResponseDTO, FAClassUpdateDTO
from .service import FAClassService
# Create router with generic CRUD routes
crud_routes = TenantCRUDRoutes(
service=FAClassService,
create_schema=FAClassCreateDTO,
update_schema=FAClassUpdateDTO,
response_schema=FAClassResponseDTO,
prefix="/fa/classes",
tags=["a24 / fa / classes"],
resource_name="Fixed Asset Class",
id_name="fa_class_id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=100,
)
router = crud_routes.router

View File

@@ -1,215 +0,0 @@
"""
Capa de servicio para lógica de negocio de clases de activos fijos (FA)
"""
import logging
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import FAClassCreateDTO, FAClassResponseDTO, FAClassUpdateDTO
from .models import QClasses
logger = logging.getLogger(__name__)
class FAClassService:
"""Servicio para gestión de clases de activos fijos"""
@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[QClasses], int]:
"""
Obtener todas las clases de activos fijos con paginación y filtros
"""
query = db.query(QClasses).filter(
QClasses.tenant_id == tenant_id, QClasses.company_id == company_id
)
if filters:
if filters.get("class_id"):
query = query.filter(QClasses.class_id == filters["class_id"])
if filters.get("fda_code"):
query = query.filter(
QClasses.fda_code.ilike(f"%{filters['fda_code']}%")
)
if filters.get("class_enabled") is not None:
query = query.filter(
QClasses.class_enabled == filters["class_enabled"]
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, fa_class_id: int, tenant_id: int, company_id: int
) -> Optional[QClasses]:
"""Obtener una clase de activo fijo por ID"""
return (
db.query(QClasses)
.filter(
QClasses.id == fa_class_id,
QClasses.tenant_id == tenant_id,
QClasses.company_id == company_id,
)
.first()
)
@staticmethod
def get_by_class_id(
db: Session, class_id: int, tenant_id: int, company_id: int
) -> Optional[QClasses]:
"""Obtener una clase de activo fijo por class_id de a76"""
return (
db.query(QClasses)
.filter(
QClasses.class_id == class_id,
QClasses.tenant_id == tenant_id,
QClasses.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session, fa_class_data: FAClassCreateDTO, tenant_id: int, company_id: int
) -> QClasses:
"""Crear una nueva clase de activo fijo"""
try:
# Verificar que la clase base existe en a76.classes
from api.v1.modules.a76.classes.models import Class
base_class = (
db.query(Class)
.filter(
Class.id == fa_class_data.class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
if not base_class:
raise HTTPException(
status_code=404,
detail=f"Base class with id {fa_class_data.class_id} not found"
)
# Verificar que no exista ya una clase de activo fijo para esta clase base
existing = FAClassService.get_by_class_id(
db, fa_class_data.class_id, tenant_id, company_id
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Fixed asset class already exists for class_id {fa_class_data.class_id}"
)
data_dict = fa_class_data.model_dump()
new_fa_class = QClasses(
**data_dict,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(new_fa_class)
db.commit()
db.refresh(new_fa_class)
return new_fa_class
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating fixed asset class: {str(e)}")
raise HTTPException(
status_code=400,
detail=f"Database constraint violation: {str(e.orig)}"
)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error creating fixed asset class: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def update(
db: Session,
fa_class_id: int,
tenant_id: int,
fa_class_data: FAClassUpdateDTO,
company_id: int,
) -> QClasses:
"""Actualizar una clase de activo fijo"""
fa_class = FAClassService.get_by_id(db, fa_class_id, tenant_id, company_id)
if not fa_class:
raise HTTPException(
status_code=404,
detail=f"Fixed asset class {fa_class_id} not found"
)
try:
update_data = fa_class_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(fa_class, key, value)
db.commit()
db.refresh(fa_class)
return fa_class
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating fixed asset class: {str(e)}")
raise HTTPException(
status_code=400,
detail=f"Database constraint violation: {str(e.orig)}"
)
except Exception as e:
db.rollback()
logger.error(f"Error updating fixed asset class: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def delete(
db: Session, fa_class_id: int, tenant_id: int, company_id: int
) -> None:
"""Eliminar una clase de activo fijo"""
fa_class = FAClassService.get_by_id(db, fa_class_id, tenant_id, company_id)
if not fa_class:
raise HTTPException(
status_code=404,
detail=f"Fixed asset class {fa_class_id} not found"
)
try:
db.delete(fa_class)
db.commit()
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting fixed asset class: {str(e)}")
raise HTTPException(
status_code=400,
detail="Cannot delete: Fixed asset class is referenced by other records"
)
except Exception as e:
db.rollback()
logger.error(f"Error deleting fixed asset class: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,17 +0,0 @@
"""
Módulo de líneas de activos fijos (FA Item Lines) para Anexo 24
"""
from .models import FaLineItem
from .dto import FaLineItemCreateDTO, FaLineItemUpdateDTO, FaLineItemResponseDTO
from .service import FaLineItemService
from .routes import router
__all__ = [
"FaLineItem",
"FaLineItemCreateDTO",
"FaLineItemUpdateDTO",
"FaLineItemResponseDTO",
"FaLineItemService",
"router",
]

View File

@@ -1,165 +0,0 @@
"""
DTOs (Data Transfer Objects) para líneas de activos fijos (FA Item Lines)
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FaLineItemCreateDTO(BaseModel):
"""DTO para crear una línea de activo fijo"""
# line_item_id references the id in a76.item_lines
# Not required on creation - will be set when the LineItem is created
line_item_id: Optional[int] = Field(None, description="ID de la línea base en a76.item_lines")
# Asset information (SCAF specific)
asset_number: Optional[str] = Field(
None, max_length=25, description="Número de activo"
)
asset_photo: Optional[str] = Field(
None, max_length=255, description="Foto del activo fijo"
)
equipment_message: Optional[str] = Field(
None, max_length=40, description="Mensaje del equipo"
)
invoice_type_asset: Optional[str] = Field(
None, max_length=6, description="Tipo de factura para activo"
)
return_import_invoice: Optional[str] = Field(
None, max_length=15, description="Factura de importación de retorno"
)
return_import_date: Optional[int] = Field(
None, description="Fecha de factura de importación de retorno"
)
movement_type_import: Optional[str] = Field(
None, max_length=3, description="Tipo de movimiento de importación"
)
# Cross-references for import repair
search_invoice: Optional[str] = Field(
None, max_length=15, description="Factura de búsqueda cruzada"
)
search_line: Optional[int] = Field(None, description="Línea de búsqueda cruzada")
# Search type
search_type: Optional[str] = Field(
None, max_length=10, description="Tipo de búsqueda"
)
# Subitems
is_subitem: Optional[bool] = Field(False, description="Es subpartida")
contains_subitems: Optional[bool] = Field(False, description="Contiene subpartidas")
includes_subitems: Optional[bool] = Field(False, description="Incluye subpartidas")
subitem_number: Optional[int] = Field(0, description="Número de subpartida")
# Special flags
discharge: Optional[bool] = Field(None, description="Indicador de descarga")
own_equipment: Optional[bool] = Field(None, description="Equipo propio")
omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31")
model_config = ConfigDict(from_attributes=True)
class FaLineItemUpdateDTO(BaseModel):
"""DTO para actualizar una línea de activo fijo"""
# Asset information (SCAF specific)
asset_number: Optional[str] = Field(
None, max_length=25, description="Número de activo"
)
asset_photo: Optional[str] = Field(
None, max_length=255, description="Foto del activo fijo"
)
equipment_message: Optional[str] = Field(
None, max_length=40, description="Mensaje del equipo"
)
invoice_type_asset: Optional[str] = Field(
None, max_length=6, description="Tipo de factura para activo"
)
return_import_invoice: Optional[str] = Field(
None, max_length=15, description="Factura de importación de retorno"
)
return_import_date: Optional[int] = Field(
None, description="Fecha de factura de importación de retorno"
)
movement_type_import: Optional[str] = Field(
None, max_length=3, description="Tipo de movimiento de importación"
)
# Cross-references for import repair
search_invoice: Optional[str] = Field(
None, max_length=15, description="Factura de búsqueda cruzada"
)
search_line: Optional[int] = Field(None, description="Línea de búsqueda cruzada")
# Search type
search_type: Optional[str] = Field(
None, max_length=10, description="Tipo de búsqueda"
)
# Subitems
is_subitem: Optional[bool] = Field(False, description="Es subpartida")
contains_subitems: Optional[bool] = Field(False, description="Contiene subpartidas")
includes_subitems: Optional[bool] = Field(False, description="Incluye subpartidas")
subitem_number: Optional[int] = Field(None, description="Número de subpartida")
# Special flags
discharge: Optional[bool] = Field(None, description="Indicador de descarga")
own_equipment: Optional[bool] = Field(None, description="Equipo propio")
omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31")
model_config = ConfigDict(from_attributes=True)
class FaLineItemResponseDTO(BaseModel):
"""DTO para respuesta de línea de activo fijo"""
id: int = Field(..., description="ID de la línea de activo fijo")
tenant_id: int = Field(..., description="ID del tenant")
company_id: int = Field(..., description="ID de la empresa")
# Asset information (SCAF specific)
asset_number: Optional[str] = Field(None, description="Número de activo")
asset_photo: Optional[str] = Field(None, description="Foto del activo fijo")
equipment_message: Optional[str] = Field(None, description="Mensaje del equipo")
invoice_type_asset: Optional[str] = Field(
None, description="Tipo de factura para activo"
)
return_import_invoice: Optional[str] = Field(
None, description="Factura de importación de retorno"
)
return_import_date: Optional[int] = Field(
None, description="Fecha de factura de importación de retorno"
)
movement_type_import: Optional[str] = Field(
None, description="Tipo de movimiento de importación"
)
# Cross-references for import repair
search_invoice: Optional[str] = Field(
None, description="Factura de búsqueda cruzada"
)
search_line: Optional[int] = Field(None, description="Línea de búsqueda cruzada")
# Search type
search_type: Optional[str] = Field(None, description="Tipo de búsqueda")
# Subitems
is_subitem: Optional[bool] = Field(False, description="Es subpartida")
contains_subitems: Optional[bool] = Field(False, description="Contiene subpartidas")
includes_subitems: Optional[bool] = Field(False, description="Incluye subpartidas")
subitem_number: Optional[int] = Field(None, description="Número de subpartida")
# Special flags
discharge: Optional[bool] = Field(None, description="Indicador de descarga")
own_equipment: Optional[bool] = Field(None, description="Equipo propio")
omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31")
# Timestamps
created_at: datetime = Field(..., description="Fecha de creación")
updated_at: datetime = Field(..., description="Fecha de actualización")
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,81 +0,0 @@
"""
Modelo ORM para datos específicos de líneas de Activos Fijos - Anexo 24
Extensión de a76.item_lines para SCAF (Sistema de Control de Activo Fijo)
"""
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
Boolean,
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a76.items.models import LineItem
class FaLineItem(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla fa_item_lines: Extensión de Anexo 24 para líneas de Activos Fijos.
Hereda el ID de la tabla item_lines en a76.
"""
__tablename__ = "fa_item_lines"
__table_args__ = (
PrimaryKeyConstraint("id", name="fa_item_lines_pkey"),
ForeignKeyConstraint(
["id"], ["a76.item_lines.id"], name="fk_fa_item_lines_master"
),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
# 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
# Subitems
is_subitem: Mapped[Optional[bool]] = mapped_column(Boolean) # ESSUBPARTIDA
contains_subitems: Mapped[Optional[bool]] = mapped_column(Boolean) # CONTIENESUBP
subitem_number: Mapped[Optional[int]] = mapped_column(Integer) # SUBPARTIDA
# Special flags
discharge: 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
# --- RELACIÓN ---
master_info: Mapped["LineItem"] = relationship("LineItem", back_populates="fa_data")
def __repr__(self) -> str:
return f"<FaLineItem(id={self.id}, asset_number='{self.asset_number}')>"

View File

@@ -1,35 +0,0 @@
"""
Endpoints API para gestión de líneas de activos fijos (FA Item Lines)
"""
from typing import Any, Dict
from fastapi import Depends, Query
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,
validate_access_to_resource,
)
from .dto import FaLineItemCreateDTO, FaLineItemResponseDTO, FaLineItemUpdateDTO
from .service import FaLineItemService
# Create router with generic CRUD routes
crud_routes = TenantCRUDRoutes(
service=FaLineItemService,
create_schema=FaLineItemCreateDTO,
update_schema=FaLineItemUpdateDTO,
response_schema=FaLineItemResponseDTO,
prefix="/fa/item-lines",
tags=["a24 / fa / item-lines"],
resource_name="Fixed Asset Line Item",
id_name="fa_line_item_id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=100,
)
router = crud_routes.router

View File

@@ -1,258 +0,0 @@
"""
Capa de servicio para lógica de negocio de líneas de activos fijos (FA Item Lines)
"""
import logging
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import FaLineItemCreateDTO, FaLineItemResponseDTO, FaLineItemUpdateDTO
from .models import FaLineItem
logger = logging.getLogger(__name__)
class FaLineItemService:
"""Servicio para gestión de líneas de activos fijos"""
@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[FaLineItem], int]:
"""
Obtener todas las líneas de activos fijos con paginación y filtros
"""
query = db.query(FaLineItem).filter(
FaLineItem.tenant_id == tenant_id, FaLineItem.company_id == company_id
)
if filters:
if filters.get("asset_number"):
query = query.filter(
FaLineItem.asset_number.ilike(f"%{filters['asset_number']}%")
)
if filters.get("invoice_type_asset"):
query = query.filter(
FaLineItem.invoice_type_asset == filters["invoice_type_asset"]
)
if filters.get("own_equipment") is not None:
query = query.filter(
FaLineItem.own_equipment == filters["own_equipment"]
)
if filters.get("discharge") is not None:
query = query.filter(FaLineItem.discharge == filters["discharge"])
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, fa_line_item_id: int, tenant_id: int, company_id: int
) -> Optional[FaLineItem]:
"""Obtener una línea de activo fijo por ID"""
return (
db.query(FaLineItem)
.filter(
FaLineItem.id == fa_line_item_id,
FaLineItem.tenant_id == tenant_id,
FaLineItem.company_id == company_id,
)
.first()
)
@staticmethod
def get_by_line_item_id(
db: Session, line_item_id: int, tenant_id: int, company_id: int
) -> Optional[FaLineItem]:
"""Obtener una línea de activo fijo por line_item_id de a76"""
return (
db.query(FaLineItem)
.filter(
FaLineItem.id == line_item_id,
FaLineItem.tenant_id == tenant_id,
FaLineItem.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session,
fa_line_item_data: FaLineItemCreateDTO,
tenant_id: int,
company_id: int,
) -> FaLineItem:
"""Crear una nueva línea de activo fijo"""
try:
# Verificar que la línea base existe en a76.item_lines
from api.v1.modules.a76.items.models import LineItem
base_line_item = (
db.query(LineItem)
.filter(
LineItem.id == fa_line_item_data.line_item_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
if not base_line_item:
raise HTTPException(
status_code=404,
detail=f"Base line item with id {fa_line_item_data.line_item_id} not found",
)
# Verificar si ya existe una línea FA para esta línea base
existing_fa = (
db.query(FaLineItem)
.filter(
FaLineItem.id == fa_line_item_data.line_item_id,
FaLineItem.tenant_id == tenant_id,
FaLineItem.company_id == company_id,
)
.first()
)
if existing_fa:
raise HTTPException(
status_code=400,
detail=f"FA line item already exists for line_item_id {fa_line_item_data.line_item_id}",
)
# Crear nueva línea FA
fa_line_item = FaLineItem(
id=fa_line_item_data.line_item_id, # Usa el mismo ID que la línea base
tenant_id=tenant_id,
company_id=company_id,
asset_number=fa_line_item_data.asset_number,
asset_photo=fa_line_item_data.asset_photo,
equipment_message=fa_line_item_data.equipment_message,
invoice_type_asset=fa_line_item_data.invoice_type_asset,
return_import_invoice=fa_line_item_data.return_import_invoice,
return_import_date=fa_line_item_data.return_import_date,
movement_type_import=fa_line_item_data.movement_type_import,
search_invoice=fa_line_item_data.search_invoice,
search_line=fa_line_item_data.search_line,
search_type=fa_line_item_data.search_type,
discharge=fa_line_item_data.discharge,
own_equipment=fa_line_item_data.own_equipment,
omit_annex31=fa_line_item_data.omit_annex31,
)
db.add(fa_line_item)
db.commit()
db.refresh(fa_line_item)
logger.info(
f"FA line item created: id={fa_line_item.id}, tenant={tenant_id}, company={company_id}"
)
return fa_line_item
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating FA line item: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error creating FA line item. Constraint violation.",
)
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error creating FA line item: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error creating FA line item"
)
@staticmethod
def update(
db: Session,
fa_line_item_id: int,
fa_line_item_data: FaLineItemUpdateDTO,
tenant_id: int,
company_id: int,
) -> FaLineItem:
"""Actualizar una línea de activo fijo existente"""
try:
fa_line_item = FaLineItemService.get_by_id(
db, fa_line_item_id, tenant_id, company_id
)
if not fa_line_item:
raise HTTPException(
status_code=404,
detail=f"FA line item with id {fa_line_item_id} not found",
)
# Actualizar solo los campos proporcionados
update_data = fa_line_item_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(fa_line_item, field, value)
db.commit()
db.refresh(fa_line_item)
logger.info(
f"FA line item updated: id={fa_line_item.id}, tenant={tenant_id}, company={company_id}"
)
return fa_line_item
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating FA line item: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error updating FA line item"
)
@staticmethod
def delete(
db: Session, fa_line_item_id: int, tenant_id: int, company_id: int
) -> bool:
"""Eliminar una línea de activo fijo"""
try:
fa_line_item = FaLineItemService.get_by_id(
db, fa_line_item_id, tenant_id, company_id
)
if not fa_line_item:
raise HTTPException(
status_code=404,
detail=f"FA line item with id {fa_line_item_id} not found",
)
db.delete(fa_line_item)
db.commit()
logger.info(
f"FA line item deleted: id={fa_line_item_id}, tenant={tenant_id}, company={company_id}"
)
return True
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error deleting FA line item: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error deleting FA line item"
)

View File

@@ -1,48 +0,0 @@
"""
Modelo ORM para datos específicos de Activos Fijos (Q-Partes) - Anexo 24
"""
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Integer,
PrimaryKeyConstraint,
String,
ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
class FaPart(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla fa_partes: Extensión de Anexo 24 para Activos Fijos.
"""
__tablename__ = "fa_partes"
__table_args__ = (
PrimaryKeyConstraint("id", name="fa_partes_pkey"),
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_fa_partes_master"
),
{"schema": "a24", "extend_existing": True},
)
# El ID hereda el valor de la tabla parts
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
# --- CAMPOS ESPECÍFICOS FISCALES (Q-PARTES) ---
origin_country: Mapped[Optional[str]] = mapped_column(String(3)) # PAIS
sector: Mapped[Optional[str]] = mapped_column(String(8)) # SECTOR
fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION
# --- RELACIÓN ---
# Usamos string "Part" para evitar que truene al inicializar los mappers
master_info: Mapped["Part"] = relationship("Part", back_populates="fa_data")
def __repr__(self) -> str:
return f"<FaPart(id={self.id}, sector='{self.sector}')>"

View File

@@ -1,56 +0,0 @@
"""
Modelo ORM para la lista de materiales (BOM) de una parte - Anexo 24
"""
from typing import TYPE_CHECKING, Optional
from decimal import Decimal
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Integer,
Numeric,
String,
PrimaryKeyConstraint,
ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
class BillOfMaterial(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla inv_bom: Lista de materiales para una parte.
"""
__tablename__ = "inv_bom"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_bom_pkey"),
ForeignKeyConstraint(
["parent_part_id"], ["a76.parts.id"], name="fk_inv_bom_parent"
),
ForeignKeyConstraint(
["component_part_id"], ["a76.parts.id"], name="fk_inv_bom_component"
),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
parent_part_id: Mapped[int] = mapped_column(Integer, nullable=False)
component_part_id: Mapped[int] = mapped_column(Integer, nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(19, 8), default=Decimal('1.0'))
# Nuevos campos Legacy
uom_code: Mapped[str] = mapped_column(String(5), nullable=False)
procedure_type: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # TEMPORAL, DEFINITIVA, CTM
is_percentage: Mapped[bool] = mapped_column(default=True)
raw_material: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True)
waste: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True)
merma: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True)
# Relaciones
parent_part: Mapped["Part"] = relationship("Part", foreign_keys=[parent_part_id], back_populates="bom_items")
component_part: Mapped["Part"] = relationship("Part", foreign_keys=[component_part_id])
def __repr__(self) -> str:
return f"<BillOfMaterial(id={self.id}, parent={self.parent_part_id}, component={self.component_part_id}, uom={self.uom_code})>"

View File

@@ -1,17 +0,0 @@
from .inv_aphis_general.dto import InvPartAphisGeneralDTO
from .inv_aphis_characteristic.dto import InvPartAphisCharacteristicDTO
from .inv_aphis_stype_pitems.dto import InvPartAphisStypePitemsDTO
from .inv_aphis_lpcos.dto import InvPartAphisLpcosDTO
from .inv_aphis_entities.dto import InvPartAphisEntitiesDTO
from .inv_aphis_containers.dto import InvPartAphisContainersDTO
from .inv_aphis_routing.dto import InvPartAphisRoutingDTO
__all__ = [
"InvPartAphisGeneralDTO",
"InvPartAphisCharacteristicDTO",
"InvPartAphisStypePitemsDTO",
"InvPartAphisLpcosDTO",
"InvPartAphisEntitiesDTO",
"InvPartAphisContainersDTO",
"InvPartAphisRoutingDTO"
]

View File

@@ -1,57 +0,0 @@
from pydantic import BaseModel, ConfigDict, field_validator
from typing import Optional, List, Dict, Any
from datetime import date
class AphisCatalogDTO(BaseModel):
id: Optional[int] = None
# --- Pestaña 1: General ---
program_code: Optional[str] = None
processing_code: Optional[str] = None
aphis_type: Optional[str] = None
disclaimer: Optional[str] = None
electronic_image: Optional[str] = None
confidential: Optional[str] = None
global_product_id: Optional[str] = None
intended_use_code: Optional[str] = None
intended_use_description: Optional[str] = None
item_type: Optional[str] = None
product_code: Optional[str] = None
product_code_2: Optional[str] = None
product_code_3: Optional[str] = None
scientific_genus_name: Optional[str] = None
scientific_species_name: Optional[str] = None
scientific_sub_species_name: Optional[str] = None
common_name_specific: Optional[str] = None
common_name_general: Optional[str] = None
signed_doc: Optional[str] = None
signed_doc_date: Optional[date] = None
signed_doc_id: Optional[str] = None
invoice_number: Optional[str] = None
quantity_1: Optional[str] = None
quantity_2: Optional[str] = None
quantity_3: Optional[str] = None
inspection: Optional[str] = None
inspection_date: Optional[date] = None
inspection_loc_date: Optional[date] = None
inspection_location: Optional[str] = None
country_production: Optional[str] = None
country_source: Optional[str] = None
# --- Pestañas 2-7: Detalles (Listas de objetos) ---
characteristics: Optional[List[Dict[str, Any]]] = []
pitems: Optional[List[Dict[str, Any]]] = []
lpcos: Optional[List[Dict[str, Any]]] = []
entities: Optional[List[Dict[str, Any]]] = []
containers: Optional[List[Dict[str, Any]]] = []
routing: Optional[List[Dict[str, Any]]] = []
model_config = ConfigDict(from_attributes=True)
@field_validator("signed_doc_date", "inspection_date", "inspection_loc_date", mode="before")
@classmethod
def empty_to_none(cls, v):
if v == "":
return None
return v

View File

@@ -1,61 +0,0 @@
from typing import Optional, List, Dict, Any
from datetime import date
from sqlalchemy import Integer, String, Date, PrimaryKeyConstraint, JSON
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class AphisCatalog(Base):
"""
Catálogo global de registros APHIS por empresa.
Soporta las 7 pestañas de información (General + 6 detalles via JSON).
"""
__tablename__ = "inv_aphis_catalog"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_catalog_pkey"),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# --- Pestaña 1: General (Campos principales) ---
program_code: Mapped[Optional[str]] = mapped_column(String(10))
processing_code: Mapped[Optional[str]] = mapped_column(String(10))
aphis_type: Mapped[Optional[str]] = mapped_column(String(10))
disclaimer: Mapped[Optional[str]] = mapped_column(String(10))
electronic_image: Mapped[Optional[str]] = mapped_column(String(50))
confidential: Mapped[Optional[str]] = mapped_column(String(1))
global_product_id: Mapped[Optional[str]] = mapped_column(String(100))
intended_use_code: Mapped[Optional[str]] = mapped_column(String(10))
intended_use_description: Mapped[Optional[str]] = mapped_column(String(200))
item_type: Mapped[Optional[str]] = mapped_column(String(20))
product_code: Mapped[Optional[str]] = mapped_column(String(20))
product_code_2: Mapped[Optional[str]] = mapped_column(String(20))
product_code_3: Mapped[Optional[str]] = mapped_column(String(20))
scientific_genus_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_species_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_sub_species_name: Mapped[Optional[str]] = mapped_column(String(100))
common_name_specific: Mapped[Optional[str]] = mapped_column(String(200))
common_name_general: Mapped[Optional[str]] = mapped_column(String(200))
signed_doc: Mapped[Optional[str]] = mapped_column(String(100))
signed_doc_date: Mapped[Optional[date]] = mapped_column(Date)
signed_doc_id: Mapped[Optional[str]] = mapped_column(String(50))
invoice_number: Mapped[Optional[str]] = mapped_column(String(50))
quantity_1: Mapped[Optional[str]] = mapped_column(String(50))
quantity_2: Mapped[Optional[str]] = mapped_column(String(50))
quantity_3: Mapped[Optional[str]] = mapped_column(String(50))
inspection: Mapped[Optional[str]] = mapped_column(String(200))
inspection_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_loc_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_location: Mapped[Optional[str]] = mapped_column(String(200))
country_production: Mapped[Optional[str]] = mapped_column(String(3))
country_source: Mapped[Optional[str]] = mapped_column(String(3))
# --- Pestañas 2-7: Detalles (Almacenados como JSON por flexibilidad) ---
characteristics: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
pitems: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
lpcos: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
entities: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
containers: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
routing: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)

View File

@@ -1,62 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from core.database import get_core_db
from .models import AphisCatalog
from .dto import AphisCatalogDTO
router = APIRouter(prefix="/aphis-catalog", tags=["APHIS Catalog"])
@router.get("/", response_model=List[AphisCatalogDTO])
def list_aphis_catalog(company_id: int, db: Session = Depends(get_core_db)):
records = (
db.query(AphisCatalog)
.filter(AphisCatalog.company_id == company_id)
.order_by(AphisCatalog.id)
.all()
)
return records
@router.post("/", response_model=AphisCatalogDTO)
def create_aphis_catalog(data: AphisCatalogDTO, company_id: int, db: Session = Depends(get_core_db)):
payload = data.model_dump(exclude={"id"})
record = AphisCatalog(**payload, company_id=company_id)
db.add(record)
db.commit()
db.refresh(record)
return record
@router.put("/{record_id}", response_model=AphisCatalogDTO)
def update_aphis_catalog(
record_id: int, data: AphisCatalogDTO, company_id: int, db: Session = Depends(get_core_db)
):
record = (
db.query(AphisCatalog)
.filter(AphisCatalog.id == record_id, AphisCatalog.company_id == company_id)
.first()
)
if not record:
raise HTTPException(status_code=404, detail="Registro no encontrado")
for field, value in data.model_dump(exclude={"id"}).items():
setattr(record, field, value)
db.commit()
db.refresh(record)
return record
@router.delete("/{record_id}", status_code=204)
def delete_aphis_catalog(record_id: int, company_id: int, db: Session = Depends(get_core_db)):
record = (
db.query(AphisCatalog)
.filter(AphisCatalog.id == record_id, AphisCatalog.company_id == company_id)
.first()
)
if not record:
raise HTTPException(status_code=404, detail="Registro no encontrado")
db.delete(record)
db.commit()

View File

@@ -1,17 +0,0 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import date
class InvPartAphisCharacteristicDTO(BaseModel):
id: Optional[int] = None
aphis_general_id: Optional[int] = None
item_id: Optional[str] = None
number_from: Optional[str] = None
number_to: Optional[str] = None
category_type: Optional[str] = None
commodity_qua: Optional[str] = None
commodity_char_qua: Optional[str] = None
description: Optional[str] = None
category_code: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,35 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from datetime import date
from sqlalchemy import (
Integer, String, Date, Numeric, Boolean,
PrimaryKeyConstraint, ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class InvPartAphisCharacteristic(Base, TenantScopedMixin, TimestampMixin):
"""Módulo: Item characteristic"""
__tablename__ = "inv_aphis_characteristic"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_characteristic_pkey"),
ForeignKeyConstraint(["aphis_general_id"], ["a24.inv_aphis_general.id"]),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
aphis_general_id: Mapped[int] = mapped_column(Integer, nullable=False)
# --- CAMPOS CARACTERISTICAS ---
item_id: Mapped[Optional[str]] = mapped_column(String(50))
number_from: Mapped[Optional[str]] = mapped_column(String(50))
number_to: Mapped[Optional[str]] = mapped_column(String(50))
category_type: Mapped[Optional[str]] = mapped_column(String(50))
commodity_qua: Mapped[Optional[str]] = mapped_column(String(50))
commodity_char_qua: Mapped[Optional[str]] = mapped_column(String(50))
description: Mapped[Optional[str]] = mapped_column(String(200))
category_code: Mapped[Optional[str]] = mapped_column(String(50))
aphis_general: Mapped["InvPartAphisGeneral"] = relationship(back_populates="characteristics")

View File

@@ -1,12 +0,0 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import date
class InvPartAphisContainersDTO(BaseModel):
id: Optional[int] = None
aphis_general_id: Optional[int] = None
container_number: Optional[str] = None
length: Optional[str] = None
type: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,30 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from datetime import date
from sqlalchemy import (
Integer, String, Date, Numeric, Boolean,
PrimaryKeyConstraint, ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class InvPartAphisContainers(Base, TenantScopedMixin, TimestampMixin):
"""Módulo: containers"""
__tablename__ = "inv_aphis_containers"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_containers_pkey"),
ForeignKeyConstraint(["aphis_general_id"], ["a24.inv_aphis_general.id"]),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
aphis_general_id: Mapped[int] = mapped_column(Integer, nullable=False)
# --- CAMPOS CONTAINERS ---
container_number: Mapped[Optional[str]] = mapped_column(String(50))
length: Mapped[Optional[str]] = mapped_column(String(20))
type: Mapped[Optional[str]] = mapped_column(String(50))
aphis_general: Mapped["InvPartAphisGeneral"] = relationship(back_populates="containers")

View File

@@ -1,13 +0,0 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import date
class InvPartAphisEntitiesDTO(BaseModel):
id: Optional[int] = None
aphis_general_id: Optional[int] = None
consignee_key: Optional[str] = None
broker_key: Optional[str] = None
lpco_auth_party_key: Optional[str] = None
grower_key: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,31 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from datetime import date
from sqlalchemy import (
Integer, String, Date, Numeric, Boolean,
PrimaryKeyConstraint, ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class InvPartAphisEntities(Base, TenantScopedMixin, TimestampMixin):
"""Módulo: entities"""
__tablename__ = "inv_aphis_entities"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_entities_pkey"),
ForeignKeyConstraint(["aphis_general_id"], ["a24.inv_aphis_general.id"]),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
aphis_general_id: Mapped[int] = mapped_column(Integer, nullable=False)
# --- CAMPOS ENTITIES ---
consignee_key: Mapped[Optional[str]] = mapped_column(String(50))
broker_key: Mapped[Optional[str]] = mapped_column(String(50))
lpco_auth_party_key: Mapped[Optional[str]] = mapped_column(String(50))
grower_key: Mapped[Optional[str]] = mapped_column(String(50))
aphis_general: Mapped["InvPartAphisGeneral"] = relationship(back_populates="entities")

View File

@@ -1,64 +0,0 @@
from pydantic import BaseModel, field_validator, ConfigDict
from typing import Optional, List, Any
from datetime import date
from ..inv_aphis_characteristic.dto import InvPartAphisCharacteristicDTO
from ..inv_aphis_stype_pitems.dto import InvPartAphisStypePitemsDTO
from ..inv_aphis_lpcos.dto import InvPartAphisLpcosDTO
from ..inv_aphis_entities.dto import InvPartAphisEntitiesDTO
from ..inv_aphis_containers.dto import InvPartAphisContainersDTO
from ..inv_aphis_routing.dto import InvPartAphisRoutingDTO
# NOTA: Importar primero los sub-dtos para resolver dependencias
class InvPartAphisGeneralDTO(BaseModel):
id: Optional[int] = None
program_code: Optional[str] = None
processing_code: Optional[str] = None
aphis_type: Optional[str] = None
disclaimer: Optional[str] = None
electronic_image: Optional[str] = None
confidential: Optional[str] = None
global_product_id: Optional[str] = None
intended_use_code: Optional[str] = None
intended_use_description: Optional[str] = None
item_type: Optional[str] = None
product_code: Optional[str] = None
product_code_2: Optional[str] = None
product_code_3: Optional[str] = None
scientific_genus_name: Optional[str] = None
scientific_species_name: Optional[str] = None
scientific_sub_species_name: Optional[str] = None
common_name_specific: Optional[str] = None
common_name_general: Optional[str] = None
signed_doc: Optional[str] = None
signed_doc_date: Optional[date] = None
signed_doc_id: Optional[str] = None
invoice_number: Optional[str] = None
quantity_1: Optional[str] = None
quantity_2: Optional[str] = None
quantity_3: Optional[str] = None
inspection: Optional[str] = None
inspection_date: Optional[date] = None
inspection_loc_date: Optional[date] = None
inspection_location: Optional[str] = None
country_production: Optional[str] = None
country_source: Optional[str] = None
# Relaciones
characteristics: List[InvPartAphisCharacteristicDTO] = []
stype_pitems: List[InvPartAphisStypePitemsDTO] = []
lpcos: List[InvPartAphisLpcosDTO] = []
entities: List[InvPartAphisEntitiesDTO] = []
containers: List[InvPartAphisContainersDTO] = []
routing: List[InvPartAphisRoutingDTO] = []
model_config = ConfigDict(from_attributes=True)
@field_validator('signed_doc_date', 'inspection_date', 'inspection_loc_date', mode='before')
@classmethod
def empty_str_to_none(cls, v: Any) -> Any:
if v == "":
return None
return v

View File

@@ -1,72 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from datetime import date
from sqlalchemy import (
Integer, String, Date, Numeric, Boolean,
PrimaryKeyConstraint, ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class InvPartAphisGeneral(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla principal de Aphis vinculada a la parte.
Pestaña: Información general de aphis
"""
__tablename__ = "inv_aphis_general"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_general_pkey"),
ForeignKeyConstraint(
["inv_part_id"], ["a24.inv_partes.id"], name="fk_aphis_general_inv_part"
),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inv_part_id: Mapped[int] = mapped_column(Integer, nullable=False)
# --- 31 CAMPOS INICIALES ---
program_code: Mapped[Optional[str]] = mapped_column(String(10))
processing_code: Mapped[Optional[str]] = mapped_column(String(10))
aphis_type: Mapped[Optional[str]] = mapped_column(String(10))
disclaimer: Mapped[Optional[str]] = mapped_column(String(10))
electronic_image: Mapped[Optional[str]] = mapped_column(String(50))
confidential: Mapped[Optional[str]] = mapped_column(String(1))
global_product_id: Mapped[Optional[str]] = mapped_column(String(100))
intended_use_code: Mapped[Optional[str]] = mapped_column(String(10))
intended_use_description: Mapped[Optional[str]] = mapped_column(String(200))
item_type: Mapped[Optional[str]] = mapped_column(String(20))
product_code: Mapped[Optional[str]] = mapped_column(String(20))
product_code_2: Mapped[Optional[str]] = mapped_column(String(20))
product_code_3: Mapped[Optional[str]] = mapped_column(String(20))
scientific_genus_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_species_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_sub_species_name: Mapped[Optional[str]] = mapped_column(String(100))
common_name_specific: Mapped[Optional[str]] = mapped_column(String(200))
common_name_general: Mapped[Optional[str]] = mapped_column(String(200))
signed_doc: Mapped[Optional[str]] = mapped_column(String(100))
signed_doc_date: Mapped[Optional[date]] = mapped_column(Date)
signed_doc_id: Mapped[Optional[str]] = mapped_column(String(50))
invoice_number: Mapped[Optional[str]] = mapped_column(String(50))
quantity_1: Mapped[Optional[str]] = mapped_column(String(50))
quantity_2: Mapped[Optional[str]] = mapped_column(String(50))
quantity_3: Mapped[Optional[str]] = mapped_column(String(50))
inspection: Mapped[Optional[str]] = mapped_column(String(200))
inspection_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_loc_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_location: Mapped[Optional[str]] = mapped_column(String(200))
country_production: Mapped[Optional[str]] = mapped_column(String(3))
country_source: Mapped[Optional[str]] = mapped_column(String(3))
# Relaciones
inv_part: Mapped["InvPart"] = relationship("InvPart", back_populates="aphis_records")
characteristics: Mapped[List["InvPartAphisCharacteristic"]] = relationship(back_populates="aphis_general", cascade="all, delete-orphan")
stype_pitems: Mapped[List["InvPartAphisStypePitems"]] = relationship(back_populates="aphis_general", cascade="all, delete-orphan")
lpcos: Mapped[List["InvPartAphisLpcos"]] = relationship(back_populates="aphis_general", cascade="all, delete-orphan")
entities: Mapped[List["InvPartAphisEntities"]] = relationship(back_populates="aphis_general", cascade="all, delete-orphan")
containers: Mapped[List["InvPartAphisContainers"]] = relationship(back_populates="aphis_general", cascade="all, delete-orphan")
routing: Mapped[List["InvPartAphisRouting"]] = relationship(back_populates="aphis_general", cascade="all, delete-orphan")

View File

@@ -1,27 +0,0 @@
from pydantic import BaseModel, field_validator, ConfigDict
from typing import Optional, List, Any
from datetime import date
class InvPartAphisLpcosDTO(BaseModel):
id: Optional[int] = None
aphis_general_id: Optional[int] = None
issuer: Optional[str] = None
issuer_loc_qua: Optional[str] = None
issuer_loc: Optional[str] = None
issuer_loc_desc: Optional[str] = None
uom: Optional[str] = None
txn_type: Optional[str] = None
type: Optional[str] = None
number: Optional[str] = None
date_qual: Optional[str] = None
date: Optional[date] = None
qty: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@field_validator('date', mode='before')
@classmethod
def empty_str_to_none(cls, v: Any) -> Any:
if v == "":
return None
return v

View File

@@ -1,38 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from datetime import date
from sqlalchemy import (
Integer, String, Date, Numeric, Boolean,
PrimaryKeyConstraint, ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class InvPartAphisLpcos(Base, TenantScopedMixin, TimestampMixin):
"""Módulo: lpcos"""
__tablename__ = "inv_aphis_lpcos"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_lpcos_pkey"),
ForeignKeyConstraint(["aphis_general_id"], ["a24.inv_aphis_general.id"]),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
aphis_general_id: Mapped[int] = mapped_column(Integer, nullable=False)
# --- CAMPOS LPCO ---
issuer: Mapped[Optional[str]] = mapped_column(String(100))
issuer_loc_qua: Mapped[Optional[str]] = mapped_column(String(50))
issuer_loc: Mapped[Optional[str]] = mapped_column(String(50))
issuer_loc_desc: Mapped[Optional[str]] = mapped_column(String(200))
uom: Mapped[Optional[str]] = mapped_column(String(20))
txn_type: Mapped[Optional[str]] = mapped_column(String(50))
type: Mapped[Optional[str]] = mapped_column(String(50))
number: Mapped[Optional[str]] = mapped_column(String(50))
date_qual: Mapped[Optional[str]] = mapped_column(String(50))
date: Mapped[Optional[date]] = mapped_column(Date)
qty: Mapped[Optional[str]] = mapped_column(String(50))
aphis_general: Mapped["InvPartAphisGeneral"] = relationship(back_populates="lpcos")

View File

@@ -1,12 +0,0 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import date
class InvPartAphisRoutingDTO(BaseModel):
id: Optional[int] = None
aphis_general_id: Optional[int] = None
type: Optional[str] = None
country: Optional[str] = None
name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,30 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from datetime import date
from sqlalchemy import (
Integer, String, Date, Numeric, Boolean,
PrimaryKeyConstraint, ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class InvPartAphisRouting(Base, TenantScopedMixin, TimestampMixin):
"""Módulo: routing"""
__tablename__ = "inv_aphis_routing"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_routing_pkey"),
ForeignKeyConstraint(["aphis_general_id"], ["a24.inv_aphis_general.id"]),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
aphis_general_id: Mapped[int] = mapped_column(Integer, nullable=False)
# --- CAMPOS ROUTING ---
type: Mapped[Optional[str]] = mapped_column(String(50))
country: Mapped[Optional[str]] = mapped_column(String(50))
name: Mapped[Optional[str]] = mapped_column(String(100))
aphis_general: Mapped["InvPartAphisGeneral"] = relationship(back_populates="routing")

View File

@@ -1,23 +0,0 @@
from pydantic import BaseModel, field_validator, ConfigDict
from typing import Optional, List, Any
from datetime import date
class InvPartAphisStypePitemsDTO(BaseModel):
id: Optional[int] = None
aphis_general_id: Optional[int] = None
source_type_code: Optional[str] = None
country_code: Optional[str] = None
geo_location: Optional[str] = None
processing_start: Optional[date] = None
processing_end: Optional[date] = None
processing_type: Optional[str] = None
processing_desc: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@field_validator('processing_start', 'processing_end', mode='before')
@classmethod
def empty_str_to_none(cls, v: Any) -> Any:
if v == "":
return None
return v

View File

@@ -1,34 +0,0 @@
from typing import TYPE_CHECKING, Optional, List
from datetime import date
from sqlalchemy import (
Integer, String, Date, Numeric, Boolean,
PrimaryKeyConstraint, ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class InvPartAphisStypePitems(Base, TenantScopedMixin, TimestampMixin):
"""Módulo: stype_Pitems"""
__tablename__ = "inv_aphis_stype_pitems"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_stype_pitems_pkey"),
ForeignKeyConstraint(["aphis_general_id"], ["a24.inv_aphis_general.id"]),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
aphis_general_id: Mapped[int] = mapped_column(Integer, nullable=False)
# --- CAMPOS STYPE PITEMS ---
source_type_code: Mapped[Optional[str]] = mapped_column(String(50))
country_code: Mapped[Optional[str]] = mapped_column(String(3))
geo_location: Mapped[Optional[str]] = mapped_column(String(100))
processing_start: Mapped[Optional[date]] = mapped_column(Date)
processing_end: Mapped[Optional[date]] = mapped_column(Date)
processing_type: Mapped[Optional[str]] = mapped_column(String(50))
processing_desc: Mapped[Optional[str]] = mapped_column(String(200))
aphis_general: Mapped["InvPartAphisGeneral"] = relationship(back_populates="stype_pitems")

View File

@@ -1,17 +0,0 @@
from .inv_aphis_general.models import InvPartAphisGeneral
from .inv_aphis_characteristic.models import InvPartAphisCharacteristic
from .inv_aphis_stype_pitems.models import InvPartAphisStypePitems
from .inv_aphis_lpcos.models import InvPartAphisLpcos
from .inv_aphis_entities.models import InvPartAphisEntities
from .inv_aphis_containers.models import InvPartAphisContainers
from .inv_aphis_routing.models import InvPartAphisRouting
__all__ = [
"InvPartAphisGeneral",
"InvPartAphisCharacteristic",
"InvPartAphisStypePitems",
"InvPartAphisLpcos",
"InvPartAphisEntities",
"InvPartAphisContainers",
"InvPartAphisRouting"
]

View File

@@ -1,20 +0,0 @@
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.classes.id"], name="fk_sclasses_classes"
),
PrimaryKeyConstraint("id", name="sclases_pk"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
class_id: Mapped[int] = mapped_column(Integer, nullable=False) # Id de clase
stock_um: Mapped[str] = mapped_column(String(5)) # Unidad de medida para existencia
us_tariff_code: Mapped[str] = mapped_column(String(19)) # Fracción americana (USA)

View File

@@ -1,174 +0,0 @@
"""
Modelo ORM para datos específicos de Inventario y Manufactura (S-Partes) - Anexo 24
"""
from typing import TYPE_CHECKING, Optional, List
from decimal import Decimal
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Integer,
Numeric,
PrimaryKeyConstraint,
String,
Boolean,
ForeignKeyConstraint
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
# Importar los modelos Aphis para asegurar su registro en SQLAlchemy
import api.v1.modules.a24.inv.inv_aphis.models as aphis_models
class InvPart(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla inv_partes: Extensión de Anexo 24 para Inventarios (SPartes).
"""
__tablename__ = "inv_partes"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_partes_pkey"),
ForeignKeyConstraint(
["id"], ["a76.parts.id"], name="fk_inv_partes_master"
),
{"schema": "a24", "extend_existing": True},
)
# Relación 1:1 - El ID es el mismo de la tabla maestra
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
# --- 1. ATRIBUTOS PRINCIPALES DE INVENTARIO ---
part_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOPARTE
material_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOMAT
reference_number: Mapped[Optional[str]] = mapped_column(String(70)) # NUMPARTEREF
flex_reference_number: Mapped[Optional[str]] = mapped_column(String(120)) # NUMPARTEREFFLEX
# Conversiones
equivalent_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDEQUIV
conversion_factor: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # FACTORCONV
stock_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UMEXISTENCIA
alternate_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDALTERNA
conversion_uom: Mapped[Optional[str]] = mapped_column(String(9)) # UMCONVERSION
# Valor Agregado
added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORAGREGADO
added_value_type: Mapped[Optional[str]] = mapped_column(String(2)) # TIPOVA
assigned_client: Mapped[Optional[str]] = mapped_column(String(50)) # CLIENTEASIGNADO
supplier_code: Mapped[Optional[str]] = mapped_column(String(8)) # PROVEEDOR
is_textile: Mapped[Optional[str]] = mapped_column(String(2)) # ESTEXTIL
# --- 2. MANUFACTURA Y PELIGROSIDAD ---
bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM / VERSIONBILL
is_repair: Mapped[Optional[str]] = mapped_column(String(3)) # ESREPARACION
is_hazardous: Mapped[Optional[str]] = mapped_column(String(1)) # ESMATPELIGROSO
emergency_number: Mapped[Optional[str]] = mapped_column(String(30)) # NUMEMERGENCIA
danger_class: Mapped[Optional[str]] = mapped_column(String(4)) # CLASEDEPELIGRO
packaging_group: Mapped[Optional[str]] = mapped_column(String(3)) # GRUPOEMBALAJE
# Dimensiones
width: Mapped[Optional[str]] = mapped_column(String(50)) # ANCHURA
thickness: Mapped[Optional[str]] = mapped_column(String(50)) # ESPESOR
specification: Mapped[Optional[str]] = mapped_column(String(50)) # SPEC
# --- 3. COSTOS DETALLADOS Y ADUANA US ---
total_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTOTAL
direct_labor: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TRABAJODIREC
general_expenses: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # GASTOGRALES
total_expenses: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TOTALGASTOS
depreciation: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # DEPRECIACION
tooling: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TOOLING
material_consumed: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MATCONSUMED
profit: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # GANANCIA
# Fracciones Internacionales
us_fraction_alt: Mapped[Optional[str]] = mapped_column(String(13)) # FRACEUA
ca_fraction: Mapped[Optional[str]] = mapped_column(String(13)) # FRACCANADA
ad_valorem_us: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # ADVALOREMAME
# Nafta / USMCA
nafta_result: Mapped[Optional[str]] = mapped_column(String(19)) # RESULTADOCALCULONAFTA
nafta_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # PORCENTAJECALCULONAFTA
# Impuestos Específicos (Derechos de Trámite Admon)
dta: Mapped[Optional[str]] = mapped_column(String(19)) # DTA
dtb: Mapped[Optional[str]] = mapped_column(String(19)) # DTB
dtg: Mapped[Optional[str]] = mapped_column(String(19)) # DTG
# --- CAMPOS ADICIONALES FRONTEND ---
substitute_part: Mapped[Optional[str]] = mapped_column(String(70))
complementary_part: Mapped[Optional[str]] = mapped_column(String(70))
preference_part: Mapped[Optional[str]] = mapped_column(String(70))
use_alternate_quantity: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
un_number: Mapped[Optional[str]] = mapped_column(String(30))
shipping_name: Mapped[Optional[str]] = mapped_column(String(200))
hazard_notes: Mapped[Optional[str]] = mapped_column(String(500))
repair_unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
repair_added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
fraction_9801: Mapped[Optional[str]] = mapped_column(String(10))
immex_type: Mapped[Optional[str]] = mapped_column(String(10))
disable_movements: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
pga_program_code: Mapped[Optional[str]] = mapped_column(String(10))
usmca_fraction: Mapped[Optional[str]] = mapped_column(String(10))
scrap_part_number: Mapped[Optional[str]] = mapped_column(String(70))
waste_part_number: Mapped[Optional[str]] = mapped_column(String(70))
scrap_description_en: Mapped[Optional[str]] = mapped_column(String(500))
scrap_description_es: Mapped[Optional[str]] = mapped_column(String(500))
scrap_export_fraction: Mapped[Optional[str]] = mapped_column(String(10))
scrap_us_fraction: Mapped[Optional[str]] = mapped_column(String(10))
equivalent_uom_2: Mapped[Optional[str]] = mapped_column(String(5))
conversion_factor_2: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
has_auxiliary: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
auxiliary_uom: Mapped[Optional[str]] = mapped_column(String(5))
auxiliary_conversion: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
auxiliary_unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
mex_packing: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
sales_order: Mapped[Optional[str]] = mapped_column(String(50))
use_rule_8: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
sector: Mapped[Optional[str]] = mapped_column(String(150))
origin_country: Mapped[Optional[str]] = mapped_column(String(3), default='MEX')
fraction_type: Mapped[Optional[str]] = mapped_column(String(10))
# --- NUEVOS CAMPOS EXTENSION ---
agency_code_definition: Mapped[Optional[str]] = mapped_column(String(50)) # Fila 7
carta_porte: Mapped[Optional[str]] = mapped_column(String(100)) # Fila 10
client_part_names: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[]) # Fila 5
part_identifiers: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[]) # Fila 9
substitute_parts: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[]) # Pestaña Continuación
aphis_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True, default={}) # Sección Aphis en Continuación
# JSONB para almacenar clientes con restricción de no descarga
non_discharge_clients: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[])
# --- RELACIÓN ---
aphis_records: Mapped[List["InvPartAphisGeneral"]] = relationship(
"InvPartAphisGeneral",
back_populates="inv_part",
cascade="all, delete-orphan"
)
# Usamos string "Part" para evitar problemas de carga
master_info: Mapped["Part"] = relationship("Part", back_populates="inv_data")
@property
def bom_items(self):
"""Exponer los BOM items de la parte maestra para Pydantic"""
if self.master_info:
return self.master_info.bom_items
return []
@property
def countries(self):
"""Exponer los países de la parte maestra para Pydantic"""
if self.master_info:
return self.master_info.inv_countries
return []
def __repr__(self) -> str:
return f"<InvPart(id={self.id}, part_type='{self.part_type}')>"

View File

@@ -1,81 +0,0 @@
"""
DTOs (Data Transfer Objects) para relación entre partes y países - Anexo 24
"""
from typing import Optional
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class PartCountryCreateDTO(BaseModel):
"""DTO para crear una relación entre parte y país"""
part_id: int = Field(..., description="Part ID")
country_code: str = Field(..., max_length=3, description="Country code (ISO 3166-1 alpha-3)")
fraction: Optional[str] = Field(None, max_length=20, description="Tariff fraction")
preference: str = Field(default="GENERAL", description="Trade preference: GENERAL, PROSEC, ALADI, TLCS")
has_certificate: bool = Field(default=False, description="Has certificate of origin")
certificate_number: Optional[str] = Field(None, max_length=50, description="Certificate of origin number")
end_date: Optional[datetime] = Field(None, description="Certificate expiration date")
previous_fractions_7m: bool = Field(default=False, description="Has fractions older than 7 months")
omission_import: bool = Field(default=False, description="Import omission flag")
omission_export: bool = Field(default=False, description="Export omission flag")
import_percentage: Optional[Decimal] = Field(None, description="Import tariff percentage")
export_percentage: Optional[Decimal] = Field(None, description="Export tariff percentage")
sector: Optional[str] = Field(None, max_length=10, description="Sector reference")
class Config:
from_attributes = True
class PartCountryUpdateDTO(BaseModel):
"""DTO para actualizar una relación entre parte y país"""
country_code: Optional[str] = Field(None, max_length=3, description="Country code")
fraction: Optional[str] = Field(None, max_length=20, description="Tariff fraction")
preference: Optional[str] = Field(None, description="Trade preference")
has_certificate: Optional[bool] = Field(None, description="Has certificate of origin")
certificate_number: Optional[str] = Field(None, max_length=50, description="Certificate number")
end_date: Optional[datetime] = Field(None, description="Certificate expiration date")
previous_fractions_7m: Optional[bool] = Field(None, description="Previous fractions flag")
omission_import: Optional[bool] = Field(None, description="Import omission flag")
omission_export: Optional[bool] = Field(None, description="Export omission flag")
import_percentage: Optional[Decimal] = Field(None, description="Import percentage")
export_percentage: Optional[Decimal] = Field(None, description="Export percentage")
sector: Optional[str] = Field(None, max_length=10, description="Sector reference")
class Config:
from_attributes = True
class PartCountryResponseDTO(BaseModel):
"""DTO para responder con datos de relación entre parte y país"""
id: int
part_id: int
country_code: str
fraction: Optional[str] = None
preference: str
has_certificate: bool
certificate_number: Optional[str] = None
end_date: Optional[datetime] = None
previous_fractions_7m: bool
omission_import: bool
omission_export: bool
import_percentage: Optional[Decimal] = None
export_percentage: Optional[Decimal] = None
sector: Optional[str] = None
class Config:
from_attributes = True
class PartCountriesBulkResponseDTO(BaseModel):
"""DTO para responder con lista de relaciones entre partes y países"""
data: list[PartCountryResponseDTO]
total: int
skip: int
limit: int

View File

@@ -1,73 +0,0 @@
"""
Modelo ORM para la relación entre partes y países - Anexo 24
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from decimal import Decimal
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Integer,
PrimaryKeyConstraint,
String,
Boolean,
ForeignKeyConstraint,
DateTime,
Numeric,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
class PartCountry(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla inv_parte_paises: Relación entre partes y países.
Almacena información de países de origen, preferencias, certificados y omisiones.
"""
__tablename__ = "inv_parte_paises"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_parte_paises_pkey"),
ForeignKeyConstraint(
["part_id"], ["a76.parts.id"], name="fk_inv_parte_paises_part"
),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
part_id: Mapped[int] = mapped_column(Integer, nullable=False)
country_code: Mapped[str] = mapped_column(String(3), nullable=False)
# Información de fracciones
fraction: Mapped[Optional[str]] = mapped_column(String(20))
is_origin: Mapped[bool] = mapped_column(Boolean, default=False)
# Preferencia comercial
preference: Mapped[str] = mapped_column(String(15), default="GENERAL") # GENERAL, PROSEC, ALADI, TLCS
# Certificado de origen
has_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
certificate_number: Mapped[Optional[str]] = mapped_column(String(50))
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
# Fracciones anteriores a 7 meses
previous_fractions_7m: Mapped[bool] = mapped_column(Boolean, default=False)
# Omisiones (importación/exportación)
omission_import: Mapped[bool] = mapped_column(Boolean, default=False)
omission_export: Mapped[bool] = mapped_column(Boolean, default=False)
# Porcentajes
import_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2))
export_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2))
# Sector (para referencia)
sector: Mapped[Optional[str]] = mapped_column(String(10))
# Relación
part: Mapped["Part"] = relationship("Part", back_populates="inv_countries")
def __repr__(self) -> str:
return f"<PartCountry(id={self.id}, part={self.part_id}, country='{self.country_code}', preference='{self.preference}')>"

View File

@@ -1,162 +0,0 @@
"""
Rutas para gestión de relaciones entre partes y países - Anexo 24
"""
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 (
PartCountryCreateDTO,
PartCountryResponseDTO,
PartCountryUpdateDTO,
PartCountriesBulkResponseDTO,
)
from .models import PartCountry
from .service import PartCountryService
router = APIRouter(prefix="/part-countries", tags=["part-countries"])
@router.get(
"",
response_model=dict,
summary="Get all part-country relationships",
)
async def get_all_part_countries(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
part_id: int = Query(None),
country_code: str = Query(None),
preference: str = Query(None),
db: Session = Depends(get_core_db),
):
"""Get all part-country relationships with optional filtering and pagination"""
filters = {}
if part_id:
filters["part_id"] = part_id
if country_code:
filters["country_code"] = country_code
if preference:
filters["preference"] = preference
part_countries, total = PartCountryService.get_all(db, skip, limit, filters)
return {
"data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries],
"total": total,
"skip": skip,
"limit": limit,
}
@router.get(
"/{part_country_id}",
response_model=PartCountryResponseDTO,
summary="Get part-country relationship by ID",
)
async def get_part_country(
part_country_id: int,
db: Session = Depends(get_core_db),
):
"""Get a part-country relationship by its ID"""
part_country = PartCountryService.get_by_id(db, part_country_id)
if not part_country:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Part-country relationship not found",
)
return PartCountryResponseDTO.model_validate(part_country)
@router.get(
"/part/{part_id}",
response_model=dict,
summary="Get all countries for a part",
)
async def get_part_countries(
part_id: int,
db: Session = Depends(get_core_db),
):
"""Get all countries associated with a specific part"""
part_countries = PartCountryService.get_by_part_id(db, part_id)
return {
"data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries],
"total": len(part_countries),
}
@router.post(
"",
response_model=PartCountryResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Create part-country relationship",
)
async def create_part_country(
part_country_data: PartCountryCreateDTO,
db: Session = Depends(get_core_db),
):
"""Create a new part-country relationship"""
part_country = PartCountryService.create(db, part_country_data)
return PartCountryResponseDTO.model_validate(part_country)
@router.post(
"/part/{part_id}/bulk",
response_model=dict,
status_code=status.HTTP_201_CREATED,
summary="Bulk create/update part-country relationships",
)
async def bulk_create_part_countries(
part_id: int,
countries_data: List[PartCountryCreateDTO],
db: Session = Depends(get_core_db),
):
"""Create or replace all part-country relationships for a part"""
part_countries = PartCountryService.bulk_create(db, part_id, countries_data)
return {
"data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries],
"total": len(part_countries),
}
@router.put(
"/{part_country_id}",
response_model=PartCountryResponseDTO,
summary="Update part-country relationship",
)
async def update_part_country(
part_country_id: int,
part_country_data: PartCountryUpdateDTO,
db: Session = Depends(get_core_db),
):
"""Update a part-country relationship"""
part_country = PartCountryService.update(db, part_country_id, part_country_data)
if not part_country:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Part-country relationship not found",
)
return PartCountryResponseDTO.model_validate(part_country)
@router.delete(
"/{part_country_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete part-country relationship",
)
async def delete_part_country(
part_country_id: int,
db: Session = Depends(get_core_db),
):
"""Delete a part-country relationship"""
success = PartCountryService.delete(db, part_country_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Part-country relationship not found",
)

View File

@@ -1,186 +0,0 @@
"""
Capa de servicio para lógica de negocio de relación entre partes y países
"""
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 PartCountryCreateDTO, PartCountryResponseDTO, PartCountryUpdateDTO
from .models import PartCountry
logger = logging.getLogger(__name__)
class PartCountryService:
"""Servicio para gestión de relaciones entre partes y países"""
@staticmethod
def get_all(
db: Session,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[PartCountry], int]:
"""Get all part-country relationships with pagination"""
query = db.query(PartCountry)
if filters:
if filters.get("part_id"):
query = query.filter(PartCountry.part_id == filters["part_id"])
if filters.get("country_code"):
query = query.filter(
PartCountry.country_code.ilike(f"%{filters['country_code']}%")
)
if filters.get("preference"):
query = query.filter(PartCountry.preference == filters["preference"])
total = query.count()
part_countries = query.offset(skip).limit(limit).all()
return part_countries, total
@staticmethod
def get_by_id(db: Session, part_country_id: int) -> Optional[PartCountry]:
"""Get part-country relationship by ID"""
return db.query(PartCountry).filter(PartCountry.id == part_country_id).first()
@staticmethod
def get_by_part_id(db: Session, part_id: int) -> List[PartCountry]:
"""Get all countries for a specific part"""
return db.query(PartCountry).filter(PartCountry.part_id == part_id).all()
@staticmethod
def get_by_part_and_country(db: Session, part_id: int, country_code: str) -> Optional[PartCountry]:
"""Get specific part-country relationship"""
return db.query(PartCountry).filter(
PartCountry.part_id == part_id,
PartCountry.country_code == country_code
).first()
@staticmethod
def create(db: Session, part_country_data: PartCountryCreateDTO) -> PartCountry:
"""Create a new part-country relationship"""
try:
db_part_country = PartCountry(
**part_country_data.model_dump(exclude_unset=True)
)
db.add(db_part_country)
db.commit()
db.refresh(db_part_country)
return db_part_country
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating part-country relationship: {str(e)}")
raise HTTPException(
status_code=400,
detail="Relationship already exists or invalid foreign key",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating part-country relationship: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error creating relationship: {str(e)}",
)
@staticmethod
def update(
db: Session, part_country_id: int, part_country_data: PartCountryUpdateDTO
) -> Optional[PartCountry]:
"""Update part-country relationship"""
try:
db_part_country = db.query(PartCountry).filter(
PartCountry.id == part_country_id
).first()
if not db_part_country:
return None
update_data = part_country_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_part_country, field, value)
db.add(db_part_country)
db.commit()
db.refresh(db_part_country)
return db_part_country
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating part-country relationship: {str(e)}")
raise HTTPException(
status_code=400,
detail="Cannot update: constraint violation",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating part-country relationship: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error updating relationship: {str(e)}",
)
@staticmethod
def delete(db: Session, part_country_id: int) -> bool:
"""Delete part-country relationship"""
try:
db_part_country = db.query(PartCountry).filter(
PartCountry.id == part_country_id
).first()
if not db_part_country:
return False
db.delete(db_part_country)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting part-country relationship: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error deleting relationship: {str(e)}",
)
@staticmethod
def bulk_create(db: Session, part_id: int, countries_data: List[PartCountryCreateDTO]) -> List[PartCountry]:
"""Create multiple part-country relationships for a part"""
try:
# Delete existing relationships for this part
db.query(PartCountry).filter(PartCountry.part_id == part_id).delete()
db.commit()
# Create new relationships
db_part_countries = []
for country_data in countries_data:
country_data.part_id = part_id
db_part_country = PartCountry(
**country_data.model_dump(exclude_unset=True)
)
db_part_countries.append(db_part_country)
db.add_all(db_part_countries)
db.commit()
for pc in db_part_countries:
db.refresh(pc)
return db_part_countries
except Exception as e:
db.rollback()
logger.error(f"Error bulk creating part-country relationships: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error creating relationships: {str(e)}",
)

View File

@@ -1,29 +0,0 @@
"""
Router principal del módulo A24 (SCAF - Sistema de Control de Activo Fijo)
"""
from fastapi import APIRouter
# Importar routers de submódulos
from .fa.fa_classes.routes import router as fa_classes_router
from .fa.fa_item_lines.routes import router as fa_item_lines_router
from .inv.part_countries.routes import router as part_countries_router
from .inv.inv_aphis.inv_aphis_catalog.router import router as aphis_catalog_router
# Importar modelo para que SQLAlchemy cree la tabla automáticamente
import api.v1.modules.a24.inv.inv_aphis.inv_aphis_catalog.models # noqa: F401
# Router principal de A24
router = APIRouter()
# Registrar routers de FA (Fixed Assets)
router.include_router(fa_classes_router, prefix="/a24", tags=["a24 / fa / classes"])
router.include_router(
fa_item_lines_router, prefix="/a24", tags=["a24 / fa / item-lines"]
)
# Registrar routers de INV (Inventory)
router.include_router(part_countries_router, prefix="/a24", tags=["a24 / inv / part-countries"])
router.include_router(aphis_catalog_router, prefix="/a24", tags=["a24 / inv / aphis-catalog"])

View File

@@ -1 +0,0 @@
# Module initialization for app_settings

View File

@@ -1,33 +0,0 @@
from typing import Optional
from sqlalchemy import Integer, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from api.v1.common.base_models import BaseTimestampMixin
class AppSetting(Base, BaseTimestampMixin):
"""
Unified configuration table for Anexo 76.
Replaces 14 legacy tables using a hierarchical JSONB override system.
"""
__tablename__ = "app_settings"
__table_args__ = (
UniqueConstraint("tenant_id", "company_id", name="uq_app_settings_tenant_company"),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Hierarchy levels (Nullable to allow Global/Tenant/Company scoping)
tenant_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("core.tenants.id"), nullable=True, index=True
)
company_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("a76.company.id"), nullable=True, index=True
)
# The actual configuration payload
settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default={})
def __repr__(self):
return f"<AppSetting(id={self.id}, tenant={self.tenant_id}, company={self.company_id})>"

View File

@@ -1,73 +0,0 @@
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from typing import Optional, Dict, Any
from core.database import get_core_db
from .service import AppSettingsService
from .schemas import AppSettingRequest, AppSettingResponse
from core.security import get_current_user, validate_access_to_resource
router = APIRouter(prefix="/a76/app-settings", tags=["a76 / app_settings"])
import logging
import traceback
logger = logging.getLogger(__name__)
@router.get("/resolved")
def get_resolved_settings(
tenant_id: int = Query(...),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Returns the final merged configuration for a company.
Merges Global -> Tenant -> Company levels.
"""
try:
# Validar permisos
validate_access_to_resource(db, company_id, current_user, ["settings_general.view"])
return AppSettingsService.get_resolved_settings(db, tenant_id, company_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"RESOLVE ERROR: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upsert")
def upsert_settings(
payload: AppSettingRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Creates or updates an override for a specific level (Global, Tenant, or Company).
"""
try:
# Validar permisos
validate_access_to_resource(db, payload.company_id, current_user, ["settings_general.edit"])
data = payload.settings.model_dump(exclude_unset=True)
return AppSettingsService.upsert_settings(
db,
payload.tenant_id,
payload.company_id,
data
)
except HTTPException:
raise
except Exception as e:
logger.error(f"UPSERT ERROR: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/upsert")
def update_settings(
payload: AppSettingRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Alias for upsert_settings.
"""
return upsert_settings(payload, db, current_user)

File diff suppressed because it is too large Load Diff

View File

@@ -1,153 +0,0 @@
from typing import Optional, List, Dict, Any
from sqlalchemy import or_, and_, select, case, nulls_first
from sqlalchemy.orm import Session
from .models import AppSetting
import logging
logger = logging.getLogger(__name__)
from decimal import Decimal
def convert_decimals(obj: Any) -> Any:
"""
Recursively converts Decimal objects to floats for JSON serialization.
Handles nested structures and None values.
"""
if obj is None:
return None
if isinstance(obj, list):
return [convert_decimals(i) for i in obj]
elif isinstance(obj, dict):
return {k: convert_decimals(v) for k, v in obj.items()}
elif isinstance(obj, Decimal):
return float(obj)
return obj
def deep_merge(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> Dict[str, Any]:
"""
Recursively merges dict2 into dict1.
"""
for key, value in dict2.items():
if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict):
deep_merge(dict1[key], value)
else:
dict1[key] = value
return dict1
class AppSettingsService:
"""
Service to manage hierarchical configuration overrides.
Hierarchy: System (Global) -> Tenant -> Company.
"""
@staticmethod
def get_resolved_settings(db: Session, tenant_id: int, company_id: int) -> Dict[str, Any]:
"""
Retrieves settings from all levels (Global -> Tenant -> Company) and merges them.
Treats 0 as None for hierarchy resolution.
"""
# Normalize 0 to None for context-less lookups
t_id = tenant_id if tenant_id and tenant_id > 0 else None
c_id = company_id if company_id and company_id > 0 else None
stmt = (
select(AppSetting)
.where(
or_(
and_(AppSetting.tenant_id.is_(None), AppSetting.company_id.is_(None)),
and_(AppSetting.tenant_id == t_id, AppSetting.company_id.is_(None)) if t_id else False,
and_(AppSetting.tenant_id == t_id, AppSetting.company_id == c_id) if t_id and c_id else False,
)
)
.order_by(
# Ensure the order is: Global (1) -> Tenant (2) -> Company (3)
case(
(and_(AppSetting.tenant_id.is_(None), AppSetting.company_id.is_(None)), 1),
(and_(AppSetting.tenant_id.is_not(None), AppSetting.company_id.is_(None)), 2),
(and_(AppSetting.tenant_id.is_not(None), AppSetting.company_id.is_not(None)), 3),
else_=4
).asc()
)
)
results = db.execute(stmt).scalars().all()
logger.info(f"RESOLVE: Found {len(results)} rows for Hierarchy")
resolved_settings = {}
for row in results:
level_name = "GLOBAL" if not row.tenant_id else ("TENANT" if not row.company_id else "COMPANY")
# Use safe data logging to avoid crashes
settings_data = row.settings if row.settings else {}
keys = list(settings_data.keys()) if isinstance(settings_data, dict) else "not-a-dict"
logger.info(f"RESOLVE: Merging {level_name} layer with keys: {keys}")
if isinstance(settings_data, dict):
deep_merge(resolved_settings, settings_data)
return resolved_settings
@staticmethod
def upsert_settings(db: Session, tenant_id: Optional[int], company_id: Optional[int], settings: Dict[str, Any]) -> AppSetting:
"""
Inserts or updates settings for a specific level.
Treats 0 as None.
"""
# Normalize IDs: 0 or None means Global context at that level
tenant_id = tenant_id if tenant_id and tenant_id > 0 else None
company_id = company_id if company_id and company_id > 0 else None
level_label = f"level(tenant={tenant_id}, company={company_id})"
logger.info(f"UPSERT Settings START: {level_label}, keys_to_update={list(settings.keys())}")
# Ensure all Decimals are converted to floats before deep merge and save
settings = convert_decimals(settings)
stmt = select(AppSetting).where(
and_(
AppSetting.tenant_id == tenant_id if tenant_id is not None else AppSetting.tenant_id.is_(None),
AppSetting.company_id == company_id if company_id is not None else AppSetting.company_id.is_(None)
)
)
existing = db.execute(stmt).scalar_one_or_none()
if existing:
# Deep merge at the root level (merging categories like ssisgen, ssismex, etc.)
logger.info(f"UPSERT: Updating existing row ID={existing.id}")
# Create a shallow copy of the top-level dict to ensure SQLAlchemy sees a new reference
new_settings = dict(existing.settings) if existing.settings else {}
# Detailed logging of what's changing
for cat, data in settings.items():
old_keys = list(new_settings.get(cat, {}).keys())
new_keys = list(data.keys()) if isinstance(data, dict) else []
logger.info(f"UPSERT: Merging category [{cat}]. Old keys: {old_keys}, New keys to merge/overwrite: {new_keys}")
logger.info(f"UPSERT: Merging {len(settings)} top-level categories into existing row.")
deep_merge(new_settings, settings)
# Second pass of conversion (merged results might still have Decimals if original row had them)
existing.settings = convert_decimals(new_settings)
from sqlalchemy.orm.attributes import flag_modified
flag_modified(existing, "settings")
logger.info(f"UPSERT: Row updated and flagged as modified. Fields in ssisgen root: {list(new_settings.get('ssisgen', {}).keys())[:10]}...")
else:
logger.info(f"UPSERT: Creating NEW row for {level_label}")
existing = AppSetting(
tenant_id=tenant_id,
company_id=company_id,
settings=settings
)
db.add(existing)
try:
db.commit()
db.refresh(existing)
logger.info(f"UPSERT SUCCESS: Row ID={existing.id}, Final Settings Hash Keys={list(existing.settings.keys())}")
except Exception as e:
db.rollback()
logger.error(f"UPSERT FAILED: {str(e)}")
raise e
return existing

View File

@@ -1,242 +0,0 @@
"""
Audit Log Events
"""
import logging
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.invoices.models import InvoiceHeader
from core.database import (
RLS_COMPANY_KEY,
RLS_TENANT_KEY,
rls_company_var,
rls_tenant_var,
)
from .services.service import AuditService
from .utils.serialization import serialize_for_json
from core.context import get_user_context
logger = logging.getLogger(__name__)
def register_audit_listeners(models_to_audit):
"""
Register SQLAlchemy listeners for given models
"""
for model in models_to_audit:
event.listen(model, "after_insert", after_insert_listener)
event.listen(model, "after_update", after_update_listener)
event.listen(model, "after_delete", after_delete_listener)
def _get_current_username():
try:
context = get_user_context()
if context:
# Token usually has 'preferred_username' or 'name' or 'sub'
return (
context.get("preferred_username")
or context.get("email")
or context.get("sub")
or "System"
)
except Exception:
pass
return "System"
def _resolve_audit_company_tenant(session: Session, target) -> tuple:
"""
company_id / tenant_id desde la fila ORM, ContextVars RLS (petición HTTP),
o ``Company.tenant_id`` por ``company_id``.
"""
resolution_source = "target"
company_id = getattr(target, "company_id", None)
if company_id is None and getattr(target, "__tablename__", None) == "company":
company_id = getattr(target, "id", None)
if company_id is not None:
resolution_source = "company_self_id"
if company_id is None:
company_id = session.info.get(RLS_COMPANY_KEY)
if company_id is None:
company_id = rls_company_var.get()
if company_id is not None:
resolution_source = "rls_context"
tenant_id = getattr(target, "tenant_id", None)
if tenant_id is None:
tenant_id = session.info.get(RLS_TENANT_KEY)
if tenant_id is None:
tenant_id = rls_tenant_var.get()
if tenant_id is not None and resolution_source == "target":
resolution_source = "rls_context"
# Common fallback for invoice child tables where invoice_id points to header scope.
if (company_id is None or tenant_id is None) and hasattr(target, "invoice_id"):
invoice_id = getattr(target, "invoice_id", None)
if invoice_id is not None:
invoice_scope = (
session.query(InvoiceHeader.company_id, InvoiceHeader.tenant_id)
.filter(InvoiceHeader.id == invoice_id, InvoiceHeader.deleted_at.is_(None))
.first()
)
if invoice_scope:
if company_id is None:
company_id = invoice_scope[0]
if tenant_id is None:
tenant_id = invoice_scope[1]
resolution_source = "invoice_header_lookup"
if tenant_id is None and company_id is not None:
row = (
session.query(Company.tenant_id)
.filter(Company.id == company_id, Company.deleted_at.is_(None))
.first()
)
if row:
tenant_id = int(row[0])
resolution_source = "company_lookup"
return company_id, tenant_id, resolution_source
def after_insert_listener(mapper, connection, target):
"""
Listener for INSERT operations
"""
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
session = Session(bind=connection)
try:
company_id, tenant_id, resolution_source = _resolve_audit_company_tenant(
session, target
)
if company_id is None or tenant_id is None:
logger.warning(
"Audit skip INSERT table=%s record_id=%s company_id=%s tenant_id=%s source=%s",
table_name,
getattr(target, "id", None),
company_id,
tenant_id,
resolution_source,
)
return
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="CREATE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
logger.warning("Error logging insert for %s: %s", table_name, e)
finally:
session.close()
def after_update_listener(mapper, connection, target):
"""
Listener for UPDATE operations
"""
table_name = target.__tablename__
state = inspect(target)
changes = {}
old_values = {}
new_values = {}
for attr in state.attrs:
hist = attr.history
if hist.has_changes():
changes[attr.key] = hist.added[0] if hist.added else None
old_values[attr.key] = hist.deleted[0] if hist.deleted else None
new_values[attr.key] = hist.added[0] if hist.added else None
if not changes:
return
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
session = Session(bind=connection)
try:
company_id, tenant_id, resolution_source = _resolve_audit_company_tenant(
session, target
)
if company_id is None or tenant_id is None:
logger.warning(
"Audit skip UPDATE table=%s record_id=%s company_id=%s tenant_id=%s source=%s",
table_name,
getattr(target, "id", None),
company_id,
tenant_id,
resolution_source,
)
return
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="UPDATE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
old_values=serialize_for_json(old_values),
new_values=serialize_for_json(new_values),
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
logger.warning("Error logging update for %s: %s", table_name, e)
finally:
session.close()
def after_delete_listener(mapper, connection, target):
"""
Listener for DELETE operations
"""
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
session = Session(bind=connection)
try:
company_id, tenant_id, resolution_source = _resolve_audit_company_tenant(
session, target
)
if company_id is None or tenant_id is None:
logger.warning(
"Audit skip DELETE table=%s record_id=%s company_id=%s tenant_id=%s source=%s",
table_name,
getattr(target, "id", None),
company_id,
tenant_id,
resolution_source,
)
return
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
logger.warning("Error logging delete for %s: %s", table_name, e)
finally:
session.close()

View File

@@ -1,27 +0,0 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from core.security import verify_token
from core.context import set_user_context
class UserContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ")[1]
try:
# verify_token might raise exception if invalid, we catch it to not block request
# but we won't have user context
user_info = await verify_token(token)
set_user_context(user_info)
except Exception:
# Log error or ignore
pass
try:
response = await call_next(request)
except Exception:
# Re-raise the exception to let other middleware and handlers deal with it
raise
return response

View File

@@ -1,51 +0,0 @@
"""
Audit Log Models
"""
from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class AuditLog(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "audit_logs"
__table_args__ = (
Index('idx_audit_username_date', 'username', 'date'),
Index('idx_audit_procedure_date', 'procedure', 'date'),
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
Index('idx_audit_table_record', 'table_name', 'record_id'),
{"schema": "a76"} # Use the a76 schema for audit logs
)
# Primary Key
spec_id = Column(Integer, primary_key=True, autoincrement=True)
# Legacy Display Columns (English names as requested)
reference = Column(String(100), nullable=False, index=True) # Legacy: Referencia
procedure = Column(String(100), nullable=False, index=True) # Legacy: Procedimiento
movement = Column(String(255), nullable=False) # Legacy: Movimiento
username = Column(String(100), nullable=False, index=True) # Legacy: Usuario
date = Column(Date, nullable=False, index=True) # Legacy: Fecha
time = Column(Time, nullable=False) # Legacy: Hora
# Technical Columns
timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries
system = Column(String(50), nullable=False, index=True, default="fixed_asset")
# Traceability
table_name = Column(String(100), nullable=True, index=True)
record_id = Column(String(255), nullable=True, index=True)
operation_type = Column(String(50), nullable=True, index=True) # CREATE, UPDATE, DELETE, LOGIN
# Data Changes
old_values = Column(JSONB, nullable=True)
new_values = Column(JSONB, nullable=True)
changed_fields = Column(ARRAY(String), nullable=True)
# Request Context
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
endpoint = Column(String(500), nullable=True)
request_method = Column(String(10), nullable=True)
session_id = Column(String(50), nullable=True, index=True)
execution_time_ms = Column(Integer, nullable=True)

View File

@@ -1,169 +0,0 @@
# Importar modelos para Audit Log
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.invoices.models import (
InvoiceHeader,
InvoiceSalesDetails,
InvoiceComplianceMx,
InvoiceFinancials,
InvoiceLogistics,
InvoiceCollections,
)
from api.v1.modules.a76.audit_log.events import register_audit_listeners
# Core Modules
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import (
CustomsBroker,
CustomsBrokerVU,
CustomsBrokerPersonnel,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.general_catalogs.company.models import Company
# Reference Data
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.customs_warehouses.models import (
CustomsWarehouse,
)
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import (
PedimentoTransportCatalog,
)
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import (
RegimenPedimento,
)
from api.v1.modules.public.reference_data.states.models import State
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.public.reference_data.license_exceptions.models import LicenseException
from api.v1.modules.public.reference_data.agency_tariff_codes.models import AgencyTariffCode
from api.v1.modules.public.reference_data.identifiers.models import IdentifierCatalog
from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.classification_concepts.models import (
ClassificationConcept,
)
from api.v1.modules.a76.general_catalogs.concepts.models import Concept
from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import (
CustomsBrokerConcept,
)
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import (
DepreciationCatalog,
)
from api.v1.modules.a76.general_catalogs.doda.models import Doda
from api.v1.modules.a76.general_catalogs.electronic_notices.models import (
ElectronicNotice,
)
from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency
from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
from api.v1.modules.a76.general_catalogs.inpc.models import INPC
from api.v1.modules.a76.general_catalogs.legends.models import Legend
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import (
MultiCurrencyType,
)
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.ports.models import Port
from api.v1.modules.a76.general_catalogs.location.models import Location, FaLocationExt
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
from api.v1.modules.a76.general_catalogs.seal.models import Seal
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import (
TariffFraction,
)
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
# Registrar Listeners de Auditoría
def register_audit():
register_audit_listeners(
[
# Core Transactions
Pedimentos,
InvoiceHeader,
InvoiceSalesDetails,
InvoiceComplianceMx,
InvoiceFinancials,
InvoiceLogistics,
InvoiceCollections,
LineItem,
Serie,
# Sidebar Core Modules
ClientProvider,
CustomsBroker,
CustomsBrokerVU,
CustomsBrokerPersonnel,
Part,
Company,
# Transportation Modules
Trailer,
Transporter,
Vehicle,
# Reference Data
Country,
CurrencyType,
CustomsSection,
CustomsWarehouse,
Incoterm,
InvoiceType,
MaterialType,
PaymentMethod,
PedimentoTransportCatalog,
PedimentoCode,
RegimenPedimento,
Sector,
State,
TransportMode,
TransportType,
ValuationMethod,
LicenseException,
AgencyTariffCode,
IdentifierCatalog,
CartaPorte,
UnitOfMeasure,
ExchangeRate,
Identifier,
Class,
ClassificationConcept,
Concept,
CustomsBrokerConcept,
DepreciationCatalog,
Doda,
ElectronicNotice,
Equivalency,
ErrorCatalog,
FDACatalog,
INPC,
Legend,
MultiCurrencyType,
Package,
Port,
Prevalidator,
Seal,
Signature,
TariffFraction,
UnitConversion,
USTariffFraction,
]
)

View File

@@ -1,462 +0,0 @@
"""
Audit Log Router
"""
from datetime import date
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_, desc
from core.database import get_core_db, set_rls_context
from core.security import get_current_user, validate_access_to_resource
from core.storage_s3 import get_object_bytes, list_objects_tree, should_ensure_s3_bucket
from .models import AuditLog
from .schemas import (
AuditFileBreadcrumb,
AuditFileBrowserResponse,
AuditFileFolderItem,
AuditFileObjectItem,
AuditLogDetailResponse,
AuditLogListResponse,
)
from api.v1.modules.a76.general_catalogs.company.models import Company
router = APIRouter()
def _audit_scope_tenant_id(db: Session, company_id: int) -> int:
"""Tenant_id de la fila ``Company`` para filtrar ``audit_logs`` (alineado con lo persistido)."""
row = (
db.query(Company.tenant_id)
.filter(Company.id == company_id, Company.deleted_at.is_(None))
.first()
)
if not row:
raise HTTPException(status_code=404, detail="Company not found")
return int(row[0])
_SEGMENT_LABELS = {
"tenants": "Espacio",
"companies": "Companias",
"users": "Usuarios",
"imports": "Importaciones",
"csv": "Archivos CSV",
"branding": "Logotipos",
"certificates": "Certificados",
"customs_brokers": "Agentes aduanales",
"keys": "Llaves",
"cove": "COVE",
"doda": "DODA",
"system": "Sistema",
"help": "Ayuda",
}
def _normalize_relative_path(raw: Optional[str]) -> str:
if not raw:
return ""
val = raw.strip().strip("/")
if not val:
return ""
if ".." in val or "\\" in val:
raise HTTPException(status_code=400, detail="Invalid path")
parts = [p for p in val.split("/") if p]
for part in parts:
if part in (".", ".."):
raise HTTPException(status_code=400, detail="Invalid path segment")
return "/".join(parts)
def _tenant_prefix(tenant_id: int) -> str:
return f"tenants/{tenant_id}/"
def _relative_from_tenant_prefix(key: str, tenant_prefix: str) -> str:
if not key.startswith(tenant_prefix):
raise HTTPException(status_code=403, detail="Access denied to object key")
return key[len(tenant_prefix) :].strip("/")
def _companies_map(db: Session, tenant_id: int) -> Dict[str, str]:
rows = (
db.query(Company.id, Company.name)
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
.all()
)
out: Dict[str, str] = {}
for company_id, company_name in rows:
if company_id is None:
continue
safe_name = (company_name or "").strip()
out[str(company_id)] = safe_name or "Compania"
return out
def _display_segment(
part: str,
prev_part: Optional[str],
company_names: Dict[str, str],
current_user_id: Optional[str] = None,
current_user_label: Optional[str] = None,
) -> str:
if prev_part == "companies":
return company_names.get(part, "Compania")
if prev_part == "users":
# Para carpetas de usuarios, mostrar un nombre amigable:
# - Si es el propio usuario actual, usar preferred_username/email/nombre.
# - Para otros IDs (UUIDs) mostrar un label genérico.
if current_user_id and part == str(current_user_id):
return (current_user_label or "").strip() or "Usuario"
return "Usuario"
if part in _SEGMENT_LABELS:
return _SEGMENT_LABELS[part]
# Evita exponer IDs puros en UI.
if part.isdigit():
return "Elemento"
return part.replace("_", " ").strip().title() or "Elemento"
def _display_path(
rel_path: str,
company_names: Dict[str, str],
current_user_id: Optional[str] = None,
current_user_label: Optional[str] = None,
) -> str:
if not rel_path:
return "Raiz de archivos"
parts = [p for p in rel_path.split("/") if p]
labels: List[str] = []
prev: Optional[str] = None
for part in parts:
labels.append(
_display_segment(
part,
prev,
company_names,
current_user_id=current_user_id,
current_user_label=current_user_label,
)
)
prev = part
return " / ".join(labels)
def _display_file_name(filename: str) -> str:
stem, dot, ext = filename.rpartition(".")
if not dot:
stem = filename
ext = ""
if stem.isdigit():
return f"Archivo{f'.{ext}' if ext else ''}"
return filename
def _build_breadcrumbs(
rel_path: str,
company_names: Dict[str, str],
current_user_id: Optional[str] = None,
current_user_label: Optional[str] = None,
) -> List[AuditFileBreadcrumb]:
breadcrumbs: List[AuditFileBreadcrumb] = [
AuditFileBreadcrumb(path="", display_name="Raiz de archivos")
]
if not rel_path:
return breadcrumbs
parts = [p for p in rel_path.split("/") if p]
prev: Optional[str] = None
acc: List[str] = []
for part in parts:
acc.append(part)
breadcrumbs.append(
AuditFileBreadcrumb(
path="/".join(acc),
display_name=_display_segment(
part,
prev,
company_names,
current_user_id=current_user_id,
current_user_label=current_user_label,
),
)
)
prev = part
return breadcrumbs
@router.get("/bitacora", response_model=AuditLogListResponse)
async def get_bitacora(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
search: Optional[str] = None,
username: Optional[str] = None,
procedure: Optional[str] = None,
reference: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Bitácora por compañía. Requiere permiso ``audit_logs.view``.
"""
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
query = db.query(AuditLog).filter(
AuditLog.company_id == company_id,
AuditLog.tenant_id == scope_tenant_id,
)
# Filters
if date_from:
query = query.filter(AuditLog.date >= date_from)
if date_to:
query = query.filter(AuditLog.date <= date_to)
if username:
query = query.filter(AuditLog.username.ilike(f"%{username}%"))
if procedure:
# Exact match for dropdown filter usually better, but let's allow partial if manual
# Legacy UI sends exact strings usually
query = query.filter(AuditLog.procedure == procedure)
if reference:
query = query.filter(AuditLog.reference.ilike(f"%{reference}%"))
if search:
# General search across main columns
search_filter = or_(
AuditLog.reference.ilike(f"%{search}%"),
AuditLog.procedure.ilike(f"%{search}%"),
AuditLog.movement.ilike(f"%{search}%"),
AuditLog.username.ilike(f"%{search}%")
)
query = query.filter(search_filter)
total = query.count()
# Sort by ID desc (newest first) -> Legacy usually shows newest first or spec_id desc
logs = query.order_by(desc(AuditLog.spec_id))\
.offset((page - 1) * page_size)\
.limit(page_size)\
.all()
return {
"data": logs,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/bitacora/procedimientos", response_model=List[str])
async def get_procedures(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Lista de procedimientos para filtros (alcance compañía). Requiere ``audit_logs.view``.
"""
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
results = (
db.query(AuditLog.procedure)
.filter(
AuditLog.company_id == company_id,
AuditLog.tenant_id == scope_tenant_id,
)
.distinct()
.order_by(AuditLog.procedure)
.all()
)
return [r[0] for r in results if r[0]]
@router.get("/bitacora/{spec_id}/detalle", response_model=AuditLogDetailResponse)
async def get_audit_detail(
spec_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Detalle de un registro de bitácora. Requiere ``audit_logs.view``.
"""
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
log = (
db.query(AuditLog)
.filter(
AuditLog.spec_id == spec_id,
AuditLog.company_id == company_id,
AuditLog.tenant_id == scope_tenant_id,
)
.first()
)
if not log:
raise HTTPException(status_code=404, detail="Log entry not found")
return log
@router.get("/files", response_model=AuditFileBrowserResponse)
async def list_tenant_files(
company_id: int = Query(..., description="Company ID"),
path: Optional[str] = Query(default="", description="Ruta relativa de navegación."),
continuation_token: Optional[str] = Query(default=None),
max_keys: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Explorador de archivos de solo lectura para Auditoría.
Requiere permiso ``audit_logs.view``; el prefijo S3 sigue al tenant de la compañía.
"""
if not should_ensure_s3_bucket():
raise HTTPException(status_code=400, detail="S3 storage is disabled")
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
tenant_prefix = _tenant_prefix(scope_tenant_id)
rel_path = _normalize_relative_path(path)
list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix
# Datos del usuario actual para etiquetas amigables bajo /users/{id}/...
current_user_id = str(current_user.get("sub") or "")
current_user_label = (
(current_user.get("preferred_username") or "").strip()
or (current_user.get("name") or "").strip()
or (current_user.get("email") or "").strip()
or "Usuario"
)
data = list_objects_tree(
prefix=list_prefix,
delimiter="/",
max_keys=max_keys,
continuation_token=continuation_token,
)
company_names = _companies_map(db, scope_tenant_id)
folders: List[AuditFileFolderItem] = []
for prefix in data.get("prefixes", []):
rel = _relative_from_tenant_prefix(prefix, tenant_prefix)
folders.append(
AuditFileFolderItem(
path=rel,
# Para el gestor de archivos mostramos el nombre amigable del último segmento
# (empresa, usuario actual, etc.), no el ID bruto.
display_name=_display_path(
rel,
company_names,
current_user_id=current_user_id,
current_user_label=current_user_label,
).split(" / ")[-1],
)
)
files: List[AuditFileObjectItem] = []
for obj in data.get("objects", []):
key = obj.get("key")
if not key:
continue
rel = _relative_from_tenant_prefix(key, tenant_prefix)
name = rel.rsplit("/", 1)[-1]
files.append(
AuditFileObjectItem(
path=rel,
display_name=_display_file_name(name),
size=int(obj.get("size", 0) or 0),
last_modified=obj.get("last_modified"),
)
)
return AuditFileBrowserResponse(
current_path=rel_path,
display_path=_display_path(
rel_path,
company_names,
current_user_id=current_user_id,
current_user_label=current_user_label,
),
breadcrumbs=_build_breadcrumbs(
rel_path,
company_names,
current_user_id=current_user_id,
current_user_label=current_user_label,
),
folders=sorted(folders, key=lambda x: x.display_name.lower()),
files=sorted(files, key=lambda x: x.display_name.lower()),
next_token=data.get("next_continuation_token"),
)
@router.get("/files/download")
async def download_tenant_file(
company_id: int = Query(..., description="Company ID"),
path: str = Query(..., description="Ruta relativa del archivo a descargar."),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Descarga segura (backend streaming) de archivos autorizados.
Requiere ``audit_logs.view``.
"""
if not should_ensure_s3_bucket():
raise HTTPException(status_code=400, detail="S3 storage is disabled")
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
tenant_prefix = _tenant_prefix(scope_tenant_id)
rel_path = _normalize_relative_path(path)
if not rel_path or rel_path.endswith("/"):
raise HTTPException(status_code=400, detail="A file path is required")
object_key = f"{tenant_prefix}{rel_path}"
if not object_key.startswith(tenant_prefix):
raise HTTPException(status_code=403, detail="Access denied to object key")
try:
body = get_object_bytes(object_key)
except Exception as e:
raise HTTPException(status_code=404, detail=f"File not found: {e}") from e
filename = rel_path.rsplit("/", 1)[-1]
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return StreamingResponse(
iter([body]),
media_type="application/octet-stream",
headers=headers,
)

View File

@@ -1,78 +0,0 @@
"""
Audit Log Schemas
"""
from typing import Optional, List, Any, Dict
from datetime import date as date_type, time as time_type, datetime
from pydantic import BaseModel, Field
# --- Response Schemas ---
class AuditLogResponse(BaseModel):
"""
Standard response showing the Legacy columns
"""
spec_id: int
reference: str
procedure: str
movement: str
username: str
date: date_type
time: time_type
# Modern extras
timestamp: datetime
system: str
operation_type: Optional[str] = None
table_name: Optional[str] = None
record_id: Optional[str] = None
class Config:
from_attributes = True
class AuditLogDetailResponse(AuditLogResponse):
"""
Detailed response including changed values
"""
old_values: Optional[Dict[str, Any]] = None
new_values: Optional[Dict[str, Any]] = None
changed_fields: Optional[List[str]] = None
ip_address: Optional[str] = None
execution_time_ms: Optional[int] = None
# --- List Response ---
class AuditLogListResponse(BaseModel):
"""
Paginated response
"""
data: List[AuditLogResponse]
total: int
page: int
page_size: int
class AuditFileBreadcrumb(BaseModel):
path: str = Field(default="")
display_name: str
class AuditFileFolderItem(BaseModel):
path: str = Field(description="Ruta relativa interna del archivo, para navegación.")
display_name: str
class AuditFileObjectItem(BaseModel):
path: str = Field(description="Ruta relativa interna del archivo, para descarga.")
display_name: str
size: int
last_modified: Optional[datetime] = None
class AuditFileBrowserResponse(BaseModel):
current_path: str = Field(default="")
display_path: str = Field(default="")
breadcrumbs: List[AuditFileBreadcrumb]
folders: List[AuditFileFolderItem]
files: List[AuditFileObjectItem]
next_token: Optional[str] = None

View File

@@ -1,209 +0,0 @@
"""
Audit Log Core Logic: Reference Generation and Mapping
"""
from typing import Optional, Dict, Any, Tuple
class ReferenceGenerator:
"""
Generates legacy-style references (e.g., FUSE0-040-10)
"""
@staticmethod
def generate_invoice_reference(invoice_data: Dict[str, Any]) -> str:
"""
Format: {SYSTEM}-{CUSTOMS}-{YEAR}
Example: FUSE0-040-10
"""
# Default values
system = "FUSE0"
customs = "000"
year = "00"
# Try to extract system (invoice_type usually holds this key)
if invoice_data.get("invoice_type"):
system = str(invoice_data["invoice_type"])
# Try to extract customs (need to look into nested compliance_mx if available, or just use default)
# Since this receives a dictionary from the mapper, we might not have deep nested relations resolved
# We'll try to do our best with available data
# Try to get year from invoice_date
if invoice_data.get("invoice_date"):
try:
# invoice_date can be a date object or string
d = invoice_data["invoice_date"]
if hasattr(d, "year"):
y = d.year
else:
# Assume string YYYY-MM-DD
y = int(str(d)[:4])
year = str(y)[-2:]
except:
pass
return f"{system}-{customs}-{year}"
@staticmethod
def generate_invoice_item_reference(invoice_ref: str, item_data: Dict[str, Any]) -> str:
"""
Format: {INVOICE_REF}-{ITEM_PART}
Example: FUSE0-040-10-FUS035
"""
part_number = item_data.get("part_number", "ITEM")
return f"{invoice_ref}-{part_number}"
@staticmethod
def generate_pedimento_reference(pedimento_data: Dict[str, Any]) -> str:
"""
Format: {LICENSE}-{CUSTOMS}{YEAR}{NUMBER}
Example: 0756C-040010315
"""
license = str(pedimento_data.get("license", "0000")).strip()
customs = str(pedimento_data.get("customs_office", "000")).zfill(3)
year = str(pedimento_data.get("year", "00")).zfill(2)
number = str(pedimento_data.get("pedimento_number", "0000000")).zfill(7)
return f"{license}-{customs}{year}{number}"
class AuditMapper:
"""
Maps table names and operations to English Procedures and Movements
"""
# Map table names to Legacy Procedures (English)
TABLE_TO_PROCEDURE = {
# Invoices
"invoice_header": "IMPORT INVOICE BROWSE", # BROWSEOFACIMP
"invoice_sales_details": "IMPORT INVOICE UPDATE", # UPDATEOFACIMP
"invoice_compliance_mx": "IMPORT INVOICE UPDATE",
"invoice_financials": "IMPORT INVOICE UPDATE",
"invoice_logistics": "IMPORT INVOICE UPDATE",
"invoice_collections": "IMPORT INVOICE UPDATE",
# Exports would be similar but we start with general
# Pedimentos
"pedimentos": "PEDIMENTO BROWSE", # BROWSEPEDIMEN
# System
"users": "SYSTEM SCAF",
"sessions": "SYSTEM SCAF",
# General fallbacks
"clients_and_providers": "CATALOGS",
"items": "CATALOGS",
"parts": "CATALOGS",
"customs_brokers": "CATALOGS",
"customs_brokers_vu": "CATALOGS",
"customs_brokers_personnel": "CATALOGS",
"classes": "CATALOGS",
"classification_concepts": "CATALOGS",
"concepts": "CATALOGS",
"customs_broker_concepts": "CATALOGS",
"depreciation_catalog": "CATALOGS",
"electronic_notices": "ELECTRONIC NOTICES",
"equivalencies": "CATALOGS",
"error_catalogs": "CATALOGS",
"fda_catalog": "CATALOGS",
"inpc": "CATALOGS",
"legends": "CATALOGS",
"multi_currency_types": "CATALOGS",
"packages": "CATALOGS",
"ports": "CATALOGS",
"prevalidators": "CATALOGS",
"seal": "CATALOGS",
"seals": "CATALOGS",
"signatures": "CATALOGS",
"tariff_fractions": "CATALOGS",
"unit_conversions": "CATALOGS",
"us_tariff_fractions": "CATALOGS",
# DODA
"doda": "DODA",
"doda_containers": "DODA",
"doda_pedimentos": "DODA",
}
# Map (Table, Operation) to Legacy Movements (English)
OPERATION_TO_MOVEMENT = {
("invoice_header", "CREATE"): "ADD IMPORT_INVOICE",
("invoice_header", "UPDATE"): "EDIT IMPORT_INVOICE",
("invoice_header", "DELETE"): "DELETE IMPORT_INVOICE",
("invoice_sales_details", "CREATE"): "ADD IMPORT_INVOICE_ITEM",
("invoice_sales_details", "UPDATE"): "EDIT IMPORT_INVOICE_ITEM",
("invoice_sales_details", "DELETE"): "DELETE IMPORT_INVOICE_ITEM",
("invoice_compliance_mx", "CREATE"): "ADD IMPORT_INVOICE",
("invoice_compliance_mx", "UPDATE"): "EDIT IMPORT_INVOICE",
("invoice_compliance_mx", "DELETE"): "DELETE IMPORT_INVOICE",
("invoice_financials", "CREATE"): "ADD IMPORT_INVOICE",
("invoice_financials", "UPDATE"): "EDIT IMPORT_INVOICE",
("invoice_financials", "DELETE"): "DELETE IMPORT_INVOICE",
("invoice_logistics", "CREATE"): "ADD IMPORT_INVOICE",
("invoice_logistics", "UPDATE"): "EDIT IMPORT_INVOICE",
("invoice_logistics", "DELETE"): "DELETE IMPORT_INVOICE",
("invoice_collections", "CREATE"): "ADD IMPORT_INVOICE",
("invoice_collections", "UPDATE"): "EDIT IMPORT_INVOICE",
("invoice_collections", "DELETE"): "DELETE IMPORT_INVOICE",
("pedimentos", "CREATE"): "ADD PEDIMENTO",
("pedimentos", "UPDATE"): "EDIT PEDIMENTO",
("pedimentos", "DELETE"): "DELETE PEDIMENTO",
("auth", "LOGIN"): "SYSTEM LOGIN",
("auth", "LOGOUT"): "SYSTEM LOGOUT",
("classes", "CREATE"): "ADD CLASS",
("classes", "UPDATE"): "EDIT CLASS",
("classes", "DELETE"): "DELETE CLASS",
("doda", "CREATE"): "ADD DODA",
("doda", "UPDATE"): "EDIT DODA",
("doda", "DELETE"): "DELETE DODA",
("company", "CREATE"): "ADD COMPANY",
("company", "UPDATE"): "EDIT COMPANY",
("company", "DELETE"): "DELETE COMPANY",
("companies", "CREATE"): "ADD COMPANY",
("companies", "UPDATE"): "EDIT COMPANY",
("companies", "DELETE"): "DELETE COMPANY",
}
@staticmethod
def map_to_legacy_format(
table_name: str,
record_id: str,
operation_type: str,
username: str,
system: str = "SCAF"
) -> Dict[str, Any]:
"""
Returns dictionary with keys: reference, procedure, movement, username, system
"""
# Determine Procedure
procedure = AuditMapper.TABLE_TO_PROCEDURE.get(
table_name,
table_name.upper().replace("_", " ") # Fallback
)
# Determine Movement
movement_key = (table_name, operation_type)
movement = AuditMapper.OPERATION_TO_MOVEMENT.get(
movement_key,
f"{operation_type} {table_name.upper()}"
)
# Determine Reference Base
if operation_type in ["LOGIN", "LOGOUT"]:
reference = operation_type
else:
reference = record_id or "NO-REF"
return {
"reference": reference,
"procedure": procedure,
"movement": movement,
"username": username,
"system": system
}

View File

@@ -1,261 +0,0 @@
"""
Audit Log Service
"""
import logging
from datetime import datetime, timedelta, date, time
from decimal import Decimal
import uuid
import pytz
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from ..models import AuditLog
from .core import AuditMapper, ReferenceGenerator
logger = logging.getLogger(__name__)
def _make_json_safe(obj: Any) -> Any:
"""Recursively convert non-JSON-serializable types to serializable equivalents."""
if obj is None:
return None
if isinstance(obj, dict):
return {k: _make_json_safe(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_make_json_safe(v) for v in obj]
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, time):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, bytes):
return obj.decode("utf-8", errors="replace")
if hasattr(obj, "__dict__"):
d = dict(obj.__dict__)
d.pop("_sa_instance_state", None)
return {k: _make_json_safe(v) for k, v in d.items()}
if not isinstance(obj, (int, float, str, bool)):
return str(obj)
return obj
class AuditService:
@staticmethod
def create_audit_log(
db: Session,
reference: str,
procedure: str,
movement: str,
username: str,
system: str = "SCAF",
# Extra context
table_name: Optional[str] = None,
record_id: Optional[str] = None,
operation_type: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
changed_fields: Optional[List[str]] = None,
# HTTP Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
endpoint: Optional[str] = None,
request_method: Optional[str] = None,
session_id: Optional[str] = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
) -> AuditLog:
"""
Low-level creation of an Audit Log entry
"""
# Timezone handling: Use UTC for consistency across regions.
# Frontend will convert to user's local time.
now = datetime.now(pytz.UTC)
log = AuditLog(
reference=reference,
procedure=procedure,
movement=movement,
username=username,
date=now.date(),
time=now.time(),
timestamp=now,
system=system,
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=_make_json_safe(old_values),
new_values=_make_json_safe(new_values),
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
endpoint=endpoint,
request_method=request_method,
session_id=session_id,
company_id=company_id,
tenant_id=tenant_id
)
db.add(log)
db.flush()
return log
@staticmethod
def log_crud_operation(
db: Session,
table_name: str,
operation_type: str,
record_data: Dict[str, Any],
username: str,
record_id: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
# Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None
):
"""
High-level wrapper to log CRUD operations automatically mapping to Legacy format
"""
# 1. Map to Legacy Base Format
legacy_data = AuditMapper.map_to_legacy_format(
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
username=username
)
# 2. Refine Reference based on specific table logic
reference = legacy_data["reference"]
if table_name == "invoice_header":
generated_ref = ReferenceGenerator.generate_invoice_reference(record_data)
# Use generated ref only if meaningful, else keep default
if generated_ref != "FUSE0-000-00":
reference = generated_ref
elif table_name == "pedimentos":
reference = ReferenceGenerator.generate_pedimento_reference(record_data)
elif table_name == "clients_and_providers":
reference = record_data.get("rfc") or reference
elif table_name == "parts":
reference = record_data.get("part_number") or reference
elif table_name in {"companies", "company"}:
reference = record_data.get("rfc") or reference
elif table_name == "classes":
reference = record_data.get("class_code") or reference
# 3. Detect Changed Fields (for Update)
changed_fields = None
if operation_type == "UPDATE" and old_values and new_values:
changed_fields = [
k for k in new_values.keys()
if old_values.get(k) != new_values.get(k)
]
# 4. Create Log
return AuditService.create_audit_log(
db=db,
reference=reference,
procedure=legacy_data["procedure"],
movement=legacy_data["movement"],
username=username,
system=legacy_data["system"],
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=old_values,
new_values=new_values,
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id,
tenant_id=tenant_id
)
@staticmethod
def log_login(
db: Session,
username: str,
ip_address: str = None,
user_agent: str = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
):
"""
``audit_logs`` exige ``tenant_id`` y ``company_id``. El login vía Hub no
define compañía activa; sin ambos argumentos no se inserta fila (antes fallaba NOT NULL).
"""
if company_id is None or tenant_id is None:
return None
try:
now = datetime.now(pytz.UTC)
five_seconds_ago = now - timedelta(seconds=5)
existing = db.query(AuditLog).filter(
AuditLog.username == username,
AuditLog.operation_type == "LOGIN",
AuditLog.company_id == company_id,
AuditLog.tenant_id == tenant_id,
AuditLog.timestamp >= five_seconds_ago,
).first()
if existing:
return existing
except Exception as e:
logger.warning("Login audit debounce query failed: %s", e)
return AuditService.create_audit_log(
db=db,
reference="LOGIN",
procedure="SYSTEM SCAF",
movement="SYSTEM LOGIN",
username=username,
operation_type="LOGIN",
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id,
tenant_id=tenant_id,
)
@staticmethod
def log_logout(
db: Session,
username: str,
ip_address: str = None,
user_agent: str = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
):
"""Misma condición que ``log_login``: sin alcance compañía/tenant no se escribe."""
if company_id is None or tenant_id is None:
return None
try:
AuditService.create_audit_log(
db=db,
reference="LOGOUT",
procedure="SYSTEM AUTH",
movement="SYSTEM LOGOUT",
username=username,
operation_type="LOGOUT",
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
logger.warning("Logout audit insert failed: %s", e)

View File

@@ -1,7 +0,0 @@
"""
Audit Log Utilities
"""
from .serialization import serialize_for_json
__all__ = ["serialize_for_json"]

View File

@@ -1,50 +0,0 @@
"""
Serialization utilities for audit logs
"""
from datetime import date, datetime, time
from decimal import Decimal
from uuid import UUID
from typing import Any, Dict
def serialize_value(value: Any) -> Any:
"""
Convert a Python value to a JSON-serializable type
"""
if value is None:
return None
elif isinstance(value, (date, datetime)):
return value.isoformat()
elif isinstance(value, time):
return value.isoformat()
elif isinstance(value, Decimal):
return float(value)
elif isinstance(value, UUID):
return str(value)
elif isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
elif isinstance(value, (list, tuple)):
return [serialize_value(item) for item in value]
elif isinstance(value, dict):
return {key: serialize_value(val) for key, val in value.items()}
else:
if hasattr(value, "__dict__"):
d = dict(value.__dict__)
d.pop("_sa_instance_state", None)
return {k: serialize_value(v) for k, v in d.items()}
if not isinstance(value, (int, float, str, bool)):
return str(value)
return value
def serialize_for_json(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Recursively serialize a dictionary for JSON storage
"""
if not data:
return data
return {key: serialize_value(value) for key, value in data.items()}

View File

@@ -1,7 +0,0 @@
"""
Módulo de importación CSV para BOMs (Bills of Materials).
"""
from .routes import router
__all__ = ["router"]

View File

@@ -1,13 +0,0 @@
"""
Endpoints para importación CSV de BOMs.
Mismo patrón que parts y classes: upload → scan → status → commit.
"""
from fastapi import APIRouter
from api.v1.modules.a76.layouts_csv.boms.routes import router as imports_router
router = APIRouter()
# CSV import (upload → scan → status → commit)
router.include_router(imports_router, prefix="/imports", tags=["a76 / boms / csv_import"])

View File

@@ -1,21 +0,0 @@
"""
Módulo de Class
"""
from typing import Any
__all__ = ["router"]
def __getattr__(name: str) -> Any:
"""
Lazy export to avoid circular imports during Celery init.
This package is imported when models are loaded (e.g. a76.items.models),
so importing FastAPI routes at import-time can break Celery startup.
"""
if name == "router":
from .routes import router
return router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -1,276 +0,0 @@
"""
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 decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class ClassCreateDTO(BaseModel):
"""DTO para crear una clase"""
class_code: str = Field(..., max_length=8, description="Class code")
description_es: str = Field(
..., max_length=500, description="Description in Spanish (required)"
)
description_en: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
material_key: str = Field(
...,
max_length=10,
description="Material key - Fixed Asset Type (required)",
)
unit_of_measure: str = Field(
..., max_length=5, description="Unit of measure - U.M. comercial (required)"
)
stock_unit_of_measure: Optional[str] = Field(
None,
max_length=5,
description="UM Existencia - solo SCAII/inventory (UMEXISTENCIA)",
)
fraction: str = Field(
..., max_length=20, description="Mexican tariff fraction (required)"
)
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"
)
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
system: str = Field(
default="fixed_asset", max_length=12,
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
)
class Config:
from_attributes = True
class ClassCreateDTOFA(ClassCreateDTO):
"""DTO para crear una clase de activo fijo (clase base + extensión FA)"""
# Campos específicos de activos fijos (a24.fa_classes)
import_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de importación"
)
import_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de importación"
)
export_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de exportación"
)
export_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de exportación"
)
depreciation_rate: Optional[Decimal] = Field(
None, ge=0, le=100, description="Tasa de depreciación anual (%)"
)
fda_code: Optional[str] = Field(
None, max_length=20, description="Código FDA"
)
eccn_code: Optional[str] = Field(
None, max_length=20, description="Código ECCN"
)
class_enabled: Optional[bool] = Field(
True, description="Indica si la clase está habilitada"
)
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
class Config:
from_attributes = True
class ClassUpdateDTO(BaseModel):
"""DTO para actualizar una clase"""
class_code: Optional[str] = Field(
None, 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)"
)
stock_unit_of_measure: Optional[str] = Field(
None,
max_length=5,
description="UM Existencia - solo SCAII/inventory (UMEXISTENCIA)",
)
fraction: Optional[str] = Field(
None, max_length=20, 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"
)
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
system: Optional[str] = Field(
None, max_length=12,
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
)
model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields
class ClassResponseDTO(BaseModel):
"""DTO para respuesta de clase"""
id: int
tenant_id: int
company_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
stock_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
is_active: Optional[bool] = None
system: str = "fixed_asset"
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ClassResponseDTOFA(ClassResponseDTO):
"""DTO para respuesta de clase de activo fijo (incluye campos FA)"""
# Campos de a24.fa_classes
fa_id: Optional[int] = None
import_tariff_code: Optional[str] = None
import_tariff_type: Optional[str] = None
export_tariff_code: Optional[str] = None
export_tariff_type: Optional[str] = None
depreciation_rate: Optional[Decimal] = None
fda_code: Optional[str] = None
eccn_code: Optional[str] = None
class_enabled: Optional[bool] = None
model_config = ConfigDict(from_attributes=True)
class ClassBasicDTO(BaseModel):
"""DTO para información básica de clase"""
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"""
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
class ClassWithFADataResponse(BaseModel):
"""DTO para respuesta de clase con datos FA embebidos (para fixed-asset-classes)"""
# Base class fields
id: int
tenant_id: int
company_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
stock_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
system: str = "fixed_asset"
created_at: datetime
updated_at: datetime
# FA-specific fields (embedded from a24.fa_classes)
fa_class_id: Optional[int] = None
import_tariff_code: Optional[str] = None
import_tariff_type: Optional[str] = None
export_tariff_code: Optional[str] = None
export_tariff_type: Optional[str] = None
depreciation_rate: Optional[Decimal] = None
fda_code: Optional[str] = None
eccn_code: Optional[str] = None
class_enabled: Optional[bool] = None
model_config = ConfigDict(from_attributes=True)
class ClassWithFADataPaginatedResponse(BaseModel):
"""Lista paginada de clases con datos FA (fixed-asset-classes)"""
items: list[ClassWithFADataResponse]
total: int
page: int
page_size: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,124 +0,0 @@
"""
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
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
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
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(
["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"],
),
ForeignKeyConstraint(
["stock_unit_of_measure", "tenant_id", "company_id"],
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"],
name="fk_classes_stock_uom",
),
UniqueConstraint(
"tenant_id",
"company_id",
"class_code",
name="uq_classes_tenant_company_code",
),
{"schema": "a76", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# 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
stock_unit_of_measure: Mapped[Optional[str]] = mapped_column(
String(5)
) # UMEXISTENCIA - UM Existencia (SCAII/inventory)
# Tariff fractions
fraction: Mapped[Optional[str]] = mapped_column(String(20)) # 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
is_active: Mapped[bool] = mapped_column(
default=True, server_default="true", nullable=False
) # Campo para habilitar/deshabilitar clases sin eliminarlas
# Sistema al que pertenece la clase: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset")
# Relationships
material_type: Mapped[Optional["MaterialType"]] = relationship(
foreign_keys=[material_key]
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[unit_of_measure]
)
stock_unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[stock_unit_of_measure],
viewonly=True,
)
# Inverse relationship with GParts that have this class
parts: Mapped[list["Part"]] = relationship(
primaryjoin="and_(Class.class_code == Part.part_class)",
foreign_keys="[Part.part_class]",
viewonly=True,
back_populates="part_class_info",
)
def __repr__(self) -> str:
return f"<Class(class_code='{self.class_code}', description='{self.description_es}')>"

View File

@@ -1,151 +0,0 @@
"""
Endpoints API para gestión de clases SCAII y SCAF
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, get_active_system
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from .dto import (
ClassCreateDTO,
ClassCreateDTOFA,
ClassResponseDTO,
ClassResponseDTOFA,
ClassUpdateDTO,
ClassWithFADataPaginatedResponse,
)
from .service import ClassService
from api.v1.modules.a76.layouts_csv.classes.routes import router as imports_router
# Create a new router for custom endpoints
router = APIRouter()
# CSV import (upload → scan → status → commit)
router.include_router(imports_router, prefix="/imports", tags=["a76 / classes / csv_import"])
# Add consolidated catalog endpoints FIRST (before generic CRUD routes)
# This ensures they have priority over the generic /{id} route
@router.get(
"/with-fa-data",
response_model=ClassWithFADataPaginatedResponse,
summary="Get Classes with FA Data",
description="Clases con datos FA en una sola consulta (JOIN); respuesta paginada",
tags=["a76 / classes"],
)
async def get_classes_with_fa_data(
request: Request,
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=1000, description="Page size"),
sort_by: Optional[str] = Query(None, description="Column to sort by"),
sort_order: Optional[str] = Query("asc", description="Sort order (asc/desc)"),
class_code: Optional[str] = Query(None, description="Filter by class code (contains)"),
description: Optional[str] = Query(None, description="Filter by ES/EN description (contains)"),
material_key: Optional[str] = Query(None, description="Filter by material key (contains)"),
fraction: Optional[str] = Query(None, description="Filter by tariff fraction (contains)"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Get all classes with their FA data using a single LEFT JOIN query.
This endpoint is optimized for the fixed-asset-classes view.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.view"])
skip = (page - 1) * page_size
filters: Dict[str, Any] = {}
if class_code:
filters["class_code"] = class_code
if description:
filters["description"] = description
if material_key:
filters["material_key"] = material_key
if fraction:
filters["fraction"] = fraction
# Sistema activo desde header/cookie (TenantMiddleware no setea request.state en /api/)
active_system = get_active_system(request)
if active_system:
filters["system"] = active_system
classes_with_fa, total = ClassService.get_all_with_fa_data(
db=db,
tenant_id=tenant_id,
company_id=company_id,
skip=skip,
limit=page_size,
filters=filters if filters else None,
sort_by=sort_by,
sort_order=sort_order,
)
return ClassWithFADataPaginatedResponse(
items=classes_with_fa,
total=total,
page=page,
page_size=page_size,
)
@router.post(
"/fa",
response_model=ClassResponseDTOFA,
status_code=201,
summary="Create Fixed Asset Class",
description="Create a class with FA extension in a single transaction",
tags=["a76 / classes"],
)
async def create_fa_class(
request: Request,
class_data: ClassCreateDTOFA,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Create a fixed asset class (both base class and FA extension)"""
active_system = get_active_system(request)
if active_system == "inventory":
raise HTTPException(
status_code=403,
detail=(
"Solo se pueden crear clases de activo fijo con el módulo de Activo Fijo (SCAF) activo. "
"Cambie de aplicación e intente de nuevo."
),
)
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.create"])
# Siempre persistir como activo fijo; no depender del default del DTO ni del body del cliente
class_data = class_data.model_copy(update={"system": "fixed_asset"})
result = ClassService.create_fa_class(db, class_data, tenant_id, company_id)
return result
# Now include generic CRUD routes
# These will be registered AFTER the custom endpoints above
crud_router = TenantCRUDRoutes(
service=ClassService,
create_schema=ClassCreateDTO,
update_schema=ClassUpdateDTO,
response_schema=ClassResponseDTO,
prefix="", # No prefix here, will be added in main router
tags=["a76 / classes"],
resource_name="Class",
id_name="id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=1000,
list_permissions=["goods_classes.view"],
create_permissions=["goods_classes.create"],
update_permissions=["goods_classes.edit"],
delete_permissions=["goods_classes.delete"],
).router
# Include the CRUD routes into our main router
router.include_router(crud_router)

View File

@@ -1,873 +0,0 @@
"""
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,
ClassCreateDTOFA,
ClassListDTO,
ClassResponseDTO,
ClassResponseDTOFA,
ClassSearchDTO,
ClassUpdateDTO,
)
from .models import Class
logger = logging.getLogger(__name__)
def _validate_stock_uom_conversion(
db: Session,
tenant_id: int,
company_id: int,
system: str,
stock_unit_of_measure: Optional[str],
unit_of_measure: Optional[str],
) -> None:
"""SCAII: si UM Existencia ≠ UM Comercial, debe existir conversión en catálogo."""
if system != "inventory":
return
stock_um = (stock_unit_of_measure or "").strip().upper()
commercial_um = (unit_of_measure or "").strip().upper()
if not stock_um or not commercial_um or stock_um == commercial_um:
return
from api.v1.modules.a76.general_catalogs.unit_conversions.models import (
UnitConversion,
)
conversion_exists = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
or_(
(UnitConversion.from_unit_code == stock_um)
& (UnitConversion.to_unit_code == commercial_um),
(UnitConversion.from_unit_code == commercial_um)
& (UnitConversion.to_unit_code == stock_um),
),
)
.first()
)
if not conversion_exists:
raise HTTPException(
status_code=422,
detail=(
f"No existe un factor de conversión para '{stock_um}''{commercial_um}'. "
"Configúrelo primero en el catálogo de Conversiones de UM."
),
)
class ClassService:
"""Servicio para gestión de clases SCAII y SCAF"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "asc",
) -> tuple[List[Class], int]:
"""
Get all classes for a tenant with pagination and filters
"""
query = db.query(Class).filter(Class.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(Class.company_id == company_id)
if filters:
# Búsqueda libre: OR en clave y descripciones (selectores / listados)
search_raw = filters.get("search") or filters.get("q")
if search_raw and str(search_raw).strip():
pattern = f"%{str(search_raw).strip()}%"
query = query.filter(
or_(
Class.class_code.ilike(pattern),
Class.description_es.ilike(pattern),
Class.description_en.ilike(pattern),
)
)
else:
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"]
)
if filters.get("system"):
query = query.filter(Class.system == filters["system"])
# Apply sorting
if sort_by:
column = getattr(Class, sort_by, None)
if column:
if sort_order == "desc":
query = query.order_by(column.desc())
else:
query = query.order_by(column.asc())
else:
# Default sorting
query = query.order_by(Class.class_code.asc())
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_all_with_fa_data(
db: Session,
tenant_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 1000,
filters: Optional[Dict[str, Any]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "asc",
) -> tuple[List[Dict[str, Any]], int]:
"""
Get all classes with their FA data in a single query using LEFT JOIN.
This eliminates the N+1 query problem.
Returns a list of dicts with combined base class + FA data.
"""
from api.v1.modules.a24.fa.fa_classes.models import QClasses
# Build query with LEFT JOIN
query = (
db.query(Class, QClasses)
.outerjoin(QClasses, and_(
Class.id == QClasses.class_id,
QClasses.tenant_id == tenant_id
))
.filter(Class.tenant_id == tenant_id)
)
if company_id is not None:
query = query.filter(Class.company_id == company_id)
# Apply filters if provided
if filters:
if filters.get("q"):
search = f"%{filters['q']}%"
query = query.filter(
or_(
Class.class_code.ilike(search),
Class.description_es.ilike(search),
Class.description_en.ilike(search)
)
)
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("system"):
query = query.filter(Class.system == filters["system"])
# Apply sorting
if sort_by:
# Check if sort_by belongs to Class or QClasses
column = getattr(Class, sort_by, None)
if column:
if sort_order == "desc":
query = query.order_by(column.desc())
else:
query = query.order_by(column.asc())
else:
# Handle FA extension fields if sort_by is one of them
# (Simple approach for now, assuming base class fields are prioritized)
pass
else:
# Default sorting
query = query.order_by(Class.class_code.asc())
# Count total before pagination
total = query.count()
# Apply pagination
results = query.offset(skip).limit(limit).all()
# Combine base class + FA data into dicts
combined = []
for base_class, fa_class in results:
class_dict = {
# Base class fields
"id": base_class.id,
"tenant_id": base_class.tenant_id,
"company_id": base_class.company_id,
"class_code": base_class.class_code,
"description_es": base_class.description_es,
"description_en": base_class.description_en,
"material_key": base_class.material_key,
"unit_of_measure": base_class.unit_of_measure,
"stock_unit_of_measure": base_class.stock_unit_of_measure,
"fraction": base_class.fraction,
"us_fraction": base_class.us_fraction,
"sub_key": base_class.sub_key,
"physical_review": base_class.physical_review,
"iva_exempt_fraction": base_class.iva_exempt_fraction,
"system": base_class.system,
"created_at": base_class.created_at,
"updated_at": base_class.updated_at,
# FA extension fields (None if no FA record exists)
"fa_class_id": fa_class.id if fa_class else None,
"import_tariff_code": fa_class.import_tariff_code if fa_class else None,
"import_tariff_type": fa_class.import_tariff_type if fa_class else None,
"export_tariff_code": fa_class.export_tariff_code if fa_class else None,
"export_tariff_type": fa_class.export_tariff_type if fa_class else None,
"depreciation_rate": fa_class.depreciation_rate if fa_class else None,
"fda_code": fa_class.fda_code if fa_class else None,
"eccn_code": fa_class.eccn_code if fa_class else None,
"class_enabled": fa_class.class_enabled if fa_class else None,
}
combined.append(class_dict)
return combined, 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.class_code == data_dict["class_code"]
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f" El código de clase '{data_dict['class_code']}' ya existe. Por favor use un código diferente."
)
# Validate material_key exists (now required)
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:
raise HTTPException(
status_code=400,
detail=f"Material type '{data_dict['material_key']}' does not exist"
)
_validate_stock_uom_conversion(
db,
tenant_id,
company_id,
data_dict.get("system", "fixed_asset"),
data_dict.get("stock_unit_of_measure"),
data_dict.get("unit_of_measure"),
)
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:
logger.warning(f"Class {class_id} not found for tenant {tenant_id}, company {company_id}")
return None
update_data = class_data.model_dump(exclude_unset=True)
effective_system = update_data.get("system", class_obj.system)
effective_stock_um = update_data.get(
"stock_unit_of_measure", class_obj.stock_unit_of_measure
)
effective_commercial_um = update_data.get(
"unit_of_measure", class_obj.unit_of_measure
)
_validate_stock_uom_conversion(
db,
tenant_id,
company_id,
effective_system,
effective_stock_um,
effective_commercial_um,
)
# 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
# Validate class_code is unique if being changed
if "class_code" in update_data and update_data["class_code"]:
new_code = update_data["class_code"]
# Check if another class with this code exists (excluding current class)
# The unique constraint is on (tenant_id, company_id, class_code)
existing_class = db.query(Class).filter(
Class.class_code == new_code,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
Class.id != class_id # Exclude current class
).first()
if existing_class:
logger.warning(f"Duplicate class_code found: {existing_class.id}")
raise HTTPException(
status_code=400,
detail=f"El código '{new_code}' ya está en uso para este cliente. Por favor ingrese un código diferente."
)
for field, value in update_data.items():
setattr(class_obj, field, value)
try:
db.commit()
db.refresh(class_obj)
return class_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig)
logger.error(f"IntegrityError updating class {class_id}: {error_msg}")
# Check if it's a duplicate class_code error
if "already exists" in error_msg.lower() or "duplicate" in error_msg.lower():
# Extract the code from update_data if it was changed
code = update_data.get("class_code", class_obj.class_code)
raise HTTPException(
status_code=400,
detail=f"El código '{code}' ya está en uso. Por favor ingrese un código diferente."
)
raise HTTPException(
status_code=400,
detail=f"Error al actualizar la clase: {error_msg}"
)
except Exception as e:
db.rollback()
logger.error(f"Unexpected error updating class {class_id}: {type(e).__name__}: {str(e)}")
raise
@staticmethod
def delete(db: Session, class_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete a class (and its FA extension if exists)"""
from api.v1.modules.a24.fa.fa_classes.models import QClasses
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.parts.models import Part
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
if not class_obj:
return False
used_in_parts = (
db.query(Part.id)
.filter(
Part.part_class == class_obj.class_code,
Part.tenant_id == tenant_id,
Part.company_id == company_id,
)
.first()
is not None
)
used_in_invoices = (
db.query(LineItem.id)
.filter(
LineItem.class_id == class_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
is not None
)
if used_in_parts or used_in_invoices:
reasons: list[str] = []
if used_in_parts:
reasons.append("partes")
if used_in_invoices:
reasons.append("facturas")
usage = " y ".join(reasons)
raise HTTPException(
status_code=409,
detail=f"No se puede eliminar la clase porque ya fue utilizada en {usage}.",
)
# Delete FA extension first (if exists) to avoid FK constraint violation
fa_extension = db.query(QClasses).filter(
QClasses.class_id == class_id,
QClasses.tenant_id == tenant_id
).first()
if fa_extension:
db.delete(fa_extension)
# Now delete the base class
db.delete(class_obj)
db.commit()
return True
@staticmethod
def create_fa_class(
db: Session, class_data: ClassCreateDTOFA, tenant_id: int, company_id: int
) -> Dict[str, Any]:
"""
Create a fixed asset class (both a76.classes and a24.fa_classes)
Returns a dict with both records combined
"""
from api.v1.modules.a24.fa.fa_classes.models import QClasses
# Extract base class fields
base_fields = {
"class_code", "description_es", "description_en",
"material_key", "unit_of_measure", "stock_unit_of_measure",
"fraction", "us_fraction",
"sub_key", "physical_review", "iva_exempt_fraction", "system"
}
base_data = {k: v for k, v in class_data.model_dump().items() if k in base_fields}
# Extract FA-specific fields
fa_fields = {
"import_tariff_code", "import_tariff_type", "export_tariff_code",
"export_tariff_type", "depreciation_rate", "fda_code", "eccn_code",
"class_enabled"
}
fa_data = {k: v for k, v in class_data.model_dump().items() if k in fa_fields}
try:
# 1. Create base class
base_dto = ClassCreateDTO(**base_data)
base_class = ClassService.create(db, base_dto, tenant_id, company_id)
# 2. Create FA extension
fa_obj = QClasses(**fa_data)
fa_obj.class_id = base_class.id
fa_obj.tenant_id = tenant_id
fa_obj.company_id = company_id
db.add(fa_obj)
db.commit()
db.refresh(fa_obj)
# 3. Combine response - build dict manually to avoid SQLAlchemy internals
combined_response = {
# Base class fields
"id": base_class.id,
"tenant_id": base_class.tenant_id,
"company_id": base_class.company_id,
"class_code": base_class.class_code,
"description_es": base_class.description_es,
"description_en": base_class.description_en,
"material_key": base_class.material_key,
"unit_of_measure": base_class.unit_of_measure,
"stock_unit_of_measure": base_class.stock_unit_of_measure,
"fraction": base_class.fraction,
"us_fraction": base_class.us_fraction,
"sub_key": base_class.sub_key,
"physical_review": base_class.physical_review,
"iva_exempt_fraction": base_class.iva_exempt_fraction,
"system": base_class.system,
"created_at": base_class.created_at,
"updated_at": base_class.updated_at,
# FA extension fields
"fa_id": fa_obj.id,
"import_tariff_code": fa_obj.import_tariff_code,
"import_tariff_type": fa_obj.import_tariff_type,
"export_tariff_code": fa_obj.export_tariff_code,
"export_tariff_type": fa_obj.export_tariff_type,
"depreciation_rate": fa_obj.depreciation_rate,
"fda_code": fa_obj.fda_code,
"eccn_code": fa_obj.eccn_code,
"class_enabled": fa_obj.class_enabled,
}
return combined_response
except Exception as e:
db.rollback()
# If FA creation fails, rollback base class too
if 'base_class' in locals():
try:
db.delete(base_class)
db.commit()
except:
pass
# Extract and improve error message
error_msg = str(e)
if "already exists" in error_msg.lower() or "duplicad" in error_msg.lower():
# Extract code from error if possible
code = class_data.class_code
raise HTTPException(
status_code=400,
detail=f"El código '{code}' ya está en uso. Por favor ingrese un código diferente."
)
raise HTTPException(
status_code=400,
detail=f"Error al crear clase de activo fijo: {error_msg}"
)
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.class_code == class_data.class_code,
)
)
.first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Class with class_code '{class_data.class_code}' already exists",
)
# Crear clase
db_class = Class(
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 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, class_code: str) -> Optional[ClassResponseDTO]:
"""
Obtiene una clase por clave compuesta
Args:
class_code: Código de clase
Returns:
ClassResponseDTO o None si no existe
"""
class_obj = (
self.db.query(Class)
.filter(and_(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.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, class_code: str, class_data: ClassUpdateDTO
) -> Optional[ClassResponseDTO]:
"""
Actualiza una clase
Args:
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.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 {class_code}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating class")
def delete_class(self, class_code: str) -> bool:
"""
Elimina una clase
Args:
class_code: Código de clase
Returns:
True si se eliminó, False si no existe
"""
class_obj = (
self.db.query(Class)
.filter(and_(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 {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_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 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,
"classes_with_fraction": with_fraction,
"classes_with_us_fraction": with_us_fraction,
**physical_review_stats,
}
def get_classes_by_unit_measure(self, unit_of_measure: str) -> List[ClassBasicDTO]:
"""Obtiene clases por unidad de medida"""
classes = (
self.db.query(Class).filter(Class.unit_of_measure == unit_of_measure).all()
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]

View File

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

View File

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

View File

@@ -1,280 +0,0 @@
"""
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, ConfigDict, Field, model_validator
from .validators import is_valid_rfc, is_valid_tax_id
# Mensajes de error reutilizados por las validaciones de identificador fiscal
_RFC_FORMAT_ERROR = "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000."
_TAX_ID_FORMAT_ERROR = (
"El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres."
)
def _validate_fiscal_format(rfc: Optional[str], tax_id: Optional[str]) -> None:
"""
Valida el formato de cada identificador fiscal de forma independiente (no excluyente):
un registro puede traer RFC y TAX-ID a la vez; cada uno valida solo si tiene valor.
"""
if (rfc or "").strip() and not is_valid_rfc(rfc):
raise ValueError(_RFC_FORMAT_ERROR)
if (tax_id or "").strip() and not is_valid_tax_id(tax_id):
raise ValueError(_TAX_ID_FORMAT_ERROR)
def _require_fiscal_id_by_procedencia(
rfc: Optional[str], tax_id: Optional[str], type_nat_foreign: Optional[str]
) -> None:
"""Exige el identificador que corresponde a la procedencia: E ⇒ TAX-ID, otro ⇒ RFC."""
is_foreign = (type_nat_foreign or "").strip().upper().startswith("E")
if is_foreign and not (tax_id or "").strip():
raise ValueError("El TAX-ID es obligatorio para registros extranjeros.")
if not is_foreign and not (rfc or "").strip():
raise ValueError("El RFC es obligatorio para registros nacionales.")
# 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")
model_config = ConfigDict(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[str] = Field(None, max_length=8, 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"
)
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"
)
model_config = ConfigDict(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 (nacional)")
tax_id: Optional[str] = Field(
None, max_length=30, description="TAX-ID (identificador fiscal extranjero)"
)
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_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"
)
@model_validator(mode="after")
def validate_rfc_or_tax_id_format(self):
# Valida formato de ambos campos y exige el que corresponda a la procedencia
_validate_fiscal_format(self.rfc, self.tax_id)
_require_fiscal_id_by_procedencia(self.rfc, self.tax_id, self.type_nat_foreign)
return self
model_config = ConfigDict(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 (nacional)")
tax_id: Optional[str] = Field(
None, max_length=30, description="TAX-ID (identificador fiscal extranjero)"
)
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_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"
)
@model_validator(mode="after")
def validate_rfc_or_tax_id_format(self):
# Update es parcial (PATCH): solo se valida formato de lo que venga, sin exigir requerido
_validate_fiscal_format(self.rfc, self.tax_id)
return self
model_config = ConfigDict(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
tax_id: 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_active: Optional[bool] = None
tenant_id: int
company_id: int
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = None
programs: Optional[ClientProviderProgramsDTO] = None
model_config = ConfigDict(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
tax_id: Optional[str] = None
client_or_provider: Optional[str] = None
is_active: Optional[bool] = None
model_config = ConfigDict(from_attributes=True)
class ClientProviderListDTO(BaseModel):
"""DTO para lista de clientes/proveedores"""
clients: list[ClientProviderBasicDTO]
total: int
page: int
size: int
model_config = ConfigDict(from_attributes=True)
class ClientProviderPaginatedResponseDTO(BaseModel):
"""DTO para respuesta paginada de clientes/proveedores"""
items: List[ClientProviderResponseDTO]
total: int
page: int
page_size: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,172 +0,0 @@
"""
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))
# Identificadores fiscales separados: rfc = RFC mexicano (nacional); tax_id = identificador fiscal extranjero.
# La procedencia (type_nat_foreign) rige cuál es obligatorio, pero un registro puede traer ambos.
rfc: Mapped[Optional[str]] = mapped_column(String(30))
tax_id: 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_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"
)
@property
def fiscal_id(self) -> Optional[str]:
"""
Identificador fiscal efectivo según procedencia: RFC para nacionales,
TAX-ID para extranjeros. Punto único de verdad para consumidores downstream
(facturas, reportes, transmisiones) que antes leían solo `rfc`.
Extranjero ⟺ type_nat_foreign == 'E' (misma convención que DTOs/frontend);
cualquier otro valor (N, NULL, vacío) se trata como nacional.
"""
is_foreign = (self.type_nat_foreign or "").strip().upper().startswith("E")
return self.tax_id if is_foreign else self.rfc
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[str]] = mapped_column(String(8))
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))
broker: Mapped[Optional[str]] = mapped_column(String(6))
import_broker: Mapped[Optional[str]] = mapped_column(String(6))
transfer_key: Mapped[Optional[str]] = mapped_column(String(8))
secon_authorization: Mapped[Optional[str]] = mapped_column(String(20))
applied_proportion: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2))
is_certified_company: Mapped[Optional[str]] = mapped_column(String(1))
certified_company_registry: Mapped[Optional[str]] = mapped_column(String(40))
donation_auth_number: Mapped[Optional[str]] = mapped_column(String(50))
ctpat_svi: Mapped[Optional[str]] = mapped_column(String(100))
tax_registry_number: Mapped[Optional[str]] = mapped_column(String(40))
subassembly_service: Mapped[Optional[int]] = mapped_column(SmallInteger)
autse_dates: Mapped[Optional[int]] = mapped_column()
autse_number: Mapped[Optional[str]] = mapped_column(String(300))
# Relationship
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="programs")

View File

@@ -1,188 +0,0 @@
"""
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 import func, or_
from sqlalchemy.orm import Session, joinedload
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
from api.v1.modules.a76.layouts_csv.clients_and_providers.routes import router as imports_router
# Create main router to add custom endpoints
router = APIRouter(prefix="/clients-providers")
# CSV import (mismo flujo que customs_brokers/imports: upload → scan → commit)
router.include_router(imports_router, prefix="/imports", tags=["clients_and_providers / csv_import"])
@router.get("/", response_model=ClientProviderPaginatedResponseDTO)
async def get_clients_and_providers(
company_id: int = Query(..., description="Company ID"),
name: Optional[str] = Query(None, description="Filter by name (contains)"),
rfc: Optional[str] = Query(None, description="Filter by RFC (contains)"),
tax_id: Optional[str] = Query(None, description="Filter by TAX-ID (contains)"),
short_name: Optional[str] = Query(
None, description="Filter by short name / clave (exact match, case-insensitive)"
),
type: Optional[ClientOrProviderEnum] = Query(
None, description="Type of entity (client or provider)"
),
active: Optional[bool] = Query(None, description="Active status"),
page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"),
page_size: Optional[int] = Query(
None, ge=1, le=1000, description="Page size when using page-based pagination"
),
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, ["partners_mgmt.view"])
resolved_limit = page_size if page_size is not None else limit
resolved_skip = ((page - 1) * resolved_limit) if page is not None else skip
query = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
if name:
query = query.filter(ClientProvider.name.ilike(f"%{name.strip()}%"))
if rfc:
query = query.filter(ClientProvider.rfc.ilike(f"%{rfc.strip()}%"))
if tax_id:
query = query.filter(ClientProvider.tax_id.ilike(f"%{tax_id.strip()}%"))
if short_name:
sn = short_name.strip().upper()
query = query.filter(func.upper(ClientProvider.short_name) == sn)
if type is not None:
# Include 'both' type when filtering by client or provider
query = query.filter(
or_(
ClientProvider.client_or_provider == type,
ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH
)
)
if active is not None:
query = query.filter(ClientProvider.is_active == active)
total = query.count()
clients = query.offset(resolved_skip).limit(resolved_limit).all()
return {
"items": [ClientProviderResponseDTO.model_validate(c) for c in clients],
"total": total,
"page": (resolved_skip // resolved_limit) + 1,
"page_size": resolved_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, ["partners_mgmt.view"])
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, ["partners_mgmt.create"])
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, ["partners_mgmt.edit"])
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.get("/{client_id}", response_model=ClientProviderResponseDTO)
async def get_client_provider_detail(
client_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtener un cliente/proveedor completo por ID.
Esta es la ruta que tu formulario necesita para cargar los datos.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["partners_mgmt.view"])
# Usamos el servicio para buscar por ID
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 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, ["partners_mgmt.delete"])
success = ClientProviderService.delete(db, client_id, tenant_id, company_id)
if not success:
raise HTTPException(status_code=404, detail="Client/Provider not found")
return success

View File

@@ -1,505 +0,0 @@
"""
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 .models import ClientOrProviderEnum
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: Optional[int],
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "asc",
) -> Tuple[List[ClientProvider], int]:
"""Get all clients/providers for a tenant with pagination"""
query = db.query(ClientProvider).filter(ClientProvider.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(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),
ClientProvider.tax_id.ilike(search_pattern),
)
)
if filters.get("client_or_provider"):
query = query.filter(
or_(
ClientProvider.client_or_provider == filters["client_or_provider"],
ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH
)
)
if filters.get("status"):
enabled = 1 if filters["status"] == "enabled" else 0
query = query.filter(ClientProvider.is_active == enabled)
# Apply sorting
if sort_by:
column = getattr(ClientProvider, sort_by, None)
if column:
if sort_order == "desc":
query = query.order_by(column.desc())
else:
query = query.order_by(column.asc())
else:
query = query.order_by(ClientProvider.id.desc())
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,
company_id=company_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,
company_id=company_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.tax_id.ilike(search_pattern),
ClientProvider.client_id.ilike(search_pattern),
)
)
if client_or_provider:
query = query.filter(
or_(
ClientProvider.client_or_provider == client_or_provider,
ClientProvider.client_or_provider == ClientOrProviderEnum.BOTH
)
)
if enabled_only:
query = query.filter(ClientProvider.is_active == 1)
# Contar total
total = query.count()
# Aplicar paginación
clients = query.offset(skip).limit(limit).all()
# Convertir a DTOs básicos
client_dtos = [
ClientProviderBasicDTO.model_validate(client) for client in clients
]
return ClientProviderListDTO(
clients=client_dtos,
total=total,
page=(skip // limit) + 1 if limit > 0 else 1,
size=len(client_dtos),
)
def update_clients_and_providers(
self, client_id: str, client_data: ClientProviderUpdateDTO
) -> Optional[ClientProviderResponseDTO]:
"""
Actualiza un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a actualizar
client_data: Datos a actualizar
Returns:
ClientProviderResponseDTO actualizado o None si no existe
"""
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return None
try:
# Actualizar campos del cliente principal
update_data = client_data.model_dump(
exclude_unset=True, exclude={"address", "programs"}
)
for field, value in update_data.items():
setattr(client, field, value)
# Actualizar dirección
if client_data.address:
address = (
self.db.query(ClientProviderAddress)
.filter(ClientProviderAddress.client_id == client_id)
.first()
)
if address:
# Actualizar dirección existente
address_data = client_data.address.model_dump(exclude_unset=True)
for field, value in address_data.items():
setattr(address, field, value)
else:
# Crear nueva dirección
address = ClientProviderAddress(
client_id=client_id,
**client_data.address.model_dump(exclude_unset=True),
)
self.db.add(address)
# Actualizar programas
if client_data.programs:
programs = (
self.db.query(ClientProviderPrograms)
.filter(ClientProviderPrograms.client_id == client_id)
.first()
)
if programs:
# Actualizar programas existentes
programs_data = client_data.programs.model_dump(exclude_unset=True)
for field, value in programs_data.items():
setattr(programs, field, value)
else:
# Crear nuevos programas
programs = ClientProviderPrograms(
client_id=client_id,
**client_data.programs.model_dump(exclude_unset=True),
)
self.db.add(programs)
self.db.commit()
return self._get_client_with_relations(client_id)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating client/provider"
)
def delete_clients_and_providers(self, client_id: str) -> bool:
"""
Elimina un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a eliminar
Returns:
True si se eliminó, False si no existe
"""
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return False
try:
self.db.delete(client) # Las relaciones se eliminan en cascada
self.db.commit()
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting client/provider"
)

View File

@@ -1,27 +0,0 @@
"""
Validadores de formato para RFC (Nacional) y TAX-ID (Extranjero) en clientes y proveedores.
"""
import re
# Formato RFC México: 3-4 letras (A-Z, &, Ñ), 6 dígitos (fecha), 3 caracteres homoclave. Ej: XAXX010101000
RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE)
# TAX-ID extranjero: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789 (EIN US). Total máx 30.
TAX_ID_PATTERN = re.compile(r"^\d{2}-[A-Z0-9]{1,27}$", re.IGNORECASE)
def is_valid_rfc(value: str) -> bool:
"""Valida formato RFC mexicano. Acepta cadena vacía/None como inválida (no opcional aquí)."""
if not value or not isinstance(value, str):
return False
normalized = value.strip().upper()
return bool(normalized and RFC_PATTERN.match(normalized))
def is_valid_tax_id(value: str) -> bool:
"""Valida formato TAX-ID (extranjero): 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789."""
if not value or not isinstance(value, str):
return False
normalized = value.strip()
return bool(normalized and len(normalized) <= 30 and TAX_ID_PATTERN.match(normalized))

View File

@@ -1,61 +0,0 @@
from typing import Any, Optional, Type
from sqlalchemy import asc, desc, inspect
from sqlalchemy.orm import Query, RelationshipProperty
def apply_sorting(
query: Query,
model: Type[Any],
sort_by: Optional[str] = None,
sort_desc: bool = True,
default_sort_col: str = "created_at"
) -> Query:
"""
Applies dynamic sorting to a SQLAlchemy query.
Supports nested attributes via dot notation (e.g., 'compliance_mx.remesa').
Automatically handles joins if necessary.
"""
if not sort_by:
if hasattr(model, default_sort_col):
col = getattr(model, default_sort_col)
return query.order_by(desc(col))
return query
try:
parts = sort_by.split('.')
current_model = model
# Traverse relationships if dot notation is used
for i, part in enumerate(parts[:-1]):
# Check if relationship exists
mapper = inspect(current_model)
if part in mapper.relationships:
rel = mapper.relationships[part]
# Join the relationship
query = query.join(rel.entity.class_)
current_model = rel.entity.class_
else:
# If part is not a relationship, we can't go deeper
# Fallback to default sorting
if hasattr(model, default_sort_col):
return query.order_by(desc(getattr(model, default_sort_col)))
return query
# The last part is the actual column
last_part = parts[-1]
if hasattr(current_model, last_part):
col = getattr(current_model, last_part)
if sort_desc:
query = query.order_by(desc(col))
else:
query = query.order_by(asc(col))
else:
# Fallback to default sorting on the original model
if hasattr(model, default_sort_col):
query = query.order_by(desc(getattr(model, default_sort_col)))
except Exception as e:
# Log error or handle gracefully
print(f"Error applying sort for {sort_by}: {e}")
if hasattr(model, default_sort_col):
query = query.order_by(desc(getattr(model, default_sort_col)))
return query

View File

@@ -1 +0,0 @@
# CSV templates: generate CSV from code (no physical XLS/XLSX files)

View File

@@ -1,423 +0,0 @@
"""
Registro central de plantillas CSV: template_id -> lista de cabeceras canónicas.
Construido a partir de los TEMPLATE_COLUMNS de cada módulo de imports.
"""
import csv
import io
from typing import Dict, List, Optional, Set
# Importar configs de cada módulo
from api.v1.modules.a76.layouts_csv.facturas.template_config import (
_resolve_template_columns as resolve_imports_template,
)
from api.v1.modules.a76.layouts_csv.parts.template_config import (
TEMPLATE_COLUMNS as PARTS_TEMPLATE_COLUMNS,
TEMPLATE_DOWNLOAD_HEADERS as PARTS_TEMPLATE_DOWNLOAD_HEADERS,
)
from api.v1.modules.a76.layouts_csv.boms.template_config import TEMPLATE_COLUMNS as BOMS_TEMPLATE_COLUMNS
from api.v1.modules.a76.layouts_csv.classes.template_config import (
TEMPLATE_COLUMNS as CLASSES_TEMPLATE_COLUMNS,
TEMPLATE_DOWNLOAD_HEADERS as CLASSES_TEMPLATE_DOWNLOAD_HEADERS,
)
from api.v1.modules.a76.layouts_csv.customs_brokers.template_config import (
TEMPLATE_COLUMNS as CUSTOMS_BROKERS_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.clients_and_providers.template_config import (
TEMPLATE_COLUMNS as CLIENTS_PROVIDERS_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.exchange_rate.template_config import (
TEMPLATE_COLUMNS as EXCHANGE_RATE_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.us_tariff_fractions.template_config import (
TEMPLATE_COLUMNS as US_TARIFF_FRACTIONS_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.pedmientos.template_config import (
TEMPLATE_COLUMNS as PEDIMENTOS_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.vehicles.template_config import (
TEMPLATE_COLUMNS as VEHICLES_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.drivers.template_config import (
TEMPLATE_COLUMNS as DRIVERS_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.trailers.template_config import (
TEMPLATE_COLUMNS as TRAILERS_TEMPLATE_COLUMNS,
)
from api.v1.modules.a76.layouts_csv.transportistas.template_config import (
TEMPLATE_COLUMNS as TRANSPORTERS_TEMPLATE_COLUMNS,
)
def _normalize_header_for_match(header: str) -> str:
"""Normaliza cabeceras para comparación interna (sin alterar salida)."""
if not header:
return ""
cleaned = header.strip()
if cleaned.startswith("* "):
cleaned = cleaned[2:]
return cleaned.strip().upper()
ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE: Dict[str, set[str]] = {
# Facturas encabezados
"imp_temp_header": {
"NUMERO FACTURA",
"FECHA FACTURA",
"REGIMEN",
"CLAVE PROVEEDOR",
"CLAVE VENDIDO A",
"CLAVE ENVIADO A",
"AGENTE ADUANAL",
"ADUANA DE CRUCE",
},
"imp_def_header": {
"NUMERO FACTURA",
"FECHA FACTURA",
"REGIMEN",
"CLAVE PROVEEDOR",
"CLAVE VENDIDO A",
"CLAVE ENVIADO A",
"AGENTE ADUANAL",
},
"exp_def_header": {
"NUMERO FACTURA",
"FECHA FACTURA",
"REGIMEN",
"CLAVE PROVEEDOR",
"CLAVE VENDIDO A",
"CLAVE ENVIADO A",
"AGENTE ADUANAL",
"ADUANA DE CRUCE",
},
"cmex_header": {
"NUMERO FACTURA",
"FECHA FACTURA",
"CLAVE PROVEEDOR",
"CLAVE VENDIDO A",
"CLAVE ENVIADO A",
},
# Facturas partidas (plantillas del registry)
"imp_temp_details": {
"NUMERO FACTURA",
"CLASE",
"CANTIDAD IMPORTADA",
"COSTO UNITARIO",
"PESO NETO",
"PAIS ORIGEN",
"PREFERENCIA ARANCELARIA",
},
"imp_def_details": {
"NUMERO FACTURA",
"CLASE",
"CANTIDAD IMPORTADA",
"COSTO UNITARIO",
"PESO NETO",
"PAIS ORIGEN",
"PREFERENCIA ARANCELARIA",
"NUM. PARTE",
},
"exp_def_details": {
"NUMERO FACTURA",
"CLASE",
"CANTIDAD IMPORTADA",
"COSTO UNITARIO",
"PESO NETO",
"PAIS ORIGEN",
"PREFERENCIA ARANCELARIA",
},
"cmex_details": {
"NUMERO FACTURA",
"CLASE",
"CANTIDAD IMPORTADA",
"COSTO UNITARIO",
"PESO NETO",
"PAIS ORIGEN",
"PREFERENCIA ARANCELARIA",
"NUM. PARTE",
},
# Facturas series (plantillas del registry)
"imp_temp_series": {"NUMERO FACTURA", "LINEA FACTURA"},
"imp_def_series": {"NUMERO FACTURA", "LINEA FACTURA"},
"cmex_series": {"NUMERO FACTURA", "LINEA FACTURA"},
"exp_def_series": {"NUMERO FACTURA", "LINEA FACTURA"},
# Catálogos y transportes
"customs_brokers": {"TIPO", "CLAVE", "NOMBRE"},
"clients_providers": {"PROCEDENCIA", "SHORT_NAME", "NOMBRE", "RFC"},
"exchange_rates": {"FECHA", "VALOR"},
"american_fractions": {"FRACCION_ARANCELARIA", "DESCRIPCION"},
"material_classes": {
"CLAVE CLASE",
"CLASE",
"DESCRIPCION ESPAÑOL",
"DESCRIPCIONE",
"TIPO DE MATERIAL",
"CLAVEMAT",
"U.M. COMERCIAL",
"UNIMED",
"FRACCION ARANCELARIA",
"FRACCION",
},
"part_numbers": {
"NUMERO DE PARTE",
"NUMPARTE",
"DESCRIPCION EN ESPAÑOL",
"DESCRIPCIONE",
"UNIDAD DE MEDIDA COMERCIAL",
"UNIMED",
},
"items": {
"NUMERO DE PARTE",
"NUMPARTE",
"DESCRIPCION EN ESPAÑOL",
"DESCRIPCIONE",
"UNIDAD DE MEDIDA COMERCIAL",
"UNIMED",
},
"boms": {"NUMPARTE_PADRE", "NUMPARTE_COMPONENTE", "CANTIDAD"},
"pedimentos": {
"AÑO", "PATENTE", "NUMERO", "PEDIMENTO",
"TIPO_OPERACION",
"CLAVE_PEDIMENTO",
"REGIMEN",
"FECHA_INICIO",
"FECHA_FINAL",
"FECHA_PAGO",
"ADUANA_SECCION_CRUCE",
},
"transports": {"CLAVE", "CODIGO DE ENTIDAD"},
"drivers": {"TRANSPORTISTA", "LINEA", "CLAVE CONDUCTOR"},
"trailers": {"NUMERO TRAILER"},
"transporters": {"CLAVE TRANSPORTISTA", "NOMBRE"},
}
def _compute_required_indices_map(base_rows: Dict[str, List[str]]) -> Dict[str, Set[int]]:
"""Índices de columnas obligatorias (según plantilla ES/base), para marcar * en cualquier idioma."""
out: Dict[str, Set[int]] = {}
for tid, headers in base_rows.items():
req = ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE.get(tid)
if not req:
continue
req_norm = {_normalize_header_for_match(h) for h in req}
idx_set: Set[int] = set()
for i, h in enumerate(headers):
if not h:
continue
if _normalize_header_for_match(h) in req_norm:
idx_set.add(i)
out[tid] = idx_set
return out
def _apply_required_prefix_indices(template_id: str, headers: List[str]) -> List[str]:
"""Prefija '* ' usando índices fijos de la plantilla base (independiente del idioma de cabecera)."""
idx_set = _REQUIRED_INDICES.get(template_id)
if not idx_set:
return headers
out: List[str] = []
for i, h in enumerate(headers):
if i in idx_set and h and not str(h).startswith("* "):
out.append(f"* {h}")
else:
out.append(h)
return out
def _canonicals_from_columns(cols: Optional[List[Dict]]) -> List[str]:
"""Extrae la lista de nombres canónicos en orden a partir de una lista de columnas."""
if not cols:
return []
return [item["canonical"] for item in cols]
def _build_registry() -> Dict[str, List[str]]:
registry: Dict[str, List[str]] = {}
# a76/imports (facturas): imp_temp_header, imp_temp_details, imp_def_*, exp_def_*, cmex_*, series
for tid in (
"imp_temp_header",
"imp_temp_details",
"imp_temp_series",
"imp_def_header",
"imp_def_details",
"imp_def_series",
"exp_def_header",
"exp_def_details",
"exp_def_series",
"cmex_header",
"cmex_details",
"cmex_series",
):
cols = resolve_imports_template(tid)
registry[tid] = _canonicals_from_columns(cols)
# part_numbers (parts); "items" usa la misma plantilla
registry["part_numbers"] = (
PARTS_TEMPLATE_DOWNLOAD_HEADERS
if PARTS_TEMPLATE_DOWNLOAD_HEADERS
else _canonicals_from_columns(PARTS_TEMPLATE_COLUMNS.get("part_numbers"))
)
registry["items"] = (
PARTS_TEMPLATE_DOWNLOAD_HEADERS
if PARTS_TEMPLATE_DOWNLOAD_HEADERS
else _canonicals_from_columns(PARTS_TEMPLATE_COLUMNS.get("part_numbers"))
)
# boms
registry["boms"] = _canonicals_from_columns(BOMS_TEMPLATE_COLUMNS.get("boms"))
# material_classes: cabeceras de descarga según plantilla usuario (CLAVE, CLASE, DESCRIPCION, etc.)
registry["material_classes"] = (
CLASSES_TEMPLATE_DOWNLOAD_HEADERS
if CLASSES_TEMPLATE_DOWNLOAD_HEADERS
else _canonicals_from_columns(CLASSES_TEMPLATE_COLUMNS.get("material_classes"))
)
# customs_brokers
registry["customs_brokers"] = _canonicals_from_columns(CUSTOMS_BROKERS_TEMPLATE_COLUMNS.get("customs_brokers"))
# clients_providers
registry["clients_providers"] = _canonicals_from_columns(CLIENTS_PROVIDERS_TEMPLATE_COLUMNS.get("client_providers"))
# exchange_rates
registry["exchange_rates"] = _canonicals_from_columns(EXCHANGE_RATE_TEMPLATE_COLUMNS.get("exchange_rates"))
# american_fractions
registry["american_fractions"] = _canonicals_from_columns(
US_TARIFF_FRACTIONS_TEMPLATE_COLUMNS.get("us_tariff_fractions")
)
# pedimentos
registry["pedimentos"] = _canonicals_from_columns(PEDIMENTOS_TEMPLATE_COLUMNS.get("pedimentos"))
# transports (vehicles)
registry["transports"] = _canonicals_from_columns(VEHICLES_TEMPLATE_COLUMNS.get("vehicles"))
# drivers
registry["drivers"] = _canonicals_from_columns(DRIVERS_TEMPLATE_COLUMNS.get("drivers"))
# trailers
registry["trailers"] = _canonicals_from_columns(TRAILERS_TEMPLATE_COLUMNS.get("trailers"))
# transporters (transportistas)
registry["transporters"] = _canonicals_from_columns(TRANSPORTERS_TEMPLATE_COLUMNS.get("transporters"))
return registry
_REGISTRY_BASE_ES: Dict[str, List[str]] = _build_registry()
_REQUIRED_INDICES: Dict[str, Set[int]] = _compute_required_indices_map(_REGISTRY_BASE_ES)
_TEMPLATE_HEADERS: Dict[str, List[str]] = _REGISTRY_BASE_ES
# Nombre de archivo sugerido para descarga (sin path)
TEMPLATE_FILENAMES: Dict[str, str] = {
"customs_brokers": "EstructuraCatAgenteAduanal.csv",
"clients_providers": "EstructuraCatClienteProv.csv",
"exchange_rates": "EstructuraCatTiposCambio.csv",
"american_fractions": "EstructuraCatFraccAme.csv",
"material_classes": "EstructuraCatClasesAF.csv",
"part_numbers": "EstructuraCatPartesAF.csv",
"items": "EstructuraCatPartesAF.csv",
"boms": "EstructuraBOMS.csv",
"pedimentos": "EstructuraCatPedimentos.csv",
"transports": "EstructuraCatTransportes.csv",
"drivers": "EstructuraCatConductor.csv",
"trailers": "EstructuraCatTrailers.csv",
"transporters": "EstructuraCatTransportistas.csv",
"imp_temp_header": "EstructuraEncFacImpoTemp.csv",
"imp_temp_details": "EstructuraParFacImpoTempAF.csv",
"imp_temp_series": "EstructuraSeriesFacImpoTemp.csv",
"imp_def_header": "EstructuraEncFacImpoDef.csv",
"imp_def_details": "EstructuraParFacImpoDefAF.csv",
"imp_def_series": "EstructuraSeriesFacImpoDef.csv",
"cmex_header": "EstructuraEncFacComprasMex.csv",
"cmex_details": "EstructuraParFacComprasMex.csv",
"cmex_series": "EstructuraSeriesFacComprasMex.csv",
"exp_def_header": "EstructuraEncFacExpoCamReg.csv",
"exp_def_details": "EstructuraParExpoCamReg.csv",
"exp_def_series": "EstructuraSeriesFacExpoCamReg.csv",
}
def get_download_headers(template_id: str, locale: Optional[str] = "es") -> Optional[List[str]]:
"""
Cabeceras de descarga para la plantilla (sin prefijo *).
locale: es | en (default es).
"""
from api.v1.modules.a76.layouts_csv.common.template_locale import (
download_header_cell,
normalize_locale,
)
loc = normalize_locale(locale)
cols = resolve_imports_template(template_id)
if cols is not None:
return [download_header_cell(c, loc) for c in cols]
if template_id in ("part_numbers", "items"):
from api.v1.modules.a76.layouts_csv.parts import template_config as parts_tc
return parts_tc.download_headers_for_locale(loc)
if template_id == "material_classes":
from api.v1.modules.a76.layouts_csv.classes import template_config as classes_tc
return classes_tc.download_headers_for_locale(loc)
# template_id del registry → (module_columns, clave interna del dict de columnas)
catalog_resolvers: list[tuple[str, dict, str]] = [
("boms", BOMS_TEMPLATE_COLUMNS, "boms"),
("customs_brokers", CUSTOMS_BROKERS_TEMPLATE_COLUMNS, "customs_brokers"),
("clients_providers", CLIENTS_PROVIDERS_TEMPLATE_COLUMNS, "client_providers"),
("exchange_rates", EXCHANGE_RATE_TEMPLATE_COLUMNS, "exchange_rates"),
("american_fractions", US_TARIFF_FRACTIONS_TEMPLATE_COLUMNS, "us_tariff_fractions"),
("pedimentos", PEDIMENTOS_TEMPLATE_COLUMNS, "pedimentos"),
("transports", VEHICLES_TEMPLATE_COLUMNS, "vehicles"),
("drivers", DRIVERS_TEMPLATE_COLUMNS, "drivers"),
("trailers", TRAILERS_TEMPLATE_COLUMNS, "trailers"),
("transporters", TRANSPORTERS_TEMPLATE_COLUMNS, "transporters"),
]
for tid, mapping, inner_key in catalog_resolvers:
if tid != template_id:
continue
cols = mapping.get(inner_key)
if cols:
return [download_header_cell(col, loc) for col in cols]
return None
def get_template_headers(template_id: str) -> Optional[List[str]]:
"""Cabeceras como en descarga ES histórica, con prefijo * en obligatorias."""
raw = get_download_headers(template_id, "es")
if raw is None:
return None
return _apply_required_prefix_indices(template_id, raw)
def get_template_filename(template_id: str) -> str:
"""Nombre de archivo sugerido para la descarga."""
return TEMPLATE_FILENAMES.get(template_id, f"plantilla_{template_id}.csv")
def generate_csv_content(
template_id: str,
locale: str = "es",
include_bom: bool = True,
) -> Optional[bytes]:
"""
Genera el contenido CSV (solo fila de cabeceras) para el template_id.
UTF-8, opcionalmente con BOM para Excel.
"""
headers = get_download_headers(template_id, locale)
if not headers:
return None
headers = _apply_required_prefix_indices(template_id, headers)
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerow(headers)
content = buf.getvalue().encode("utf-8")
if include_bom:
content = b"\xef\xbb\xbf" + content
return content

View File

@@ -1,43 +0,0 @@
"""
Rutas para descargar plantillas CSV generadas desde código (sin archivos XLS/XLSX).
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from core.security import get_current_user
from .registry import generate_csv_content, get_template_filename
router = APIRouter()
_LOCALE_ALLOWED = frozenset({"es", "en"})
@router.get("/{template_id}", response_class=Response)
async def download_csv_template(
template_id: str,
locale: Optional[str] = Query("es", description="es | en: idioma de las cabeceras del CSV"),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Devuelve un CSV con solo la fila de cabeceras para la plantilla indicada.
Query `locale`: es (defecto) o en — cabeceras localizadas cuando estén definidas.
"""
loc = (locale or "es").lower().strip()
if loc not in _LOCALE_ALLOWED:
raise HTTPException(status_code=400, detail="locale must be 'es' or 'en'")
content = generate_csv_content(template_id, locale=loc, include_bom=True)
if content is None:
raise HTTPException(status_code=404, detail=f"Plantilla desconocida: {template_id}")
filename = get_template_filename(template_id)
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "private, no-store, max-age=0, must-revalidate",
"Pragma": "no-cache",
},
)

View File

@@ -1,124 +0,0 @@
from typing import Optional
from pydantic import BaseModel, Field
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] = Field(None, pattern=r"^$|^[\d\s\-\+\(\)]+$")
fax: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
country: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
company: Optional[str] = None
contact: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]+$")
class CustomsBrokerCreateDTO(CustomsBrokerBaseDTO):
"""Schema for creating a new CustomsBroker"""
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$", description="Clave única del agente aduanal (máx 5 caracteres)")
class CustomsBrokerUpdateDTO(CustomsBrokerBaseDTO):
"""Schema for updating an existing CustomsBroker"""
pass
class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
"""Schema for CustomsBroker response"""
id: int
broker_key: str
tenant_id: int
company_id: int
vu: Optional["CustomsBrokerVUResponseDTO"] = None
class Config:
from_attributes = True
# Legacy DTO for backwards compatibility (if needed elsewhere)
class CustomsBrokerDTO(BaseModel):
type: Optional[str] = None
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
name: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = Field(None, pattern=r"^$|^[\d\s\-\+\(\)]+$")
fax: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
country: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
company: Optional[str] = None
contact: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]+$")
tenant_id: str
company_id: str
class Config:
from_attributes = True
class CustomsBrokerVUCreateDTO(BaseModel):
certificate_path: Optional[str] = None
key_path: Optional[str] = None
access_key: Optional[str] = None
fiel_format: Optional[str] = None
signature_read_path: Optional[str] = None
archive_path: Optional[str] = None
fiel_access_key: Optional[str] = None
web_service_user: Optional[str] = None
web_service_access_key: Optional[str] = None
vu_email: Optional[str] = None
vu_figure_type: Optional[str] = None
xml_files_path: Optional[str] = None
query_tax_id: Optional[str] = None
doda_certificate_path: Optional[str] = None
doda_key_path: Optional[str] = None
doda_web_service_user: Optional[str] = None
doda_web_service_access_key: Optional[str] = None
doda_fiel_access_key: Optional[str] = None
doda_xml_files_path: Optional[str] = None
tenant_id: Optional[int] = None
company_id: Optional[int] = None
class CustomsBrokerVUResponseDTO(CustomsBrokerVUCreateDTO):
customs_broker_id: int
class Config:
from_attributes = True
class CustomsBrokerPersonnelDTO(BaseModel):
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
line: int
name: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
first_name: Optional[str] = None
last_name: Optional[str] = None
middle_name: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
tenant_id: Optional[int] = None
company_id: Optional[int] = None
class Config:
from_attributes = True

View File

@@ -1,94 +0,0 @@
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", uselist=False
)
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")

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