Compare commits
26 Commits
5e60000152
...
880999b03a
| Author | SHA1 | Date | |
|---|---|---|---|
| 880999b03a | |||
| 9fae110c11 | |||
| d5643ec0e9 | |||
| c5aa6630ee | |||
| 47e3f36f5c | |||
| 26c5a97c04 | |||
| 96b3beed4f | |||
| bdfcac4f94 | |||
| c8b3c23564 | |||
| 3d32dfe3f2 | |||
| e0b08dfa8a | |||
| e612037668 | |||
| c5a77fb30a | |||
| ae6517bcb4 | |||
| 7b95c6da86 | |||
| f56b2ca784 | |||
| 963425074e | |||
| 619ef8d7d8 | |||
| f5aa9dd614 | |||
| 64bf1fee0b | |||
| 6cf74c3bbd | |||
| 7ee5200cfa | |||
| 4bbc93534c | |||
| 237ed17bdd | |||
| 7a8028ac81 | |||
| ddd0635af6 |
@@ -14,9 +14,9 @@ KEYCLOAK_ADMIN_PASSWORD=admin
|
||||
|
||||
# ----- Keycloak Configuración -----
|
||||
KEYCLOAK_REALM=master
|
||||
KEYCLOAK_CLIENT_ID=anexo76-backend
|
||||
KEYCLOAK_CLIENT_ID=app-backend
|
||||
KEYCLOAK_CLIENT_SECRET=dev-secret
|
||||
KEYCLOAK_FRONTEND_CLIENT_ID=anexo76-frontend
|
||||
KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend
|
||||
|
||||
# ----- Backend -----
|
||||
DEBUG=True
|
||||
@@ -59,7 +59,7 @@ VITE_HUB_URL=http://localhost:3001
|
||||
APP_PUBLIC_URL=http://localhost:5173
|
||||
VITE_KEYCLOAK_REALM=master
|
||||
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
|
||||
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
||||
VITE_KEYCLOAK_CLIENT_ID=app-frontend
|
||||
|
||||
#------ Celery / Valkey ----------
|
||||
VALKEY_URL=redis://valkey:6379/0
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -82,3 +82,4 @@ backend/celerybeat-schedule
|
||||
celerybeat-schedule
|
||||
celerybeat-schedule.*
|
||||
celerybeat.pid
|
||||
backend/api/v1/modules/reports/generated/
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
# Application
|
||||
APP_NAME=Anexo76
|
||||
APP_NAME=Mi Aplicación
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=True
|
||||
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
|
||||
CORE_DB_HOST=localhost
|
||||
CORE_DB_PORT=5432
|
||||
|
||||
@@ -301,7 +301,7 @@ modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules")
|
||||
import_models_from_dir(modules_dir)
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""company prosec boolean to integer
|
||||
|
||||
Revision ID: 506969b0a256
|
||||
Revises: a7b8c9d0e1f2
|
||||
Create Date: 2026-06-02 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "506969b0a256"
|
||||
down_revision: Union[str, None] = "a7b8c9d0e1f2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Convierte prosec de boolean a integer para almacenar el ID del sector PROSEC.
|
||||
# El default 'false' debe eliminarse antes de cambiar el tipo.
|
||||
op.execute("ALTER TABLE a76.company ALTER COLUMN prosec DROP DEFAULT")
|
||||
op.execute("ALTER TABLE a76.company ALTER COLUMN prosec DROP NOT NULL")
|
||||
op.execute(
|
||||
"ALTER TABLE a76.company ALTER COLUMN prosec TYPE integer USING prosec::int"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE a76.company
|
||||
ALTER COLUMN prosec TYPE boolean
|
||||
USING CASE WHEN prosec IS NULL OR prosec = 0 THEN false ELSE true END
|
||||
"""
|
||||
)
|
||||
op.execute("ALTER TABLE a76.company ALTER COLUMN prosec SET DEFAULT false")
|
||||
op.execute("ALTER TABLE a76.company ALTER COLUMN prosec SET NOT NULL")
|
||||
@@ -4,477 +4,21 @@ Revision ID: 8c9bad3da37f
|
||||
Revises: 9db46c604463
|
||||
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 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"
|
||||
down_revision: Union[str, Sequence[str], None] = "9db46c604463"
|
||||
down_revision: Union[str, None] = "9db46c604463"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Catálogos de referencia y permisos base (datos)."""
|
||||
|
||||
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)
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Los datos de catálogo no se revierten automáticamente."""
|
||||
pass
|
||||
|
||||
@@ -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")
|
||||
@@ -1,7 +1,7 @@
|
||||
"""add workspace profile fields to user_tenants
|
||||
|
||||
Revision ID: c3d4e5f6a7b
|
||||
Revises: ca7d3c4e8b2a
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-05-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c3d4e5f6a7b"
|
||||
down_revision: Union[str, None] = "ca7d3c4e8b2a"
|
||||
down_revision: Union[str, None] = "b2c3d4e5f6a7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
@@ -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}"')
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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';")
|
||||
@@ -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')
|
||||
54
backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py
Normal file
54
backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""add invite codes
|
||||
|
||||
Revision ID: g2h3i4j5k6l7
|
||||
Revises: c3d4e5f6a7b
|
||||
Create Date: 2026-06-02 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "g2h3i4j5k6l7"
|
||||
down_revision: str = "c3d4e5f6a7b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"invite_codes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=16), nullable=False),
|
||||
sa.Column("tenant_slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=True),
|
||||
sa.Column("role", sa.String(length=50), nullable=False, server_default="user"),
|
||||
sa.Column("max_uses", sa.Integer(), nullable=True),
|
||||
sa.Column("uses_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="core",
|
||||
)
|
||||
op.create_index("ix_core_invite_codes_id", "invite_codes", ["id"], schema="core")
|
||||
op.create_index(
|
||||
"ix_core_invite_codes_code", "invite_codes", ["code"], unique=True, schema="core"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_invite_codes_tenant_slug", "invite_codes", ["tenant_slug"], schema="core"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_invite_codes_company_id", "invite_codes", ["company_id"], schema="core"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_core_invite_codes_company_id", table_name="invite_codes", schema="core")
|
||||
op.drop_index("ix_core_invite_codes_tenant_slug", table_name="invite_codes", schema="core")
|
||||
op.drop_index("ix_core_invite_codes_code", table_name="invite_codes", schema="core")
|
||||
op.drop_index("ix_core_invite_codes_id", table_name="invite_codes", schema="core")
|
||||
op.drop_table("invite_codes", schema="core")
|
||||
@@ -23,13 +23,11 @@ class TimestampMixin(BaseTimestampMixin):
|
||||
|
||||
|
||||
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)
|
||||
company_id: Mapped[int] = mapped_column(Integer, ForeignKey("a76.company.id"), nullable=False, index=True)
|
||||
|
||||
|
||||
class PedimentoRelatedMixin(TenantScopedMixin):
|
||||
"""Mixin for entities related to pedimentos"""
|
||||
|
||||
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
|
||||
@@ -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()
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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}')>"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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}')>"
|
||||
@@ -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})>"
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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}')>"
|
||||
@@ -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
|
||||
@@ -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}')>"
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)}",
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Module initialization for app_settings
|
||||
@@ -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})>"
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Audit Log Utilities
|
||||
"""
|
||||
|
||||
from .serialization import serialize_for_json
|
||||
|
||||
__all__ = ["serialize_for_json"]
|
||||
@@ -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()}
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Módulo de importación CSV para BOMs (Bills of Materials).
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -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"])
|
||||
@@ -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}")
|
||||
@@ -1,264 +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)"
|
||||
)
|
||||
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)"
|
||||
)
|
||||
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
|
||||
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
|
||||
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)
|
||||
@@ -1,111 +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"],
|
||||
),
|
||||
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
|
||||
|
||||
# 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]
|
||||
)
|
||||
|
||||
# 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}')>"
|
||||
@@ -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)
|
||||
@@ -1,800 +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__)
|
||||
|
||||
|
||||
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,
|
||||
"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"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# 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", "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,
|
||||
"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]
|
||||
@@ -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)
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Módulo de Client & Provider
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,267 +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
|
||||
|
||||
|
||||
# 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")
|
||||
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):
|
||||
rfc = self.rfc
|
||||
if not rfc or not (rfc := (rfc or "").strip()):
|
||||
return self
|
||||
proc = (self.type_nat_foreign or "N").strip().upper()[:1]
|
||||
if proc == "E":
|
||||
if not is_valid_tax_id(rfc):
|
||||
raise ValueError(
|
||||
"El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres."
|
||||
)
|
||||
else:
|
||||
if not is_valid_rfc(rfc):
|
||||
raise ValueError(
|
||||
"El RFC no tiene el formato correcto. Ejemplo: XAXX010101000."
|
||||
)
|
||||
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")
|
||||
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):
|
||||
rfc = self.rfc
|
||||
if not rfc or not (rfc := (rfc or "").strip()):
|
||||
return self
|
||||
proc = (self.type_nat_foreign or "N").strip().upper()[:1]
|
||||
if proc == "E":
|
||||
if not is_valid_tax_id(rfc):
|
||||
raise ValueError(
|
||||
"El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres."
|
||||
)
|
||||
else:
|
||||
if not is_valid_rfc(rfc):
|
||||
raise ValueError(
|
||||
"El RFC no tiene el formato correcto. Ejemplo: XAXX010101000."
|
||||
)
|
||||
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
|
||||
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
|
||||
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)
|
||||
@@ -1,158 +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))
|
||||
# Identificador fiscal único: RFC (nacional) o TAX-ID (extranjero); no usar programs.tax_id para lo mismo
|
||||
rfc: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
curp: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
client_or_provider: Mapped[ClientOrProviderEnum] = mapped_column(PgEnum(ClientOrProviderEnum, name="entity_client_or_provider", create_type=True, native_enum=True),nullable=False)
|
||||
linking: Mapped[Optional[str]] = mapped_column(String(1))
|
||||
transform_subassembly: Mapped[Optional[str]] = mapped_column(String(1))
|
||||
extra_information: Mapped[Optional[str]] = mapped_column(String(399))
|
||||
web_key: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
responsible: Mapped[Optional[str]] = mapped_column(String(80))
|
||||
position: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
incoterm: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
|
||||
# Relationships
|
||||
address: Mapped[Optional["ClientProviderAddress"]] = relationship(
|
||||
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
programs: Mapped[Optional["ClientProviderPrograms"]] = relationship(
|
||||
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ClientProviderAddress(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla GClientesPro_Direccion - Dirección de clientes y proveedores
|
||||
"""
|
||||
|
||||
__tablename__ = "clients_and_providers_address"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="clients_and_providers_address_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["client_id"],
|
||||
["a76.clients_and_providers.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_clients_and_providers_address_client",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key (foreign key)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
client_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
|
||||
)
|
||||
|
||||
# Address information
|
||||
municipality: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
streets: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
neighborhood: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
interior_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
exterior_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(15))
|
||||
city: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
state: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
country: Mapped[Optional[str]] = mapped_column(String(3))
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
fax_number: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
email: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
contact: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
reference: Mapped[Optional[str]] = mapped_column(String(250))
|
||||
|
||||
# Relationship
|
||||
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="address")
|
||||
|
||||
|
||||
class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Modelo para la tabla GClientesPro_Programas - Programas de clientes y proveedores
|
||||
"""
|
||||
|
||||
__tablename__ = "clients_and_providers_programs"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="clients_and_providers_programs_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["client_id"],
|
||||
["a76.clients_and_providers.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_clients_and_providers_programs_client",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
# Primary key (foreign key)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
client_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
|
||||
)
|
||||
|
||||
# Program information
|
||||
program: Mapped[Optional[str]] = mapped_column(String(7))
|
||||
program_number: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
prosec: Mapped[Optional[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")
|
||||
@@ -1,184 +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/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 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
|
||||
@@ -1,503 +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),
|
||||
)
|
||||
)
|
||||
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.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"
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
# CSV templates: generate CSV from code (no physical XLS/XLSX files)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -1,403 +0,0 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.s3_keys import (
|
||||
customs_broker_vu_certificate_key,
|
||||
customs_broker_vu_cove_key,
|
||||
customs_broker_vu_doda_certificate_key,
|
||||
customs_broker_vu_doda_cove_key,
|
||||
customs_broker_vu_doda_private_key_key,
|
||||
customs_broker_vu_private_key_key,
|
||||
)
|
||||
from core.security import get_current_user, get_tenant_from_token, resolve_tenant_id_required, validate_access_to_resource
|
||||
from core.storage_s3 import delete_object_if_exists, put_object_bytes
|
||||
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from . import dto, services
|
||||
from ..layouts_csv.customs_brokers.routes import router as imports_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_VU_CER_KEY_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
MAX_COVE_BYTES = 15 * 1024 * 1024 # 15 MB (xml/zip)
|
||||
|
||||
|
||||
def _resolve_tenant_id_int(
|
||||
current_user: dict,
|
||||
db=None,
|
||||
company_id: int = None,
|
||||
):
|
||||
"""Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
|
||||
return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
|
||||
|
||||
|
||||
def _remove_stored_vu_path(ref: Optional[str]) -> None:
|
||||
if not ref:
|
||||
return
|
||||
if ref.startswith("tenants/"):
|
||||
delete_object_if_exists(ref)
|
||||
elif os.path.isfile(ref):
|
||||
try:
|
||||
os.remove(ref)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# CSV import (mismo flujo que a76.imports: upload → scan → commit)
|
||||
router.include_router(imports_router, prefix="/customs-brokers/imports", tags=["customs_brokers / csv_import"])
|
||||
|
||||
customs_broker_crud = TenantCRUDRoutes(
|
||||
service=services.CustomsBrokerService,
|
||||
create_schema=dto.CustomsBrokerCreateDTO,
|
||||
update_schema=dto.CustomsBrokerUpdateDTO,
|
||||
response_schema=dto.CustomsBrokerResponseDTO,
|
||||
prefix="/customs-brokers",
|
||||
tags=[],
|
||||
resource_name="Customs Broker",
|
||||
id_name="broker_key",
|
||||
id_type=str,
|
||||
enable_list=True,
|
||||
list_permissions=["customs_brokers.view"],
|
||||
get_permissions=["customs_brokers.view"],
|
||||
create_permissions=["customs_brokers.create"],
|
||||
update_permissions=["customs_brokers.edit"],
|
||||
delete_permissions=["customs_brokers.delete"],
|
||||
)
|
||||
|
||||
router.include_router(customs_broker_crud.router)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/customs-brokers/{broker_key}",
|
||||
response_model=dto.CustomsBrokerResponseDTO,
|
||||
)
|
||||
def update_customs_broker(
|
||||
broker_key: str,
|
||||
broker_data: dto.CustomsBrokerUpdateDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualización parcial (PATCH).
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, ["customs_brokers.edit"])
|
||||
|
||||
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_broker = services.CustomsBrokerService.update(
|
||||
db=db,
|
||||
broker_key=broker_key,
|
||||
tenant_id=tenant_id,
|
||||
broker_data=broker_data,
|
||||
company_id=company_id
|
||||
)
|
||||
|
||||
if not updated_broker:
|
||||
raise HTTPException(status_code=400, detail="Error updating Customs Broker")
|
||||
|
||||
return updated_broker
|
||||
|
||||
|
||||
@router.put(
|
||||
"/customs-broker-vu/{broker_key}",
|
||||
response_model=dto.CustomsBrokerVUResponseDTO,
|
||||
)
|
||||
def update_customs_broker_vu(
|
||||
broker_key: str,
|
||||
vu_data: dto.CustomsBrokerVUCreateDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, ["customs_brokers.edit"])
|
||||
|
||||
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data, tenant_id, company_id)
|
||||
if not updated_vu:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker VU not found")
|
||||
return updated_vu
|
||||
|
||||
|
||||
@router.put(
|
||||
"/customs-broker-personnel/{broker_key}/{line}",
|
||||
response_model=dto.CustomsBrokerPersonnelDTO,
|
||||
)
|
||||
def update_customs_broker_personnel(
|
||||
broker_key: str,
|
||||
line: int,
|
||||
personnel_data: dto.CustomsBrokerPersonnelDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, ["customs_brokers.edit"])
|
||||
|
||||
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
updated_personnel = services.CustomsBrokerPersonnelService.update_personnel(
|
||||
db, broker_key, line, personnel_data, tenant_id, company_id
|
||||
)
|
||||
if not updated_personnel:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Customs Broker Personnel not found"
|
||||
)
|
||||
return updated_personnel
|
||||
|
||||
|
||||
@router.post(
|
||||
"/customs-brokers/{broker_key}/vu/upload",
|
||||
summary="Sube VU/DODA (CER, KEY, COVE) al bucket bajo tenants/.../customs_brokers/{id}/...",
|
||||
)
|
||||
async def upload_customs_broker_vu_file(
|
||||
broker_key: str,
|
||||
file_kind: str = Query(
|
||||
...,
|
||||
description=(
|
||||
"certificate (.cer), key (.key), cove (xml/zip/txt/pdf/json), "
|
||||
"doda_certificate (.cer), doda_key (.key), doda_cove (xml/zip/txt/pdf/json)"
|
||||
),
|
||||
),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Persiste el archivo bajo la misma jerarquía que logos/avatares (tenant/company/...).
|
||||
Guarda la clave S3 o ruta local en:
|
||||
- VU: certificate_path, key_path, xml_files_path
|
||||
- DODA: doda_certificate_path, doda_key_path, doda_xml_files_path
|
||||
"""
|
||||
validate_access_to_resource(db, company_id, current_user, ["customs_brokers.create"])
|
||||
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
|
||||
|
||||
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if not broker:
|
||||
raise HTTPException(status_code=404, detail="Customs Broker not found")
|
||||
|
||||
fk = file_kind.lower().strip()
|
||||
if fk not in (
|
||||
"certificate",
|
||||
"key",
|
||||
"cove",
|
||||
"doda_certificate",
|
||||
"doda_key",
|
||||
"doda_cove",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"file_kind must be certificate, key, cove, "
|
||||
"doda_certificate, doda_key, or doda_cove"
|
||||
),
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
max_bytes = MAX_COVE_BYTES if fk in ("cove", "doda_cove") else MAX_VU_CER_KEY_BYTES
|
||||
if len(content) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large (max {max_bytes // (1024 * 1024)} MB)",
|
||||
)
|
||||
|
||||
file_ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
vu = services.CustomsBrokerVUService.ensure_vu_for_broker(db, broker)
|
||||
broker_id = broker.id
|
||||
|
||||
field_name: str
|
||||
stored: str
|
||||
|
||||
try:
|
||||
if settings.use_s3_object_storage:
|
||||
if fk == "certificate":
|
||||
if file_ext != ".cer":
|
||||
raise HTTPException(status_code=400, detail="certificate must be .cer")
|
||||
key = customs_broker_vu_certificate_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
ct = "application/x-x509-ca-cert"
|
||||
field_name = "certificate_path"
|
||||
elif fk == "key":
|
||||
if file_ext != ".key":
|
||||
raise HTTPException(status_code=400, detail="key must be .key")
|
||||
key = customs_broker_vu_private_key_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
ct = "application/pkcs8"
|
||||
field_name = "key_path"
|
||||
elif fk == "cove":
|
||||
key = customs_broker_vu_cove_key(
|
||||
tenant_id,
|
||||
company_id,
|
||||
broker_id,
|
||||
timestamp,
|
||||
file.filename or "cove.xml",
|
||||
)
|
||||
ct = (
|
||||
file.content_type
|
||||
or mimetypes.guess_type(file.filename or "")[0]
|
||||
or "application/octet-stream"
|
||||
)
|
||||
field_name = "xml_files_path"
|
||||
elif fk == "doda_certificate":
|
||||
if file_ext != ".cer":
|
||||
raise HTTPException(status_code=400, detail="doda_certificate must be .cer")
|
||||
key = customs_broker_vu_doda_certificate_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
ct = "application/x-x509-ca-cert"
|
||||
field_name = "doda_certificate_path"
|
||||
elif fk == "doda_key":
|
||||
if file_ext != ".key":
|
||||
raise HTTPException(status_code=400, detail="doda_key must be .key")
|
||||
key = customs_broker_vu_doda_private_key_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
ct = "application/pkcs8"
|
||||
field_name = "doda_key_path"
|
||||
else:
|
||||
key = customs_broker_vu_doda_cove_key(
|
||||
tenant_id,
|
||||
company_id,
|
||||
broker_id,
|
||||
timestamp,
|
||||
file.filename or "doda.xml",
|
||||
)
|
||||
ct = (
|
||||
file.content_type
|
||||
or mimetypes.guess_type(file.filename or "")[0]
|
||||
or "application/octet-stream"
|
||||
)
|
||||
field_name = "doda_xml_files_path"
|
||||
|
||||
old = getattr(vu, field_name)
|
||||
_remove_stored_vu_path(old)
|
||||
put_object_bytes(key, content, content_type=ct)
|
||||
logger.info(
|
||||
"Customs broker VU upload kind=%s key=%s bytes=%s",
|
||||
fk,
|
||||
key,
|
||||
len(content),
|
||||
)
|
||||
stored = key
|
||||
else:
|
||||
base = os.path.join(
|
||||
"uploads", "customs_brokers", str(company_id), str(broker_id)
|
||||
)
|
||||
if fk == "certificate":
|
||||
if file_ext != ".cer":
|
||||
raise HTTPException(status_code=400, detail="certificate must be .cer")
|
||||
key = customs_broker_vu_certificate_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
field_name = "certificate_path"
|
||||
subdir = "certificates"
|
||||
elif fk == "key":
|
||||
if file_ext != ".key":
|
||||
raise HTTPException(status_code=400, detail="key must be .key")
|
||||
key = customs_broker_vu_private_key_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
field_name = "key_path"
|
||||
subdir = "keys"
|
||||
elif fk == "cove":
|
||||
try:
|
||||
key = customs_broker_vu_cove_key(
|
||||
tenant_id,
|
||||
company_id,
|
||||
broker_id,
|
||||
timestamp,
|
||||
file.filename or "cove.xml",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
field_name = "xml_files_path"
|
||||
subdir = "cove"
|
||||
elif fk == "doda_certificate":
|
||||
if file_ext != ".cer":
|
||||
raise HTTPException(status_code=400, detail="doda_certificate must be .cer")
|
||||
key = customs_broker_vu_doda_certificate_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
field_name = "doda_certificate_path"
|
||||
subdir = "doda/certificates"
|
||||
elif fk == "doda_key":
|
||||
if file_ext != ".key":
|
||||
raise HTTPException(status_code=400, detail="doda_key must be .key")
|
||||
key = customs_broker_vu_doda_private_key_key(
|
||||
tenant_id, company_id, broker_id, timestamp, file_ext
|
||||
)
|
||||
field_name = "doda_key_path"
|
||||
subdir = "doda/keys"
|
||||
else:
|
||||
try:
|
||||
key = customs_broker_vu_doda_cove_key(
|
||||
tenant_id,
|
||||
company_id,
|
||||
broker_id,
|
||||
timestamp,
|
||||
file.filename or "doda.xml",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
field_name = "doda_xml_files_path"
|
||||
subdir = "doda/cove"
|
||||
|
||||
fname = key.rsplit("/", 1)[-1]
|
||||
dest_dir = os.path.join(base, subdir)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
path = os.path.join(dest_dir, fname)
|
||||
old = getattr(vu, field_name)
|
||||
_remove_stored_vu_path(old)
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
logger.info(
|
||||
"Customs broker VU upload kind=%s path=%s bytes=%s",
|
||||
fk,
|
||||
path,
|
||||
len(content),
|
||||
)
|
||||
stored = path
|
||||
|
||||
setattr(vu, field_name, stored)
|
||||
db.add(vu)
|
||||
db.commit()
|
||||
db.refresh(vu)
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error saving file: {str(e)}"
|
||||
) from e
|
||||
|
||||
return {
|
||||
"message": "File uploaded successfully",
|
||||
"file_kind": fk,
|
||||
"field": field_name,
|
||||
"path": stored,
|
||||
"broker_key": broker_key,
|
||||
"company_id": company_id,
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from . import dto, models
|
||||
|
||||
|
||||
class CustomsBrokerService:
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, broker_key: str, tenant_id: int, company_id: int):
|
||||
"""Get a customs broker by broker_key with tenant/company validation"""
|
||||
return (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
filters: dict = None,
|
||||
sort_by: str = None,
|
||||
sort_order: str = "asc",
|
||||
):
|
||||
"""Get all customs brokers for a tenant/company with pagination"""
|
||||
query = db.query(models.CustomsBroker).filter(
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
|
||||
if filters:
|
||||
# Implement filters if needed in the future
|
||||
pass
|
||||
|
||||
# Apply sorting
|
||||
if sort_by:
|
||||
column = getattr(models.CustomsBroker, 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(models.CustomsBroker.id.desc())
|
||||
else:
|
||||
query = query.order_by(models.CustomsBroker.id.desc())
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, broker_data: dto.CustomsBrokerCreateDTO, tenant_id: int, company_id: int):
|
||||
"""Create a new customs broker"""
|
||||
broker_dict = broker_data.model_dump()
|
||||
broker_dict["tenant_id"] = tenant_id
|
||||
broker_dict["company_id"] = company_id
|
||||
|
||||
new_broker = models.CustomsBroker(**broker_dict)
|
||||
db.add(new_broker)
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(new_broker)
|
||||
return new_broker
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise ValueError("La clave del agente ya existe o hay datos duplicados.")
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, broker_key: str, tenant_id: int, broker_data: dto.CustomsBrokerUpdateDTO, company_id: int):
|
||||
"""Update an existing customs broker"""
|
||||
broker = CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if broker:
|
||||
for key, value in broker_data.model_dump(exclude_unset=True).items():
|
||||
setattr(broker, key, value)
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(broker)
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise ValueError("Los datos duplicados no pueden ser guardados o hay un conflicto de integridad.")
|
||||
return broker
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, broker_key: str, tenant_id: int, company_id: int):
|
||||
"""Delete a customs broker"""
|
||||
broker = CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
|
||||
if broker:
|
||||
db.delete(broker)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class CustomsBrokerVUService:
|
||||
@staticmethod
|
||||
def get_by_broker_key(db: Session, broker_key: str):
|
||||
return (
|
||||
db.query(models.CustomsBrokerVU)
|
||||
.join(models.CustomsBroker)
|
||||
.filter(models.CustomsBroker.broker_key == broker_key)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_vu(db: Session, vu_data: dto.CustomsBrokerVUCreateDTO):
|
||||
new_vu = models.CustomsBrokerVU(**vu_data.dict())
|
||||
db.add(new_vu)
|
||||
db.commit()
|
||||
db.refresh(new_vu)
|
||||
return new_vu
|
||||
|
||||
@staticmethod
|
||||
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO, tenant_id: int, company_id: int):
|
||||
# We need the custom broker ID to insert a new VU
|
||||
broker = (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not broker:
|
||||
return None
|
||||
|
||||
vu = db.query(models.CustomsBrokerVU).filter(models.CustomsBrokerVU.customs_broker_id == broker.id).first()
|
||||
|
||||
if vu:
|
||||
# Update existing
|
||||
safe_updates = vu_data.model_dump(
|
||||
exclude_unset=True,
|
||||
exclude={"tenant_id", "company_id"},
|
||||
)
|
||||
for key, value in safe_updates.items():
|
||||
setattr(vu, key, value)
|
||||
db.commit()
|
||||
db.refresh(vu)
|
||||
return vu
|
||||
else:
|
||||
# Create new
|
||||
new_vu_data = vu_data.model_dump()
|
||||
new_vu_data["tenant_id"] = tenant_id
|
||||
new_vu_data["company_id"] = company_id
|
||||
new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data)
|
||||
db.add(new_vu)
|
||||
db.commit()
|
||||
db.refresh(new_vu)
|
||||
return new_vu
|
||||
|
||||
@staticmethod
|
||||
def delete_vu(db: Session, broker_key: str):
|
||||
vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key)
|
||||
if vu:
|
||||
db.delete(vu)
|
||||
db.commit()
|
||||
return vu
|
||||
|
||||
@staticmethod
|
||||
def ensure_vu_for_broker(db: Session, broker: models.CustomsBroker) -> models.CustomsBrokerVU:
|
||||
"""Crea fila VU vacía si no existe (p. ej. antes de subir CER/KEY/COVE al bucket)."""
|
||||
vu = (
|
||||
db.query(models.CustomsBrokerVU)
|
||||
.filter(models.CustomsBrokerVU.customs_broker_id == broker.id)
|
||||
.first()
|
||||
)
|
||||
if vu:
|
||||
return vu
|
||||
vu = models.CustomsBrokerVU(
|
||||
customs_broker_id=broker.id,
|
||||
tenant_id=broker.tenant_id,
|
||||
company_id=broker.company_id,
|
||||
)
|
||||
db.add(vu)
|
||||
db.commit()
|
||||
db.refresh(vu)
|
||||
return vu
|
||||
|
||||
|
||||
class CustomsBrokerPersonnelService:
|
||||
@staticmethod
|
||||
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int, tenant_id: int, company_id: int):
|
||||
return (
|
||||
db.query(models.CustomsBrokerPersonnel)
|
||||
.join(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBrokerPersonnel.line == line,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO, tenant_id: int, company_id: int):
|
||||
broker = (
|
||||
db.query(models.CustomsBroker)
|
||||
.filter(
|
||||
models.CustomsBroker.broker_key == broker_key,
|
||||
models.CustomsBroker.tenant_id == tenant_id,
|
||||
models.CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not broker:
|
||||
return None
|
||||
|
||||
new_personnel_data = personnel_data.model_dump()
|
||||
new_personnel_data["tenant_id"] = tenant_id
|
||||
new_personnel_data["company_id"] = company_id
|
||||
new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data)
|
||||
db.add(new_personnel)
|
||||
db.commit()
|
||||
db.refresh(new_personnel)
|
||||
return new_personnel
|
||||
|
||||
@staticmethod
|
||||
def update_personnel(
|
||||
db: Session,
|
||||
broker_key: str,
|
||||
line: int,
|
||||
personnel_data: dto.CustomsBrokerPersonnelDTO,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
):
|
||||
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
|
||||
db, broker_key, line, tenant_id, company_id
|
||||
)
|
||||
if personnel:
|
||||
for key, value in personnel_data.model_dump(exclude_unset=True).items():
|
||||
setattr(personnel, key, value)
|
||||
db.commit()
|
||||
db.refresh(personnel)
|
||||
return personnel
|
||||
else:
|
||||
return CustomsBrokerPersonnelService.create_personnel(
|
||||
db, broker_key, personnel_data, tenant_id, company_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_personnel(db: Session, broker_key: str, line: int):
|
||||
personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line(
|
||||
db, broker_key, line
|
||||
)
|
||||
if personnel:
|
||||
db.delete(personnel)
|
||||
db.commit()
|
||||
return personnel
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user