feat: add fixed asset classes management page and embed functionality

- Implemented a new page for managing fixed asset classes with full CRUD functionality.
- Added filtering options for searching classes by code, description, type, and fraction.
- Integrated dialogs for inserting, editing, and deleting classes with validation.
- Enhanced error handling and user feedback with toast notifications.
- Created an embedded iframe for the fixed asset classes page in the merchandise section.
This commit is contained in:
Galindo97
2026-01-08 16:33:43 -06:00
parent 7c9c295c5c
commit ea0e570678
18 changed files with 3014 additions and 48 deletions

View File

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

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
@@ -24,11 +25,11 @@ class QClasses(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
class_id: Mapped[int] = mapped_column(Integer, nullable=False)
import_tariff_code: Mapped[str] = mapped_column(String(10)) # FRACCIONIMPO
import_tariff_type: Mapped[str] = mapped_column(String(6)) # TIPOFRACIMPO
export_tariff_code: Mapped[str] = mapped_column(String(10)) # FRACCIONEXPO
export_tariff_type: Mapped[str] = mapped_column(String(6)) # TIPOFRACEXPO
depreciation_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2)) # TASADEPRECIA
fda_code: Mapped[str] = mapped_column(String(20)) # FDA
eccn_code: Mapped[str] = mapped_column(String(20)) # ECCN
class_enabled: Mapped[bool] = mapped_column(Boolean) # HABILITADESHABILITACLASE
import_tariff_code: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # FRACCIONIMPO
import_tariff_type: Mapped[Optional[str]] = mapped_column(String(6), nullable=True) # TIPOFRACIMPO
export_tariff_code: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # FRACCIONEXPO
export_tariff_type: Mapped[Optional[str]] = mapped_column(String(6), nullable=True) # TIPOFRACEXPO
depreciation_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2), nullable=True) # TASADEPRECIA
fda_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # FDA
eccn_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # ECCN
class_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) # HABILITADESHABILITACLASE

View File

@@ -0,0 +1,32 @@
"""
Endpoints API para gestión de clases de activos fijos (FA)
"""
from typing import Any, Dict
from fastapi import Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from .dto import FAClassCreateDTO, FAClassResponseDTO, FAClassUpdateDTO
from .service import FAClassService
# Create router with generic CRUD routes
crud_routes = TenantCRUDRoutes(
service=FAClassService,
create_schema=FAClassCreateDTO,
update_schema=FAClassUpdateDTO,
response_schema=FAClassResponseDTO,
prefix="/fa/classes",
tags=["a24 / fa / classes"],
resource_name="Fixed Asset Class",
id_name="fa_class_id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=100,
)
router = crud_routes.router

View File

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

View File

@@ -0,0 +1,14 @@
"""
Router principal del módulo A24 (SCAF - Sistema de Control de Activo Fijo)
"""
from fastapi import APIRouter
# Importar routers de submódulos
from .fa.fa_classes.routes import router as fa_classes_router
# Router principal de A24
router = APIRouter()
# Registrar routers de FA (Fixed Assets)
router.include_router(fa_classes_router, prefix="/a24", tags=["a24 / fa / classes"])