Merge development into feature/table-invoice

This commit is contained in:
2026-04-13 11:53:03 -06:00
33 changed files with 6840 additions and 491 deletions

View File

@@ -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')

View File

@@ -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)

View File

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

View File

@@ -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"<AppSetting(id={self.id}, tenant={self.tenant_id}, company={self.company_id})>"

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"])