Merge development into feature/table-invoice
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
1
backend/api/v1/modules/a76/app_settings/__init__.py
Normal file
1
backend/api/v1/modules/a76/app_settings/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Module initialization for app_settings
|
||||
33
backend/api/v1/modules/a76/app_settings/models.py
Normal file
33
backend/api/v1/modules/a76/app_settings/models.py
Normal 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})>"
|
||||
59
backend/api/v1/modules/a76/app_settings/routes.py
Normal file
59
backend/api/v1/modules/a76/app_settings/routes.py
Normal 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)
|
||||
1042
backend/api/v1/modules/a76/app_settings/schemas.py
Normal file
1042
backend/api/v1/modules/a76/app_settings/schemas.py
Normal file
File diff suppressed because it is too large
Load Diff
153
backend/api/v1/modules/a76/app_settings/service.py
Normal file
153
backend/api/v1/modules/a76/app_settings/service.py
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user