diff --git a/backend/alembic/versions/e76_app_settings_add_table.py b/backend/alembic/versions/e76_app_settings_add_table.py new file mode 100644 index 00000000..ca4bb71d --- /dev/null +++ b/backend/alembic/versions/e76_app_settings_add_table.py @@ -0,0 +1,40 @@ +"""add_app_settings_table + +Revision ID: e76_app_settings +Revises: c1a2b3d4e5f6 +Create Date: 2026-03-27 16:10:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e76_app_settings' +down_revision = 'd4e5f6a7b8c9' +branch_labels = None +depends_on = None + +def upgrade(): + # Create a76.app_settings table + op.create_table( + 'app_settings', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=True), + sa.Column('company_id', sa.Integer(), nullable=True), + sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'company_id', name='uq_app_settings_tenant_company'), + schema='a76' + ) + op.create_index(op.f('ix_a76_app_settings_company_id'), 'app_settings', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_app_settings_tenant_id'), 'app_settings', ['tenant_id'], unique=False, schema='a76') + +def downgrade(): + op.drop_index(op.f('ix_a76_app_settings_tenant_id'), table_name='app_settings', schema='a76') + op.drop_index(op.f('ix_a76_app_settings_company_id'), table_name='app_settings', schema='a76') + op.drop_table('app_settings', schema='a76') diff --git a/backend/api/v1/common/base_models.py b/backend/api/v1/common/base_models.py index 78b26204..c58d8ffe 100644 --- a/backend/api/v1/common/base_models.py +++ b/backend/api/v1/common/base_models.py @@ -5,8 +5,8 @@ from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import func -class TimestampMixin: - """Mixin for common timestamp fields""" +class BaseTimestampMixin: + """Mixin for basic timestamp fields (no soft delete)""" created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now() @@ -14,6 +14,11 @@ class TimestampMixin: updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), onupdate=func.now() ) + + +class TimestampMixin(BaseTimestampMixin): + """Mixin for common timestamp fields including soft delete""" + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/api/v1/modules/a76/app_settings/__init__.py b/backend/api/v1/modules/a76/app_settings/__init__.py new file mode 100644 index 00000000..2af0782c --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/__init__.py @@ -0,0 +1 @@ +# Module initialization for app_settings diff --git a/backend/api/v1/modules/a76/app_settings/models.py b/backend/api/v1/modules/a76/app_settings/models.py new file mode 100644 index 00000000..6e0b0e7a --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/models.py @@ -0,0 +1,33 @@ +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"" diff --git a/backend/api/v1/modules/a76/app_settings/routes.py b/backend/api/v1/modules/a76/app_settings/routes.py new file mode 100644 index 00000000..dde03984 --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/routes.py @@ -0,0 +1,59 @@ +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 + +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) +): + """ + Returns the final merged configuration for a company. + Merges Global -> Tenant -> Company levels. + """ + try: + return AppSettingsService.get_resolved_settings(db, tenant_id, company_id) + 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) +): + """ + Creates or updates an override for a specific level (Global, Tenant, or Company). + """ + try: + data = payload.settings.model_dump(exclude_unset=True) + return AppSettingsService.upsert_settings( + db, + payload.tenant_id, + payload.company_id, + data + ) + 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) +): + """ + Alias for upsert_settings. + """ + return upsert_settings(payload, db) diff --git a/backend/api/v1/modules/a76/app_settings/schemas.py b/backend/api/v1/modules/a76/app_settings/schemas.py new file mode 100644 index 00000000..4bd2118b --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/schemas.py @@ -0,0 +1,1042 @@ +from typing import Optional, Any, Dict +from pydantic import BaseModel, Field +from decimal import Decimal + +# --- Legacy Table Domains (Tarea 1) --- + +class SSisGenSettings(BaseModel): + """ + Legacy Table: SSisGen + General system parameters migrated from Clarion/WinDev. + """ + consecutivo: Optional[int] = None + dta: Optional[int] = None + dtaexpo: Optional[int] = None + subempresa: Optional[str] = None + patharch: Optional[str] = None + patharchtransmision: Optional[str] = None + pathtransexpo: Optional[str] = None + pathrespuesta: Optional[str] = None + patharchped: Optional[str] = None + patharchpedconsm: Optional[str] = None + pathgenimpotemp: Optional[str] = None + pathgenexpo: Optional[str] = None + actseguridad: Optional[int] = None + controldes: Optional[int] = None + diadesactual: Optional[int] = None + diavencimiento: Optional[int] = None + mensajevenc: Optional[int] = None + fechades: Optional[int] = None + factoriva: Optional[Decimal] = None + validasifra: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + calvalbasetcped: Optional[int] = None + calvalbasetcpedexpo: Optional[int] = None + filtrocantidad: Optional[Decimal] = None + muestraarchcodbarras: Optional[int] = None + datoshist: Optional[int] = None + tipovenro: Optional[str] = None + cantvenro: Optional[int] = None + costoplanta: Optional[Decimal] = None + firmapacking: Optional[int] = None + advertenciatm: Optional[int] = None + tomarsaldosvenc: Optional[int] = None + costoimpofijo: Optional[int] = None + valparteexiste: Optional[int] = None + valmanifusado: Optional[int] = None + temporalfechapago: Optional[int] = None + asignadiasantdesc: Optional[int] = None + diasantdesc: Optional[int] = None + deshabilitardescparte: Optional[int] = None + deshabilitardescparteing: Optional[int] = None + asignafracameparte: Optional[int] = None + validadecencant: Optional[int] = None + usartranspamedocame: Optional[int] = None + mostraradvertenciaro: Optional[int] = None + escondaamexpacking: Optional[int] = None + calcdutypacking: Optional[int] = None + parammultiples: Optional[int] = None + fraccnivelpais: Optional[int] = None + covefechaemision: Optional[int] = None + valordllstcfacturaexpo: Optional[int] = None + interfaceaaconsolidada: Optional[int] = None + interfaceaatcfpff: Optional[str] = None + incluirobscoveobsimpo: Optional[int] = None + agregarincreimpo: Optional[int] = None + componentebom: Optional[int] = None + limitesubensamble: Optional[int] = None + actpdfreportes: Optional[int] = None + patharchpdfimpo: Optional[str] = None + patharchpdfexpo: Optional[str] = None + noimprimircons: Optional[int] = None + mostraradvertenciarovalor: Optional[int] = None + partesypedimentosporcliente: Optional[str] = None + mensajesvurfc: Optional[int] = None + mostrarpackinglistingles: Optional[int] = None + omitirempaqueencodigobarras: Optional[int] = None + restringepaisimpo: Optional[int] = None + bloqueoaldesactivarnumerodeparte: Optional[int] = None + restringpaisexpo: Optional[int] = None + geninformeanexo31: Optional[str] = None + utilizarfechapagopeddeundiaanterior: Optional[int] = None + utilizarequivalenciasdeumpornumerodeparte: Optional[int] = None + utilizartitulosalternativosimpresionfactura: Optional[int] = None + usarfactorconversionpornumerodeparte: Optional[int] = None + usarvude128o256: Optional[int] = None + usartcdelafechapagopedimpoendescarga: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + agregarnumeroembarque: Optional[int] = None + utilizarumdeexistenciaentransmisionvu: Optional[int] = None + utilizarcodigodebrokerdeclienteenmainx30: Optional[int] = None + hojacalculosepararincrementablesanexo3: Optional[int] = None + hojacalculodesglosefacturaanexo3: Optional[int] = None + usarvaloragregadoenfacturaamericana: Optional[int] = None + ocultarinformacionfraccion: Optional[int] = None + resaltarsaldostempconcolor: Optional[int] = None + valoragregadoenfacturamexicana: Optional[int] = None + validarsectorprosecr8: Optional[int] = None + agregarsubtotalinterfazaa: Optional[int] = None + utilizarnombregenericomainx30: Optional[int] = None + utilizartcrespectoatipoped: Optional[int] = None + tomardecimalescompletos: Optional[int] = None + incluirremesaeninterfazaawinsaai: Optional[int] = None + cambiarpesosporcostounitario: Optional[int] = None + utilizarsolopartesnaftaenco: Optional[int] = None + bloqueodeediciondefacturas: Optional[int] = None + reasignafraccionclase: Optional[int] = None + calcularcostounitarioenbaseavalortotal: Optional[int] = None + parametroauxiliar: Optional[int] = None + usarcontroldefechasdeversion: Optional[int] = None + desactivaciondemodulos: Optional[int] = None + imprimirfacturaalterna: Optional[int] = None + activarexpedienteelectronico: Optional[int] = None + activarcatalogofraccionesamericanassifra: Optional[int] = None + informacionamericanasubtotal: Optional[int] = None + mostrarprogramaimmexprosec: Optional[int] = None + costounitarioporempaquefac: Optional[int] = None + transmitirfacalterna: Optional[int] = None + muestra_copias_codbarras: Optional[int] = None + activar_revision_fracciones: Optional[int] = None + usarcoveenarchsaaim3: Optional[int] = None + downloadftp: Optional[str] = None + minsdownlftp: Optional[int] = None + downloadftppath: Optional[str] = None + descargarftpolocal: Optional[str] = None + serverftp: Optional[str] = None + userftp: Optional[str] = None + passwordftp: Optional[str] = None + directorioftp: Optional[str] = None + pathlocalparadescde: Optional[str] = None + agregarremplazarautomatico: Optional[str] = None + activarprocesovequipment: Optional[int] = None + activarprocesodesperdiciojdedwards: Optional[int] = None + usarfechaemisionfactura: Optional[int] = None + campo18valsaaim3: Optional[int] = None + actvaloragre: Optional[int] = None + valoragregadogen: Optional[Decimal] = None + validarseries: Optional[int] = None + emailas: Optional[int] = None + afostrofe: Optional[int] = None + codigobarrasesp: Optional[int] = None + asignainfoparte: Optional[int] = None + pathnaftaccs: Optional[str] = None + + + +class SSisGen2Settings(BaseModel): + """ + Legacy Table: SSisGen2 + Extended system parameters. + """ + consecutivo: Optional[int] = None + actvaloragre: Optional[int] = None + valoragregadogen: Optional[Decimal] = None + + +class SSisGen3Settings(BaseModel): + """ + Legacy Table: SSisGen3 + Additional name-value parameters. + """ + parametro: Optional[str] = None + valorparametro: Optional[int] = None + + +class SSisMexSettings(BaseModel): + """Legacy Table: SSisMex (Mexican Purchases)""" + consecutivo: Optional[int] = None + prefijocm: Optional[str] = None + consecutivocm: Optional[str] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + flete: Optional[Decimal] = None + paisorigenmex: Optional[int] = None + numpartemex: Optional[int] = None + firmafmex: Optional[str] = None + fraccionimp: Optional[int] = None + tipofraccmex: Optional[int] = None + tasafraccmex: Optional[int] = None + umequivalentemex: Optional[int] = None + numparteame: Optional[int] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + umequivalenteame: Optional[int] = None + firmafame: Optional[str] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + umauxiliarmex: Optional[int] = None + umalternamex: Optional[int] = None + transportista: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + packingordencompra: Optional[int] = None + firmaelectronica: Optional[str] = None + incluirlineadelpo: Optional[int] = None + lineapersonaaa: Optional[int] = None + agregarvaenprodterminados: Optional[int] = None + ocultarfechahora: Optional[int] = None + + +class SSisDefSettings(BaseModel): + """Legacy Table: SSisDef (Definitive Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + paisorigenmex: Optional[int] = None + umequivalentemex: Optional[int] = None + umauxiliarmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + umequivalenteame: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + umalternamex: Optional[int] = None + restringeimpopt: Optional[int] = None + partecomplementariamex: Optional[int] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + metvalor: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + packingpedclave: Optional[int] = None + porordenporocpack: Optional[str] = None + actualizarpartepartida: Optional[int] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + partecomplemexpartida: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + packingordencompra: Optional[int] = None + firmaelectronica: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + incluirlineadelpo: Optional[int] = None + lineapersonaaa: Optional[int] = None + agregarvaenprodterminados: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + ocultarfechahora: Optional[int] = None + + +class SSisExpoSettings(BaseModel): + """Legacy Table: SSisExpo (Exports)""" + tipofactura: Optional[str] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + prefijoexpo: Optional[str] = None + consecutivoexpo: Optional[str] = None + pedimento: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + vendidopor: Optional[str] = None + aaduanal: Optional[str] = None + tipo: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + tipomoneda: Optional[str] = None + moneda: Optional[str] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + numpartemex: Optional[int] = None + paisorigenmex: Optional[int] = None + fraccionexp: Optional[int] = None + tasafraccmex: Optional[int] = None + costounitmex: Optional[int] = None + ordencompmex: Optional[int] = None + consfacmex: Optional[int] = None + umequivalentemex: Optional[int] = None + ocultarempaquemex: Optional[int] = None + costounitmpmex: Optional[int] = None + codigobarras: Optional[int] = None + partecomplementariamex: Optional[int] = None + valordllsmexpesos: Optional[int] = None + cantpncodbar: Optional[int] = None + dtacerocodbar: Optional[int] = None + firmafmex: Optional[str] = None + numparteame: Optional[int] = None + firmafame: Optional[str] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + ordencompame: Optional[int] = None + umequivalenteame: Optional[int] = None + fdainformacioname: Optional[int] = None + paisopcame: Optional[int] = None + muestrafree: Optional[int] = None + fraccmexdes: Optional[int] = None + tipofraccmexdes: Optional[int] = None + colvalordes: Optional[int] = None + colpesodes: Optional[int] = None + descargaclase: Optional[int] = None + descargasust: Optional[int] = None + descargadef: Optional[int] = None + ventqueueact: Optional[int] = None + codigoscac: Optional[int] = None + formadesperdicio: Optional[str] = None + calvalorbasecosto: Optional[int] = None + calvabasecapt: Optional[int] = None + calpesobasedescarga: Optional[int] = None + calempaquebasecapt: Optional[int] = None + ocultcantcons: Optional[int] = None + prefijofraccmult: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + costamepaisparte: Optional[int] = None + basecostompototal: Optional[str] = None + umequivalentemex2: Optional[int] = None + umequivalenteame2: Optional[int] = None + totalequivmex: Optional[int] = None + totalequivame: Optional[int] = None + infoadicional: Optional[str] = None + restringeexpomp: Optional[int] = None + calvatotagremp: Optional[str] = None + activaadvpedrtv1: Optional[int] = None + asignafrac9801: Optional[int] = None + valorexcfrac9801: Optional[Decimal] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + scrapsolonoduty: Optional[int] = None + descextraamepartes: Optional[int] = None + incambioregdesc: Optional[int] = None + escambioregimen: Optional[str] = None + descnocontemcant: Optional[int] = None + metvalor: Optional[str] = None + ocultarempaqueame: Optional[int] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + pesonetodesdiffac: Optional[Decimal] = None + enviadoporvendidopor: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + ocultacolva: Optional[int] = None + calccostosamebasemex: Optional[int] = None + proveedorexportador: Optional[str] = None + tipofraccmex: Optional[int] = None + partecomplemexpartida: Optional[int] = None + validacantparcandes: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + costosamepais: Optional[str] = None + incluirfracamebil: Optional[int] = None + solicitarpswdactual: Optional[int] = None + solicitarpswddesactual: Optional[int] = None + buscarsaldosrecientes: Optional[int] = None + packingordenventa: Optional[int] = None + imprimirordenventa: Optional[int] = None + firmaelectronica: Optional[str] = None + mpf: Optional[int] = None + valormpf: Optional[Decimal] = None + colcantporpeso: Optional[int] = None + packingpedclave: Optional[int] = None + generafacturaimd: Optional[str] = None + generafacturaimdenbasea: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + incluirlineadelpo: Optional[int] = None + valorpacking1dlls: Optional[int] = None + tomarvalormexenfacame: Optional[int] = None + lineapersonaaa: Optional[int] = None + calamebasemexmp: Optional[int] = None + calamebasemexva: Optional[int] = None + calamebasemexempaque: Optional[int] = None + usarvaenfacturaamericana: Optional[int] = None + activarfechacortesaldos: Optional[int] = None + fechacortesaldos: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + respaldardescargaendescargam: Optional[int] = None + ocultarfechahora: Optional[int] = None + calcularvaloresamericanosenbaseapartida: Optional[int] = None + validarestatusped: Optional[int] = None + descapais: Optional[int] = None + + +class SSisImpoSettings(BaseModel): + """Legacy Table: SSisImpo (General Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + prefijocr: Optional[str] = None + consecutivoimpo: Optional[str] = None + consecutivocr: Optional[int] = None + porparteclasemex: Optional[str] = None + porparteclaseame: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + paisorigenmex: Optional[int] = None + umequivalentemex: Optional[int] = None + umauxiliarmex: Optional[int] = None + perpagrenglon: Optional[int] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + umequivalenteame: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + valorlimitepar: Optional[Decimal] = None + umalternamex: Optional[int] = None + restringeimpopt: Optional[int] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + leyendafacame: Optional[str] = None + calcigibasecapt: Optional[int] = None + umequivalentemex2: Optional[int] = None + umequivalenteame2: Optional[int] = None + partecomplementariamex: Optional[int] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + metvalor: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + packingpedclave: Optional[int] = None + porordenporocpack: Optional[str] = None + actualizarpartepartida: Optional[int] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + clavebultos: Optional[str] = None + partecomplemexpartida: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + valorlimiteparmin: Optional[Decimal] = None + packingordencompra: Optional[int] = None + firmaelectronica: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + incluirlineadelpo: Optional[int] = None + lineapersonaaa: Optional[int] = None + agregarvaenprodterminados: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + ocultarfechahora: Optional[int] = None + + + + + +class QSisCMexSettings(BaseModel): + """Legacy Table: QSisCMex (Fixed Asset Mexican Purchase)""" + consecutivo: Optional[int] = None + prefijocm: Optional[str] = None + consecutivocm: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + flete: Optional[Decimal] = None + paisorigenmex: Optional[int] = None + firmafmex: Optional[str] = None + fraccionimp: Optional[int] = None + tipofraccmex: Optional[int] = None + tasafraccmex: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + transportista: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + packingordencompra: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisDefSettings(BaseModel): + """Legacy Table: QSisDef (Fixed Asset Definitive Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + generarassettag: Optional[int] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigenmex: Optional[int] = None + paisorigename: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + clavemoneda: Optional[str] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + actualizarpartepartida: Optional[int] = None + packingordencompra: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisGenSettings(BaseModel): + """Legacy Table: QSisGen (Fixed Asset General)""" + consecutivo: Optional[int] = None + dta: Optional[int] = None + dtaexpo: Optional[int] = None + subempresa: Optional[str] = None + patharch: Optional[str] = None + patharchtransmision: Optional[str] = None + pathrespuesta: Optional[str] = None + patharchped: Optional[str] = None + patharchpedconsm: Optional[str] = None + pathgenimpotemp: Optional[str] = None + pathgenexpo: Optional[str] = None + aplicapermat: Optional[int] = None + actseguridad: Optional[int] = None + controldes: Optional[int] = None + diadesactual: Optional[int] = None + fechades: Optional[int] = None + ubiplanta: Optional[str] = None + datoshistoricos: Optional[int] = None + tipovenro: Optional[str] = None + cantvenro: Optional[int] = None + omitirimposubpcodbarras: Optional[str] = None + muestraarchcodbarras: Optional[int] = None + calvalbasetcped: Optional[int] = None + calvalbasetcpedexpo: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + factoriva: Optional[Decimal] = None + filtrocantidad: Optional[Decimal] = None + firmapacking: Optional[int] = None + advertenciatm: Optional[int] = None + calcdepreciacion: Optional[str] = None + valmanifusado: Optional[int] = None + validadecencant: Optional[int] = None + mostraradvertenciaro: Optional[int] = None + usartranspamedocame: Optional[int] = None + escondaamexpacking: Optional[int] = None + cantvscantseries: Optional[int] = None + covefechaemision: Optional[int] = None + interfaceaaconsolidada: Optional[int] = None + interfaceaatcfpff: Optional[str] = None + incluirobscoveobsimpo: Optional[int] = None + agregarincreimpo: Optional[int] = None + repdescargolineal: Optional[int] = None + identificadornodoseriecove: Optional[int] = None + actpdfreportes: Optional[int] = None + patharchpdfimpo: Optional[str] = None + patharchpdfexpo: Optional[str] = None + mensajesvurfc: Optional[int] = None + mostrarpackinglistingles: Optional[int] = None + enviarsubpartidascove: Optional[int] = None + restringepaisimpo: Optional[int] = None + restringpaisexpo: Optional[int] = None + utilizarfechapagopeddeundiaanterior: Optional[int] = None + utilizartitulosalternativosimpresionfactura: Optional[int] = None + usartcdelafechapagopedimpoendescarga: Optional[int] = None + usarvude128o256: Optional[int] = None + agregarnumeroembarque: Optional[int] = None + utilizarumdeexistenciaentransmisionvu: Optional[int] = None + utilizarcodigodebrokerdeclienteenmainx30: Optional[int] = None + hojacalculosepararincrementablesanexo3: Optional[int] = None + hojacalculodesglosefacturaanexo3: Optional[int] = None + utilizarnombregenericomainx30: Optional[int] = None + utilizartcrespectoatipoped: Optional[int] = None + utilizarsolopartesnaftaenco: Optional[int] = None + cambiarpesosporcostounitario: Optional[int] = None + bloqueodeediciondefacturas: Optional[int] = None + calcularcostounitarioenbaseavalortotalscaf: Optional[int] = None + impresionfacturaalterna: Optional[int] = None + transmitirfacalterna: Optional[int] = None + validarseries: Optional[int] = None + emailas: Optional[int] = None + afostrofe: Optional[int] = None + codigobarrasesp: Optional[int] = None + muestra_copias_codbarras: Optional[int] = None + asignainfoparte: Optional[int] = None + pathnaftaccs: Optional[str] = None + + +class QSisExpoRepSettings(BaseModel): + """Legacy Table: QSisExpoRep (Fixed Asset Export Repeat?)""" + tipofactura: Optional[str] = None + prefijoexpo: Optional[str] = None + consecutivoexpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + vendidopor: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + codigobarras: Optional[int] = None + cantpncodbar: Optional[int] = None + dtacerocodbar: Optional[int] = None + cbarrasvalor: Optional[str] = None + firmafmex: Optional[str] = None + fraccionexp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + numpartemex: Optional[int] = None + paisorigenmex: Optional[int] = None + firmafame: Optional[str] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + numparteame: Optional[int] = None + codigoscac: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + mostrarvalact: Optional[int] = None + pepsporclase: Optional[int] = None + pepsporfraccion: Optional[int] = None + pepspordescripcion: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + ventqueueact: Optional[int] = None + valfacttc: Optional[str] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + enviadoporvendidopor: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + proveedorexportador: Optional[str] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + solicitarpswdactual: Optional[int] = None + solicitarpswddesactual: Optional[int] = None + packingordenventa: Optional[int] = None + imprimirpo: Optional[int] = None + fdainformacioname: Optional[int] = None + packingpedclave: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisImpoSettings(BaseModel): + """Legacy Table: QSisImpo (Fixed Asset Import)""" + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigenmex: Optional[int] = None + paisorigename: Optional[int] = None + impordencomp: Optional[int] = None + generarassettag: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + clavemoneda: Optional[str] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + perpagrenglon: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + actualizarpartepartida: Optional[int] = None + packingordencompra: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class QSisImpoRepSettings(BaseModel): + """Legacy Table: QSisImpoRep (Fixed Asset Import Repeat?)""" + tipofactura: Optional[str] = None + consecutivo: Optional[int] = None + prefijoimpo: Optional[str] = None + consecutivoimpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + firmafmex: Optional[str] = None + firmafame: Optional[str] = None + codigobarras: Optional[int] = None + fraccionimp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + fraccioname: Optional[int] = None + paisorigenmex: Optional[int] = None + paisorigename: Optional[int] = None + impordencomp: Optional[int] = None + generarassettag: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + clavemoneda: Optional[str] = None + controlremesa: Optional[str] = None + remesainicio: Optional[int] = None + remesafinal: Optional[int] = None + perpagrenglon: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + ventqueueact: Optional[int] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + numpartemex: Optional[int] = None + numparteame: Optional[int] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + packingordencompra: Optional[int] = None + solicitarcontrasenaadministrador: Optional[int] = None + + +class QSisExpoSettings(BaseModel): + """Legacy Table: QSisExpo (Fixed Asset Export)""" + tipofactura: Optional[str] = None + escambioregimen: Optional[str] = None + prefijoexpo: Optional[str] = None + consecutivoexpo: Optional[str] = None + pedimento: Optional[str] = None + tipo: Optional[str] = None + proveedor: Optional[str] = None + vendidoconsignado: Optional[str] = None + vendidoa: Optional[str] = None + enviadotransferido: Optional[str] = None + enviadoa: Optional[str] = None + vendidopor: Optional[str] = None + aaduanal: Optional[str] = None + incoterm: Optional[str] = None + flete: Optional[Decimal] = None + identificador: Optional[str] = None + codigobarras: Optional[int] = None + cantpncodbar: Optional[int] = None + dtacerocodbar: Optional[int] = None + cbarrasvalor: Optional[str] = None + firmafmex: Optional[str] = None + fraccionexp: Optional[int] = None + tasafraccmex: Optional[int] = None + tipofraccmex: Optional[int] = None + numpartemex: Optional[int] = None + paisorigenmex: Optional[int] = None + costounitmex: Optional[int] = None + ocultarempaquemex: Optional[int] = None + firmafame: Optional[str] = None + fraccioname: Optional[int] = None + paisorigename: Optional[int] = None + fdainformacioname: Optional[int] = None + numparteame: Optional[int] = None + codigoscac: Optional[int] = None + impordencomp: Optional[int] = None + decimalespeso: Optional[int] = None + decimalescant: Optional[int] = None + decimalesvalor: Optional[int] = None + decimalescosto: Optional[int] = None + tipomoneda: Optional[str] = None + clavemoneda: Optional[str] = None + tipomn: Optional[str] = None + tipome: Optional[str] = None + mostrarvalact: Optional[int] = None + pepsporclase: Optional[int] = None + pepsporfraccion: Optional[int] = None + pepspordescripcion: Optional[int] = None + pepsporfraccalterna: Optional[int] = None + cantlimite: Optional[Decimal] = None + pesolimite: Optional[Decimal] = None + valorlimite: Optional[Decimal] = None + ventqueueact: Optional[int] = None + valfacttc: Optional[str] = None + metvalor: Optional[str] = None + pagoimpuesto: Optional[str] = None + formapago: Optional[str] = None + colcantporpeso: Optional[int] = None + transportista: Optional[str] = None + aaduanalame: Optional[str] = None + enviadoporvendidopor: Optional[str] = None + conductor: Optional[str] = None + transporte: Optional[str] = None + numtransporte: Optional[str] = None + observacione: Optional[str] = None + observacioni: Optional[str] = None + proveedorexportador: Optional[str] = None + cantlimitemin: Optional[Decimal] = None + pesolimitemin: Optional[Decimal] = None + valorlimitemin: Optional[Decimal] = None + solicitarpswdactual: Optional[int] = None + solicitarpswddesactual: Optional[int] = None + packingordenventa: Optional[int] = None + imprimirpo: Optional[int] = None + packingpedclave: Optional[int] = None + generafacturaimd: Optional[str] = None + generafacturaimdenbasea: Optional[str] = None + solicitarcontrasenaadministrador: Optional[int] = None + imprimirlote: Optional[int] = None + imprimirnumentrada: Optional[int] = None + + +class SettingsPayload(BaseModel): + """ + Master container for all possible overrides. + Using Optional fields so only the delta is required in JSONB. + """ + # SCAII (Invoices / Inventory) + ssisgen: Optional[SSisGenSettings] = None + ssisgen2: Optional[SSisGen2Settings] = None + ssisgen3: Optional[SSisGen3Settings] = None + ssismex: Optional[SSisMexSettings] = None + ssisdef: Optional[SSisDefSettings] = None + ssisexpo: Optional[SSisExpoSettings] = None + ssisimpo: Optional[SSisImpoSettings] = None + + # SCAF (Fixed Assets) + qsiscmex: Optional[QSisCMexSettings] = None + qsisdef: Optional[QSisDefSettings] = None + qsisgen: Optional[QSisGenSettings] = None + qsisexporep: Optional[QSisExpoRepSettings] = None + qsisimpo: Optional[QSisImpoSettings] = None + qsisimporep: Optional[QSisImpoRepSettings] = None + qsisexpo: Optional[QSisExpoSettings] = None + + # Unified Invoice Settings + invoices: Optional["InvoiceSettingsMap"] = None + +class InvoiceSettingsData(BaseModel): + """Container for form-specific invoice settings""" + InvoiceTopFieldsFormData: Optional[Dict[str, Any]] = None + generalFormData: Optional[Dict[str, Any]] = None + observationFormData: Optional[Dict[str, Any]] = None + itemsFormData: Optional[Dict[str, Any]] = None + othersFormData: Optional[Dict[str, Any]] = None + continuationFormData: Optional[Dict[str, Any]] = None + +class InvoiceSettingsMap(BaseModel): + """ + Map of invoice settings indexed by operation_type (imp/exp) + and then by invoice_type. + Example: {"imp": {"factura_importacion": {...}}} + """ + types: Optional[Dict[str, Dict[str, InvoiceSettingsData]]] = None + +class AppSettingRequest(BaseModel): + tenant_id: Optional[int] = None + company_id: Optional[int] = None + settings: SettingsPayload + +class AppSettingResponse(BaseModel): + id: int + tenant_id: Optional[int] + company_id: Optional[int] + settings: Dict[str, Any] + + class Config: + from_attributes = True + diff --git a/backend/api/v1/modules/a76/app_settings/service.py b/backend/api/v1/modules/a76/app_settings/service.py new file mode 100644 index 00000000..60d4e19a --- /dev/null +++ b/backend/api/v1/modules/a76/app_settings/service.py @@ -0,0 +1,153 @@ +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 diff --git a/backend/api/v1/modules/a76/invoice_settings/services.py b/backend/api/v1/modules/a76/invoice_settings/services.py index 996fcac7..4e477142 100644 --- a/backend/api/v1/modules/a76/invoice_settings/services.py +++ b/backend/api/v1/modules/a76/invoice_settings/services.py @@ -1,8 +1,6 @@ -from typing import List, Optional +from typing import List, Optional, Any, Dict from sqlalchemy.orm import Session -from sqlalchemy import select -from fastapi import HTTPException -from api.v1.modules.a76.invoice_settings.models import InvoiceSettings +from api.v1.modules.a76.app_settings.service import AppSettingsService from api.v1.modules.a76.invoice_settings.dto import InvoiceSettingsRequest, OperationType def get_settings( @@ -11,60 +9,90 @@ def get_settings( company_id: int, invoice_type: str, operation_type: OperationType -) -> Optional[InvoiceSettings]: - """Retrieve settings for a specific context""" - stmt = select(InvoiceSettings).where( - InvoiceSettings.tenant_id == tenant_id, - InvoiceSettings.company_id == company_id, - InvoiceSettings.invoice_type == invoice_type, - InvoiceSettings.operation_type == operation_type.value - ) - return db.execute(stmt).scalar_one_or_none() +) -> Optional[Dict[str, Any]]: + """Retrieve settings for a specific context from app_settings""" + # Use AppSettingsService to get the unifed settings + app_settings = AppSettingsService.get_resolved_settings(db, tenant_id, company_id) + if not app_settings: + return None + + # Navigate to: invoices -> types -> {operation_type} -> {invoice_type} + invoices = app_settings.get("invoices", {}) + types_map = invoices.get("types", {}) + op_map = types_map.get(operation_type.value, {}) + settings_payload = op_map.get(invoice_type) + + if settings_payload is None: + return None + + return { + "id": 0, # Virtual ID for compatibility + "tenant_id": tenant_id, + "company_id": company_id, + "invoice_type": invoice_type, + "operation_type": operation_type, + "settings": settings_payload + } def list_settings( db: Session, tenant_id: int, company_id: int -) -> List[InvoiceSettings]: - """List all settings for a company""" - stmt = select(InvoiceSettings).where( - InvoiceSettings.tenant_id == tenant_id, - InvoiceSettings.company_id == company_id - ) - return db.execute(stmt).scalars().all() +) -> List[Dict[str, Any]]: + """List all settings for a company from app_settings""" + app_settings = AppSettingsService.get_resolved_settings(db, tenant_id, company_id) + if not app_settings: + return [] + + invoices = app_settings.get("invoices", {}) + types_map = invoices.get("types", {}) + + results = [] + for op_val, op_map in types_map.items(): + for inv_type, settings_payload in op_map.items(): + results.append({ + "id": 0, + "tenant_id": tenant_id, + "company_id": company_id, + "invoice_type": inv_type, + "operation_type": op_val, + "settings": settings_payload + }) + return results def upsert_settings( db: Session, tenant_id: int, company_id: int, settings_data: InvoiceSettingsRequest -) -> InvoiceSettings: - """Create or update settings""" - # Check if exists - existing = get_settings( - db, - tenant_id, - company_id, - settings_data.invoice_type, - settings_data.operation_type - ) +) -> Dict[str, Any]: + """Create or update settings in app_settings""" + # Construct the nested structure for AppSettingsService.upsert_settings + # We use deep_merge in AppSettingsService, so we just send the branch we want to update + payload = { + "invoices": { + "types": { + settings_data.operation_type.value: { + settings_data.invoice_type: settings_data.settings + } + } + } + } - if existing: - existing.settings = settings_data.settings - db.commit() - db.refresh(existing) - return existing - - # Create new - new_settings = InvoiceSettings( + # Save using the unified service + AppSettingsService.upsert_settings( + db, tenant_id=tenant_id, company_id=company_id, - invoice_type=settings_data.invoice_type, - operation_type=settings_data.operation_type.value, - settings=settings_data.settings + settings=payload ) - db.add(new_settings) - db.commit() - db.refresh(new_settings) - return new_settings + # Return the same structure as get_settings for consistency + return { + "id": 0, + "tenant_id": tenant_id, + "company_id": company_id, + "invoice_type": settings_data.invoice_type, + "operation_type": settings_data.operation_type, + "settings": settings_data.settings + } diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 33e0dd48..7d8df946 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -47,6 +47,7 @@ from .reports.exportacion.transmission.MAINX30.routes import router as transmiss from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router from .reports.importacion.winsaai.router import router as winsaai_router +from .app_settings.routes import router as app_settings_router from .manifests.manifest.routes import router as manifests_router from .manifests.driver.routes import router as manifest_drivers_router @@ -176,6 +177,8 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router(app_settings_router) + # Registrar router de bitácora from .audit_log.router import router as audit_log_router router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) diff --git a/frontend/src/lib/api/dashboard/a76/app-settings.ts b/frontend/src/lib/api/dashboard/a76/app-settings.ts new file mode 100644 index 00000000..573fa13b --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/app-settings.ts @@ -0,0 +1,34 @@ +import { BACKEND_URL } from '$lib/config/backend'; + +export interface AppSettingsRequest { + tenant_id?: number | null; + company_id?: number | null; + settings: Record; +} + +export const appSettingsApi = { + /** + * Resolves settings merging Global -> Tenant -> Company hierarchy + */ + async getResolved(tenantId: number, companyId: number): Promise> { + const response = await fetch(`${BACKEND_URL}/v1/a76/app-settings/resolved?tenant_id=${tenantId}&company_id=${companyId}`); + if (!response.ok) throw new Error('Error al obtener configuraciones'); + return response.json(); + }, + + /** + * Upserts an override at a specific level + */ + async upsert(payload: AppSettingsRequest): Promise { + const response = await fetch(`${BACKEND_URL}/v1/a76/app-settings/upsert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Error al guardar configuración'); + } + return response.json(); + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts index 57bdf74c..80ca6ba8 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/packages.ts @@ -77,5 +77,5 @@ export async function updatePackage( } export async function deletePackage(id: number, companyId: number): Promise> { - return await api.delete(`/v1/a76/packages/${id}/?company_id=${companyId}`); + return await api.delete(`/v1/a76/packages/${id}?company_id=${companyId}`); } \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte index 0e5ff3e3..9b8deaf4 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte @@ -13,20 +13,22 @@ formData = $bindable(), exists = $bindable(), operationType = undefined, - invoiceType = undefined + invoiceType = undefined, + isSettings = false }: { invoice: Invoice | null; formData?: any; exists?: boolean; operationType?: number; invoiceType?: string; + isSettings?: boolean; } = $props(); if (!formData && invoice) { formData = { // Campos de esta pestaña numero_tipo_transporte: - invoice.logistics?.transport_num || invoice.logistics?.vehicle_num || '', + invoice.logistics?.trailer_num || invoice.logistics?.transport_num || invoice.logistics?.vehicle_num || '', es_ferrocarril: invoice.logistics?.is_rail ? 'si' : 'no', numero_bl: invoice.logistics?.bill_number || '', cantidad_guias_embarque: invoice.logistics?.guide_number || null, @@ -90,6 +92,23 @@ }; exists = false; } + + $effect.pre(() => { + if (formData) { + if (formData.es_ferrocarril === undefined) formData.es_ferrocarril = 'no'; + if (formData.is_mixed === undefined) formData.is_mixed = false; + if (formData.reason_export === undefined) formData.reason_export = '1'; + if (formData.fue_revisado_equipo === undefined) formData.fue_revisado_equipo = false; + if (formData.sub_division === undefined) formData.sub_division = false; + if (formData.funge_como_cd === undefined) formData.funge_como_cd = false; + if (formData.llego_pedimento === undefined) formData.llego_pedimento = false; + if (formData.semaforo_verde_aduana_mexicana === undefined) formData.semaforo_verde_aduana_mexicana = false; + if (formData.semaforo_verde_aduana_americana === undefined) formData.semaforo_verde_aduana_americana = false; + if (formData.semaforo_rojo_aduana_mexicana === undefined) formData.semaforo_rojo_aduana_mexicana = false; + if (formData.semaforo_rojo_aduana_americana === undefined) formData.semaforo_rojo_aduana_americana = false; + } + }); + let showPortModal = $state(false); function handlePortSelect(section: any) { @@ -119,7 +138,7 @@
- + formData.es_ferrocarril = v} class="flex gap-4">
@@ -203,10 +222,10 @@ {#if operationType === 1 || invoiceType === 'CR'}
-
+
- -
+ formData.reason_export = v} class="flex flex-wrap gap-4"> +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index 9cfe1c10..f1df88a6 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -33,7 +33,8 @@ operationType = undefined, defaultOperationType = undefined, exchangeRate = undefined, - invoiceType = undefined + invoiceType = undefined, + isSettings = false }: { invoice: Invoice | null; formData?: any; @@ -54,6 +55,7 @@ operationType?: number | null; exchangeRate?: number | null; invoiceType?: string; + isSettings?: boolean; } = $props(); let showManifestModal = $state(false); @@ -88,7 +90,7 @@ // RIGHT fields currency_type: invoice.financials?.currency_type || '', currency: invoice.financials?.currency || 'foreign', // foreign, local, manual - exchange_rate: invoice.financials?.exchange_rate || null, // Added exchange_rate + exchange_rate: invoice.financials?.exchange_rate || null, weight_type: 'kgs', iva_factor: invoice.financials?.iva_factor || null, carrier_id: invoice.logistics?.carrier_id || null, @@ -120,7 +122,7 @@ // RIGHT fields currency_type: '', currency: 'foreign', // foreign, local, manual - exchange_rate: null, // Added exchange_rate + exchange_rate: null, weight_type: 'kgs', iva_factor: null, carrier_id: null, @@ -135,37 +137,27 @@ electronic_signature: '' }; } - } else { - // Si formData ya existe, asegurar que tiene valores por defecto - if (formData.currency === undefined) { - formData.currency = 'foreign'; + } + + $effect.pre(() => { + if (formData) { + if (formData.currency === undefined) formData.currency = 'foreign'; + if (!formData.provider_header) formData.provider_header = 'proveedor'; + if (!formData.sold_to_header) formData.sold_to_header = 'consignado_a'; + if (!formData.shipped_to_header) formData.shipped_to_header = 'enviado_a'; + if (!formData.shipped_by_header) formData.shipped_by_header = 'enviado_por'; + if (formData.manifest_number === undefined) formData.manifest_number = ''; + if (formData.code_signature === undefined) formData.code_signature = ''; + if (formData.electronic_signature === undefined) formData.electronic_signature = ''; + + // Migración de transport_num a trailer_num (Upstream change) + if (formData.trailer_num === undefined && (formData as any).transport_num) { + formData.trailer_num = (formData as any).transport_num; + delete (formData as any).transport_num; + } + if (formData.trailer_num === undefined) formData.trailer_num = ''; } - if (!formData.provider_header) { - formData.provider_header = 'proveedor'; - } - if (!formData.sold_to_header) { - formData.sold_to_header = 'consignado_a'; - } - if (!formData.shipped_to_header) { - formData.shipped_to_header = 'enviado_a'; - } - if (!formData.shipped_by_header) { - formData.shipped_by_header = 'enviado_por'; - } - if (formData.manifest_number === undefined) { - formData.manifest_number = ''; - } - if (formData.code_signature === undefined) { - formData.code_signature = ''; - } - if (formData.electronic_signature === undefined) { - formData.electronic_signature = ''; - } - if (formData.trailer_num === undefined && (formData as any).transport_num) { - formData.trailer_num = (formData as any).transport_num; - delete (formData as any).transport_num; - } - } + }); // Opciones de tipo de peso const weightTypeOptions = [ @@ -352,12 +344,12 @@ -
+
{#if invoiceType !== 'MEX'}

Datos del pedimento

-
+
Fecha del:

{formData.fecha_pedimento_del || '-'}

@@ -380,7 +372,7 @@

Clientes - Proveedores - Agente Aduanal

-
+
- * + {#if !isSettings}*{/if} - * + {#if !isSettings}*{/if} - * + {#if !isSettings}*{/if}
Agente Aduanal Mex: {#if !isSettings}*{/if} - + {formData.customs_broker_id ? customsBrokers.find((cb) => cb.id === formData.customs_broker_id)?.name || @@ -590,14 +582,14 @@
-
+

Tipo de Moneda - Pesos Netos y Brutos

Tipo de cambio: - + {exchangeRate !== undefined && exchangeRate !== null ? exchangeRate === 0 ? 'N/A' @@ -614,19 +606,19 @@
-
-
-
@@ -642,12 +634,12 @@ formData.currency_type = v ?? ''; }} > - + {formData.currency_type || '...'} - + {#each currencyTypes as currencyType} {currencyType.code} @@ -657,7 +649,7 @@

{/if} -
+
- + {weightTypeOptions.find((w) => w.value === formData.weight_type)?.label || 'Kilogramos (kg)'} @@ -721,10 +713,10 @@
-
+

Transportista

-
+
{#if invoiceType !== 'MEX'}
@@ -737,7 +729,7 @@ formData.trailer_num = ''; }} > - + {#if formData.carrier_id} {transporters.find( @@ -830,7 +822,7 @@
-
+
-
+