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

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

View File

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

View File

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

View File

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