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:
107
backend/api/v1/modules/a24/fa/fa_classes/dto.py
Normal file
107
backend/api/v1/modules/a24/fa/fa_classes/dto.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
32
backend/api/v1/modules/a24/fa/fa_classes/routes.py
Normal file
32
backend/api/v1/modules/a24/fa/fa_classes/routes.py
Normal 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
|
||||
223
backend/api/v1/modules/a24/fa/fa_classes/service.py
Normal file
223
backend/api/v1/modules/a24/fa/fa_classes/service.py
Normal 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))
|
||||
14
backend/api/v1/modules/a24/router.py
Normal file
14
backend/api/v1/modules/a24/router.py
Normal 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"])
|
||||
@@ -4,6 +4,7 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -14,22 +15,22 @@ class ClassCreateDTO(BaseModel):
|
||||
|
||||
client_id: int = Field(..., description="Client key")
|
||||
class_code: str = Field(..., max_length=8, description="Class code")
|
||||
description_es: Optional[str] = Field(
|
||||
None, max_length=500, description="Description in Spanish"
|
||||
description_es: str = Field(
|
||||
..., max_length=500, description="Description in Spanish (required)"
|
||||
)
|
||||
description_en: Optional[str] = Field(
|
||||
None, max_length=500, description="Description in English"
|
||||
)
|
||||
material_key: Optional[str] = Field(
|
||||
None,
|
||||
material_key: str = Field(
|
||||
...,
|
||||
max_length=10,
|
||||
description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)",
|
||||
description="Material key - Fixed Asset Type (required)",
|
||||
)
|
||||
unit_of_measure: Optional[str] = Field(
|
||||
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
|
||||
unit_of_measure: str = Field(
|
||||
..., max_length=5, description="Unit of measure - U.M. comercial (required)"
|
||||
)
|
||||
fraction: Optional[str] = Field(
|
||||
None, max_length=10, description="Mexican tariff fraction"
|
||||
fraction: str = Field(
|
||||
..., max_length=20, description="Mexican tariff fraction (required)"
|
||||
)
|
||||
us_fraction: Optional[str] = Field(
|
||||
None, max_length=16, description="US tariff fraction"
|
||||
@@ -48,9 +49,45 @@ class ClassCreateDTO(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassCreateDTOFA(ClassCreateDTO):
|
||||
"""DTO para crear una clase de activo fijo (clase base + extensión FA)"""
|
||||
|
||||
# Campos específicos de activos fijos (a24.fa_classes)
|
||||
import_tariff_code: Optional[str] = Field(
|
||||
None, max_length=10, description="Código de fracción de importación"
|
||||
)
|
||||
import_tariff_type: Optional[str] = Field(
|
||||
None, max_length=6, description="Tipo de fracción de importación"
|
||||
)
|
||||
export_tariff_code: Optional[str] = Field(
|
||||
None, max_length=10, description="Código de fracción de exportación"
|
||||
)
|
||||
export_tariff_type: Optional[str] = Field(
|
||||
None, max_length=6, description="Tipo de fracción de exportación"
|
||||
)
|
||||
depreciation_rate: Optional[Decimal] = Field(
|
||||
None, ge=0, le=100, description="Tasa de depreciación anual (%)"
|
||||
)
|
||||
fda_code: Optional[str] = Field(
|
||||
None, max_length=20, description="Código FDA"
|
||||
)
|
||||
eccn_code: Optional[str] = Field(
|
||||
None, max_length=20, description="Código ECCN"
|
||||
)
|
||||
class_enabled: Optional[bool] = Field(
|
||||
True, description="Indica si la clase está habilitada"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una clase"""
|
||||
|
||||
class_code: Optional[str] = Field(
|
||||
None, max_length=8, description="Class code"
|
||||
)
|
||||
description_es: Optional[str] = Field(
|
||||
None, max_length=500, description="Description in Spanish"
|
||||
)
|
||||
@@ -66,7 +103,7 @@ class ClassUpdateDTO(BaseModel):
|
||||
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
|
||||
)
|
||||
fraction: Optional[str] = Field(
|
||||
None, max_length=10, description="Mexican tariff fraction"
|
||||
None, max_length=20, description="Mexican tariff fraction"
|
||||
)
|
||||
us_fraction: Optional[str] = Field(
|
||||
None, max_length=16, description="US tariff fraction"
|
||||
@@ -81,8 +118,7 @@ class ClassUpdateDTO(BaseModel):
|
||||
None, max_length=4, description="IVA exempt fraction"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields
|
||||
|
||||
|
||||
class ClassResponseDTO(BaseModel):
|
||||
@@ -108,6 +144,23 @@ class ClassResponseDTO(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ClassResponseDTOFA(ClassResponseDTO):
|
||||
"""DTO para respuesta de clase de activo fijo (incluye campos FA)"""
|
||||
|
||||
# Campos de a24.fa_classes
|
||||
fa_id: Optional[int] = None
|
||||
import_tariff_code: Optional[str] = None
|
||||
import_tariff_type: Optional[str] = None
|
||||
export_tariff_code: Optional[str] = None
|
||||
export_tariff_type: Optional[str] = None
|
||||
depreciation_rate: Optional[Decimal] = None
|
||||
fda_code: Optional[str] = None
|
||||
eccn_code: Optional[str] = None
|
||||
class_enabled: Optional[bool] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ClassBasicDTO(BaseModel):
|
||||
"""DTO para información básica de clase"""
|
||||
|
||||
@@ -147,4 +200,4 @@ class ClassSearchDTO(BaseModel):
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
from_attributes = True
|
||||
@@ -75,7 +75,7 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
|
||||
) # UNIMED - homologated from UNIMEDIDA
|
||||
|
||||
# Tariff fractions
|
||||
fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION
|
||||
fraction: Mapped[Optional[str]] = mapped_column(String(20)) # FRACCION
|
||||
us_fraction: Mapped[Optional[str]] = mapped_column(
|
||||
String(16)
|
||||
) # FRACCIONAME - US tariff fraction
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
Endpoints API para gestión de clases SCAII y SCAF
|
||||
"""
|
||||
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
|
||||
from typing import Dict, Any
|
||||
from fastapi import Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import ClassCreateDTO, ClassResponseDTO, ClassUpdateDTO
|
||||
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 ClassCreateDTO, ClassCreateDTOFA, ClassResponseDTO, ClassResponseDTOFA, ClassUpdateDTO
|
||||
from .service import ClassService
|
||||
|
||||
# Create router with generic CRUD routes
|
||||
router = TenantCRUDRoutes(
|
||||
crud_routes = TenantCRUDRoutes(
|
||||
service=ClassService,
|
||||
create_schema=ClassCreateDTO,
|
||||
update_schema=ClassUpdateDTO,
|
||||
@@ -16,9 +22,58 @@ router = TenantCRUDRoutes(
|
||||
prefix="/classes",
|
||||
tags=["a76 / classes"],
|
||||
resource_name="Class",
|
||||
id_name="class_id",
|
||||
id_name="id",
|
||||
enable_list=True,
|
||||
enable_filters=True,
|
||||
default_page_size=50,
|
||||
max_page_size=100,
|
||||
).router
|
||||
max_page_size=1000,
|
||||
)
|
||||
|
||||
router = crud_routes.router
|
||||
|
||||
|
||||
@router.post(
|
||||
"/seed",
|
||||
summary="Seed Fixed Asset Classes",
|
||||
description="Initialize fixed asset class catalog with default data",
|
||||
)
|
||||
async def seed_classes(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
client_id: int = Query(..., description="Client ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Seed initial data for fixed asset classes"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
count = ClassService.seed_initial_data(db, tenant_id, company_id, client_id)
|
||||
|
||||
return {
|
||||
"message": f"Successfully created {count} fixed asset classes",
|
||||
"count": count,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/fa",
|
||||
response_model=ClassResponseDTOFA,
|
||||
status_code=201,
|
||||
summary="Create Fixed Asset Class",
|
||||
description="Create a class with FA extension in a single transaction",
|
||||
)
|
||||
async def create_fa_class(
|
||||
class_data: ClassCreateDTOFA,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create a fixed asset class (both base class and FA extension)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"create_fa_class endpoint called with: {class_data.model_dump()}")
|
||||
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
result = ClassService.create_fa_class(db, class_data, tenant_id, company_id)
|
||||
|
||||
return result
|
||||
@@ -13,8 +13,10 @@ from sqlalchemy.orm import Session
|
||||
from .dto import (
|
||||
ClassBasicDTO,
|
||||
ClassCreateDTO,
|
||||
ClassCreateDTOFA,
|
||||
ClassListDTO,
|
||||
ClassResponseDTO,
|
||||
ClassResponseDTOFA,
|
||||
ClassSearchDTO,
|
||||
ClassUpdateDTO,
|
||||
)
|
||||
@@ -38,6 +40,7 @@ class ClassService:
|
||||
"""
|
||||
Get all classes for a tenant with pagination and filters
|
||||
"""
|
||||
logger.info(f"get_all called with tenant_id={tenant_id}, company_id={company_id}, skip={skip}, limit={limit}")
|
||||
query = db.query(Class).filter(
|
||||
Class.tenant_id == tenant_id, Class.company_id == company_id
|
||||
)
|
||||
@@ -70,7 +73,8 @@ class ClassService:
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
logger.info(f"get_all returning {len(items)} items out of {total} total")
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
@@ -109,18 +113,19 @@ class ClassService:
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Class with code '{data_dict['class_code']}' already exists for this tenant and company"
|
||||
detail=f" El código de clase '{data_dict['class_code']}' ya existe. Por favor use un código diferente."
|
||||
)
|
||||
|
||||
# Validate material_key exists if provided
|
||||
if data_dict.get("material_key"):
|
||||
from api.v1.modules.public.reference_data.material_types.models import MaterialType
|
||||
material_exists = db.query(MaterialType).filter(
|
||||
MaterialType.key == data_dict["material_key"]
|
||||
).first()
|
||||
if not material_exists:
|
||||
# Set to None if material_key doesn't exist
|
||||
data_dict["material_key"] = None
|
||||
# Validate material_key exists (now required)
|
||||
from api.v1.modules.public.reference_data.material_types.models import MaterialType
|
||||
material_exists = db.query(MaterialType).filter(
|
||||
MaterialType.key == data_dict["material_key"]
|
||||
).first()
|
||||
if not material_exists:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Material type '{data_dict['material_key']}' does not exist"
|
||||
)
|
||||
|
||||
class_obj = Class(**data_dict)
|
||||
class_obj.tenant_id = tenant_id
|
||||
@@ -147,11 +152,16 @@ class ClassService:
|
||||
company_id: int,
|
||||
) -> Optional[Class]:
|
||||
"""Update a class"""
|
||||
logger.info(f"Update called for class_id={class_id}, tenant_id={tenant_id}, company_id={company_id}")
|
||||
logger.info(f"Update data received: {class_data.model_dump(exclude_unset=True)}")
|
||||
|
||||
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
|
||||
if not class_obj:
|
||||
logger.warning(f"Class {class_id} not found for tenant {tenant_id}, company {company_id}")
|
||||
return None
|
||||
|
||||
update_data = class_data.model_dump(exclude_unset=True)
|
||||
logger.info(f"Update data after model_dump: {update_data}")
|
||||
|
||||
# Validate material_key exists if provided
|
||||
if "material_key" in update_data and update_data["material_key"]:
|
||||
@@ -163,24 +173,241 @@ class ClassService:
|
||||
# Set to None if material_key doesn't exist
|
||||
update_data["material_key"] = None
|
||||
|
||||
# Validate class_code is unique if being changed
|
||||
if "class_code" in update_data and update_data["class_code"]:
|
||||
new_code = update_data["class_code"]
|
||||
# Check if another class with this code exists (excluding current class)
|
||||
# The unique constraint is on (tenant_id, company_id, client_id, class_code)
|
||||
existing_class = db.query(Class).filter(
|
||||
Class.class_code == new_code,
|
||||
Class.tenant_id == tenant_id,
|
||||
Class.company_id == company_id,
|
||||
Class.client_id == class_obj.client_id, # Same client
|
||||
Class.id != class_id # Exclude current class
|
||||
).first()
|
||||
|
||||
logger.info(f"Checking for duplicate class_code '{new_code}' for client {class_obj.client_id}")
|
||||
if existing_class:
|
||||
logger.warning(f"Duplicate class_code found: {existing_class.id}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"El código '{new_code}' ya está en uso para este cliente. Por favor ingrese un código diferente."
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(class_obj, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(class_obj)
|
||||
return class_obj
|
||||
try:
|
||||
logger.info(f"Attempting to commit changes for class {class_id}")
|
||||
db.commit()
|
||||
db.refresh(class_obj)
|
||||
logger.info(f"Successfully updated class {class_id}")
|
||||
return class_obj
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
error_msg = str(e.orig)
|
||||
logger.error(f"IntegrityError updating class {class_id}: {error_msg}")
|
||||
|
||||
# Check if it's a duplicate class_code error
|
||||
if "already exists" in error_msg.lower() or "duplicate" in error_msg.lower():
|
||||
# Extract the code from update_data if it was changed
|
||||
code = update_data.get("class_code", class_obj.class_code)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"El código '{code}' ya está en uso. Por favor ingrese un código diferente."
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error al actualizar la clase: {error_msg}"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Unexpected error updating class {class_id}: {type(e).__name__}: {str(e)}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, class_id: int, tenant_id: int, company_id: int) -> bool:
|
||||
"""Delete a class"""
|
||||
"""Delete a class (and its FA extension if exists)"""
|
||||
from api.v1.modules.a24.fa.fa_classes.models import QClasses
|
||||
|
||||
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
|
||||
if not class_obj:
|
||||
return False
|
||||
|
||||
# Delete FA extension first (if exists) to avoid FK constraint violation
|
||||
fa_extension = db.query(QClasses).filter(
|
||||
QClasses.class_id == class_id,
|
||||
QClasses.tenant_id == tenant_id
|
||||
).first()
|
||||
|
||||
if fa_extension:
|
||||
db.delete(fa_extension)
|
||||
|
||||
# Now delete the base class
|
||||
db.delete(class_obj)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def create_fa_class(
|
||||
db: Session, class_data: ClassCreateDTOFA, tenant_id: int, company_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a fixed asset class (both a76.classes and a24.fa_classes)
|
||||
Returns a dict with both records combined
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"create_fa_class called with data: {class_data.model_dump()}")
|
||||
|
||||
from api.v1.modules.a24.fa.fa_classes.models import QClasses
|
||||
|
||||
# Extract base class fields
|
||||
base_fields = {
|
||||
"client_id", "class_code", "description_es", "description_en",
|
||||
"material_key", "unit_of_measure", "fraction", "us_fraction",
|
||||
"sub_key", "physical_review", "iva_exempt_fraction"
|
||||
}
|
||||
base_data = {k: v for k, v in class_data.model_dump().items() if k in base_fields}
|
||||
|
||||
# Extract FA-specific fields
|
||||
fa_fields = {
|
||||
"import_tariff_code", "import_tariff_type", "export_tariff_code",
|
||||
"export_tariff_type", "depreciation_rate", "fda_code", "eccn_code",
|
||||
"class_enabled"
|
||||
}
|
||||
fa_data = {k: v for k, v in class_data.model_dump().items() if k in fa_fields}
|
||||
|
||||
try:
|
||||
# 1. Create base class
|
||||
base_dto = ClassCreateDTO(**base_data)
|
||||
base_class = ClassService.create(db, base_dto, tenant_id, company_id)
|
||||
|
||||
# 2. Create FA extension
|
||||
fa_obj = QClasses(**fa_data)
|
||||
fa_obj.class_id = base_class.id
|
||||
fa_obj.tenant_id = tenant_id
|
||||
fa_obj.company_id = company_id
|
||||
|
||||
db.add(fa_obj)
|
||||
db.commit()
|
||||
db.refresh(fa_obj)
|
||||
|
||||
# 3. Combine response - build dict manually to avoid SQLAlchemy internals
|
||||
combined_response = {
|
||||
# Base class fields
|
||||
"id": base_class.id,
|
||||
"tenant_id": base_class.tenant_id,
|
||||
"company_id": base_class.company_id,
|
||||
"client_id": base_class.client_id,
|
||||
"class_code": base_class.class_code,
|
||||
"description_es": base_class.description_es,
|
||||
"description_en": base_class.description_en,
|
||||
"material_key": base_class.material_key,
|
||||
"unit_of_measure": base_class.unit_of_measure,
|
||||
"fraction": base_class.fraction,
|
||||
"us_fraction": base_class.us_fraction,
|
||||
"sub_key": base_class.sub_key,
|
||||
"physical_review": base_class.physical_review,
|
||||
"iva_exempt_fraction": base_class.iva_exempt_fraction,
|
||||
"created_at": base_class.created_at,
|
||||
"updated_at": base_class.updated_at,
|
||||
# FA extension fields
|
||||
"fa_id": fa_obj.id,
|
||||
"import_tariff_code": fa_obj.import_tariff_code,
|
||||
"import_tariff_type": fa_obj.import_tariff_type,
|
||||
"export_tariff_code": fa_obj.export_tariff_code,
|
||||
"export_tariff_type": fa_obj.export_tariff_type,
|
||||
"depreciation_rate": fa_obj.depreciation_rate,
|
||||
"fda_code": fa_obj.fda_code,
|
||||
"eccn_code": fa_obj.eccn_code,
|
||||
"class_enabled": fa_obj.class_enabled,
|
||||
}
|
||||
|
||||
return combined_response
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
# If FA creation fails, rollback base class too
|
||||
if 'base_class' in locals():
|
||||
try:
|
||||
db.delete(base_class)
|
||||
db.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract and improve error message
|
||||
error_msg = str(e)
|
||||
if "already exists" in error_msg.lower() or "duplicad" in error_msg.lower():
|
||||
# Extract code from error if possible
|
||||
code = class_data.class_code
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"El código '{code}' ya está en uso. Por favor ingrese un código diferente."
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error al crear clase de activo fijo: {error_msg}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def seed_initial_data(
|
||||
db: Session, tenant_id: int, company_id: int, client_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Seed initial fixed asset class data
|
||||
Returns: number of records created
|
||||
"""
|
||||
from .seed import seed
|
||||
|
||||
created_count = 0
|
||||
for record in seed:
|
||||
(
|
||||
class_code,
|
||||
description_es,
|
||||
description_en,
|
||||
material_key,
|
||||
unit_of_measure,
|
||||
fraction,
|
||||
us_fraction,
|
||||
bom,
|
||||
) = record
|
||||
|
||||
# Check if already exists
|
||||
existing = (
|
||||
db.query(Class)
|
||||
.filter(
|
||||
Class.tenant_id == tenant_id,
|
||||
Class.company_id == company_id,
|
||||
Class.client_id == client_id,
|
||||
Class.class_code == class_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing:
|
||||
class_obj = Class(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
client_id=client_id,
|
||||
class_code=class_code,
|
||||
description_es=description_es,
|
||||
description_en=description_en,
|
||||
material_key=material_key if material_key else None,
|
||||
unit_of_measure=unit_of_measure if unit_of_measure else None,
|
||||
fraction=fraction if fraction else None,
|
||||
us_fraction=us_fraction if us_fraction else None,
|
||||
)
|
||||
db.add(class_obj)
|
||||
created_count += 1
|
||||
|
||||
if created_count > 0:
|
||||
db.commit()
|
||||
|
||||
return created_count
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
||||
# Importar routers de módulos
|
||||
from .modules.core.router import router as core_router
|
||||
from .modules.a76.router import router as a76_router
|
||||
from .modules.a24.router import router as a24_router
|
||||
from .modules.public.router import router as public_router
|
||||
|
||||
# Router principal
|
||||
@@ -16,6 +17,7 @@ router = APIRouter()
|
||||
# Registrar módulos
|
||||
router.include_router(core_router)
|
||||
router.include_router(a76_router)
|
||||
router.include_router(a24_router)
|
||||
router.include_router(public_router)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user