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

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

View File

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

View File

@@ -13,8 +13,10 @@ from core.middleware import (
RequestLoggingMiddleware,
TenantMiddleware,
)
from fastapi import FastAPI
from fastapi import FastAPI, Request, status, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from api.v1.modules.a76.items.models import Item # Importar rutas para registrar con el router
from api.v1.modules.a76.items.series.models import Serie # Importar modelos para registrar con SQLAlchemy
@@ -38,6 +40,27 @@ app = FastAPI(
)
# Add validation error handler
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.error(f"Validation error for {request.method} {request.url.path}: {exc.errors()}")
logger.error(f"Request body: {await request.body()}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.errors(), "body": exc.body},
)
# Add HTTP exception handler
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
# Inicializar la base de datos
@app.on_event("startup")
async def on_startup():

View File

@@ -0,0 +1,110 @@
/**
* API para gestión de Fixed Asset Classes (Clases de Activos Fijos A24)
*/
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface FAClass {
id: number;
tenant_id: number;
company_id: number;
class_id: number;
import_tariff_code: string | null;
import_tariff_type: string | null;
export_tariff_code: string | null;
export_tariff_type: string | null;
depreciation_rate: number | null;
fda_code: string | null;
eccn_code: string | null;
class_enabled: boolean | null;
created_at: string;
updated_at: string;
}
export interface FAClassCreate {
class_id: number;
import_tariff_code?: string | null;
import_tariff_type?: string | null;
export_tariff_code?: string | null;
export_tariff_type?: string | null;
depreciation_rate?: number | null;
fda_code?: string | null;
eccn_code?: string | null;
class_enabled?: boolean;
}
export interface FAClassUpdate {
import_tariff_code?: string | null;
import_tariff_type?: string | null;
export_tariff_code?: string | null;
export_tariff_type?: string | null;
depreciation_rate?: number | null;
fda_code?: string | null;
eccn_code?: string | null;
class_enabled?: boolean;
}
export interface FAClassListResponse {
items: FAClass[];
total: number;
page: number;
page_size: number;
}
export interface FAClassListParams {
company_id: number;
page?: number;
page_size?: number;
class_id?: number;
fda_code?: string;
class_enabled?: boolean;
}
/**
* API de Fixed Asset Classes
*/
export const faClassesApi = {
/**
* Obtener lista de clases de activos fijos con paginación
*/
list: (params: FAClassListParams): Promise<ApiResponse<FAClassListResponse>> => {
const { company_id, page = 1, page_size = 50, ...filters } = params;
const queryParams = new URLSearchParams({
company_id: company_id.toString(),
page: page.toString(),
page_size: page_size.toString(),
...Object.fromEntries(
Object.entries(filters).filter(([_, v]) => v !== undefined).map(([k, v]) => [k, String(v)])
)
});
return api.get(`/v1/a24/fa/classes/?${queryParams}`);
},
/**
* Obtener una clase de activo fijo por ID
*/
get: (id: number, company_id: number): Promise<ApiResponse<FAClass>> => {
return api.get(`/v1/a24/fa/classes/${id}?company_id=${company_id}`);
},
/**
* Crear una nueva clase de activo fijo
*/
create: (data: FAClassCreate, company_id: number): Promise<ApiResponse<FAClass>> => {
return api.post(`/v1/a24/fa/classes/?company_id=${company_id}`, data);
},
/**
* Actualizar una clase de activo fijo existente
*/
update: (id: number, data: FAClassUpdate, company_id: number): Promise<ApiResponse<FAClass>> => {
return api.put(`/v1/a24/fa/classes/${id}?company_id=${company_id}`, data);
},
/**
* Eliminar una clase de activo fijo
*/
delete: (id: number, company_id: number): Promise<ApiResponse<void>> => {
return api.delete(`/v1/a24/fa/classes/${id}?company_id=${company_id}`);
}
};

View File

@@ -103,5 +103,12 @@ export const classesApi = {
*/
delete: (id: number, company_id: number): Promise<ApiResponse<void>> => {
return api.delete(`/v1/a76/classes/${id}?company_id=${company_id}`);
},
/**
* Inicializar datos semilla de clases de activo fijo
*/
seed: (company_id: number, client_id: number): Promise<ApiResponse<{ message: string; count: number }>> => {
return api.post(`/v1/a76/classes/seed?company_id=${company_id}&client_id=${client_id}`, {});
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -205,10 +205,10 @@
</div>
<Dialog.Footer>
<Button variant="outline" on:click={() => (open = false)} disabled={loading}>
<Button variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
<Button on:click={handleSubmit} disabled={loading}>
<Button onclick={handleSubmit} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Guardando

View File

@@ -159,6 +159,10 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.company_information"](),
url: "/dashboard/general_catalogs/company_information",
},
{
title: "Catálogo de Clases de Activo Fijo",
url: "/dashboard/catalogs/fixed-asset-classes",
},
{
title: m["sidebar.general_catalogs.packages"](),
url: "/dashboard/general_catalogs/packages",
@@ -372,8 +376,8 @@ export function getSidebarData(): SidebarData {
icon: Package,
items: [
{
title: "Clase de Activo Fijo",
url: "/dashboard/merchandise/fixed_asset_classes",
title: "Catálogo de Clases de Activo Fijo",
url: "/dashboard/catalogs/fixed-asset-classes",
},
],
},

View File

@@ -0,0 +1,914 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import FixedAssetClassForm from '$lib/components/dashboard/classes/forms/FixedAssetClassForm.svelte';
import { Folder, Save, Plus, RefreshCw } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
import { faClassesApi, type FAClass } from '$lib/api/dashboard/a24/fa_classes';
import { companyStore } from '$lib/stores/company.svelte';
import { getToken } from '$lib/auth';
import { onMount } from 'svelte';
// Tipo extendido que combina A76Class y FAClass
interface FixedAssetClassExtended extends A76Class {
fa_class_id?: number;
depreciation_rate?: number | null;
fda_code?: string | null;
class_enabled?: boolean | null;
}
// Estado de la lista de clases
let classes = $state<FixedAssetClassExtended[]>([]);
let selectedClass = $state<FixedAssetClassExtended | null>(null);
let isLoading = $state(false);
let searchTerm = $state('');
let searchDescription = $state('');
let searchType = $state('');
let searchFraction = $state('');
let showInsertDialog = $state(false);
let showDeleteDialog = $state(false);
let validationError = $state<string>('');
let isSaving = $state(false);
// Estado del formulario
let formData = $state({
class_code: '',
description_es: '',
description_en: '',
material_key: '',
unit_of_measure: '',
fraction: '',
us_fraction: '',
unit_measure_trade: '',
bom: ''
});
// Clases filtradas según búsqueda
const filteredClasses = $derived(
classes.filter((c) => {
// Filtro por código de clase
const matchesCode = !searchTerm ||
c.class_code.toLowerCase().includes(searchTerm.toLowerCase());
// Filtro por descripción (español o inglés)
const matchesDescription = !searchDescription ||
(c.description_es?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) ||
(c.description_en?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false);
// Filtro por tipo de material
const matchesType = !searchType ||
(c.material_key?.toLowerCase().includes(searchType.toLowerCase()) ?? false);
// Filtro por fracción arancelaria
const matchesFraction = !searchFraction ||
(c.fraction?.toLowerCase().includes(searchFraction.toLowerCase()) ?? false);
return matchesCode && matchesDescription && matchesType && matchesFraction;
})
);
// Reactively load classes when company changes
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (companyId) {
loadClasses();
}
});
async function loadClasses() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
console.log('No company selected, skipping load');
return;
}
isLoading = true;
try {
console.log('Cargando clases para company:', companyId);
const response = await classesApi.list({
company_id: companyId,
page: 1,
page_size: 1000
});
if (!response.data) return;
// Para cada clase base, intentar cargar sus datos de activo fijo
const classesWithFA = await Promise.all(
response.data.items.map(async (baseClass) => {
try {
const faResponse = await faClassesApi.list({
company_id: companyId,
class_id: baseClass.id,
page: 1,
page_size: 1
});
const faData = faResponse.data?.items[0];
return {
...baseClass,
fa_class_id: faData?.id,
depreciation_rate: faData?.depreciation_rate,
fda_code: faData?.fda_code,
class_enabled: faData?.class_enabled
} as FixedAssetClassExtended;
} catch (error) {
// Si no tiene FA class, solo retornar la clase base
return baseClass as FixedAssetClassExtended;
}
})
);
classes = classesWithFA;
console.log('Clases cargadas:', classes.length);
} catch (error) {
console.error('Error cargando clases:', error);
toast.error('Error al cargar las clases de activo fijo');
} finally {
isLoading = false;
}
}
function selectClass(cls: A76Class) {
selectedClass = cls;
formData = {
class_code: cls.class_code,
description_es: cls.description_es || '',
description_en: cls.description_en || '',
material_key: cls.material_key || '',
unit_of_measure: cls.unit_of_measure || '',
fraction: cls.fraction || '',
us_fraction: cls.us_fraction || '',
unit_measure_trade: '',
bom: ''
};
}
async function saveFixedAssetClass(formData: any) {
console.log('=== INICIO saveFixedAssetClass ===');
const companyId = companyStore.activeCompany?.id;
const clientId = 2; // Cliente demo creado en la base de datos
// CAMBIO: Usar $state.snapshot para obtener una copia real, no reactiva
const data = $state.snapshot(formData);
console.log('saveFixedAssetClass called with data:', data);
if (!companyId) {
toast.error('No hay empresa seleccionada');
throw new Error('No hay empresa seleccionada');
}
// Validar campos obligatorios
const missingFields: string[] = [];
if (!data.class_code?.trim()) {
missingFields.push('Código de clase');
}
if (!data.description_es?.trim()) {
missingFields.push('Descripción en español');
}
if (!data.material_key?.trim()) {
missingFields.push('Tipo de activo fijo');
}
if (!data.unit_of_measure?.trim()) {
missingFields.push('Unidad de medida comercial');
}
if (!data.fraction?.trim()) {
missingFields.push('Fracción arancelaria');
}
if (missingFields.length > 0) {
const fieldsList = missingFields.join(', ');
validationError = `Debe completar los siguientes campos obligatorios: ${fieldsList}`;
toast.error(validationError, {
duration: 8000
});
throw new Error(`Campos obligatorios faltantes: ${fieldsList}`);
}
// Limpiar error de validación si todo está bien
validationError = '';
try {
// Usar el endpoint combinado /fa que crea ambos registros en una transacción
const token = await getToken();
if (!token) {
toast.error('No estás autenticado');
throw new Error('No estás autenticado');
}
const payload = {
client_id: clientId,
class_code: data.class_code.trim(),
description_es: data.description_es.trim(),
description_en: data.description_en?.trim() || '',
material_key: data.material_key.trim(),
unit_of_measure: data.unit_of_measure.trim(),
fraction: data.fraction.trim(),
us_fraction: data.us_fraction?.trim() || '',
sub_key: data.sub_key || '',
physical_review: data.physical_review ? 1 : 0,
iva_exempt_fraction: data.iva_exempt_fraction || '',
// FA-specific fields
import_tariff_code: data.import_tariff_code || null,
import_tariff_type: data.import_tariff_type || null,
export_tariff_code: data.export_tariff_code || null,
export_tariff_type: data.export_tariff_type || null,
depreciation_rate: data.annual_depreciation_rate || null,
fda_code: data.fda_key || null,
eccn_code: data.eccn_code || null,
class_enabled: true
};
console.log('Sending payload:', payload);
const response = await fetch(`http://localhost:8000/api/v1/a76/classes/fa?company_id=${companyId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
console.error('Server error:', error);
// Manejar diferentes formatos de error
let errorMessage = 'Error al crear la clase';
let isDuplicateError = false;
if (error.detail) {
if (Array.isArray(error.detail)) {
// Si detail es un array (errores de validación de Pydantic)
errorMessage = error.detail.map((e: any) =>
`${e.loc ? e.loc.join(' → ') : ''}: ${e.msg || e}`
).join(', ');
} else if (typeof error.detail === 'string') {
errorMessage = error.detail;
// Detectar si es un error de código duplicado
if (errorMessage.includes('código') && errorMessage.includes('ya está en uso')) {
isDuplicateError = true;
}
} else {
errorMessage = JSON.stringify(error.detail);
}
}
// Mensaje más específico para errores de duplicado
console.log('isDuplicateError:', isDuplicateError);
if (isDuplicateError) {
validationError = `⚠️ ${errorMessage}\n\nPor favor, cambie el código de clase a uno diferente.`;
} else {
validationError = `⚠️ ${errorMessage}`;
}
console.log('MENSAJE ASIGNADO (save):', validationError);
toast.error(errorMessage, { duration: 8000 });
throw new Error(errorMessage);
}
const responseData = await response.json();
validationError = '';
toast.success('✅ Clase de activo fijo creada correctamente');
return responseData;
} catch (error: any) {
console.error('Error saving fixed asset class:', error);
// El toast ya se mostró arriba, solo re-lanzar el error
throw error;
}
}
async function updateFixedAssetClass(formData: any) {
console.log('=== INICIO updateFixedAssetClass ===');
const companyId = companyStore.activeCompany?.id;
// CAMBIO 1: Usar $state.snapshot para obtener una copia real, no reactiva
// Esto garantiza que aunque el hijo borre el formulario, 'data' mantenga los valores
const data = $state.snapshot(formData);
console.log('updateFixedAssetClass called with snapshot data:', data);
if (!companyId || !selectedClass) {
toast.error('No hay empresa o clase seleccionada');
return;
}
// CAMBIO 2: Validar sobre 'data' (la copia muerta)
const missingFields: string[] = [];
if (!data.class_code?.trim()) missingFields.push('Código de clase');
if (!data.description_es?.trim()) missingFields.push('Descripción en español');
if (!data.material_key?.trim()) missingFields.push('Tipo de activo fijo');
if (!data.unit_of_measure?.trim()) missingFields.push('Unidad de medida comercial');
if (!data.fraction?.trim()) missingFields.push('Fracción arancelaria');
if (missingFields.length > 0) {
const errorMsg = `Campos obligatorios faltantes: ${missingFields.join(', ')}`;
validationError = `⚠️ ${errorMsg}`;
toast.error(errorMsg);
// Lanzamos el error para que el 'onSave' del Dialog no cierre la ventana
throw new Error(errorMsg);
}
validationError = '';
try {
// CAMBIO 3: Usar siempre 'data' para los payloads
const a76Response = await classesApi.update(selectedClass.id, {
class_code: data.class_code.trim(),
description_es: data.description_es.trim(),
description_en: data.description_en?.trim() || '',
material_key: data.material_key.trim(),
unit_of_measure: data.unit_of_measure.trim(),
fraction: data.fraction.trim(),
us_fraction: data.us_fraction || '',
physical_review: data.physical_review ? 1 : 0,
iva_exempt_fraction: data.iva_exempt_fraction || ''
}, companyId);
if (selectedClass.fa_class_id) {
await faClassesApi.update(selectedClass.fa_class_id, {
depreciation_rate: data.annual_depreciation_rate || null,
fda_code: data.fda_key || null
}, companyId);
} else {
await faClassesApi.create({
class_id: selectedClass.id,
depreciation_rate: data.annual_depreciation_rate || null,
fda_code: data.fda_key || null,
class_enabled: true
}, companyId);
}
toast.success('Clase actualizada correctamente');
return { a76: a76Response.data };
} catch (error: any) {
console.error('Error updating fixed asset class:', error);
console.error('Error response:', error?.response);
console.error('Error response data:', error?.response?.data);
console.error('Error response detail:', error?.response?.data?.detail);
console.error('Error type:', typeof error?.response?.data?.detail);
let errorMessage = 'Error al actualizar la clase';
let isDuplicateError = false;
// Extract error message from response
if (error?.response?.data?.detail) {
if (Array.isArray(error.response.data.detail)) {
errorMessage = error.response.data.detail.map((e: any) =>
`${e.loc ? e.loc.join(' → ') : ''}: ${e.msg || e}`
).join(', ');
} else if (typeof error.response.data.detail === 'string') {
errorMessage = error.response.data.detail;
// Detectar si es un error de código duplicado
if (errorMessage.includes('código') && errorMessage.includes('ya está en uso')) {
isDuplicateError = true;
}
} else {
errorMessage = JSON.stringify(error.response.data.detail);
}
} else if (error?.message) {
errorMessage = error.message;
}
console.error('Final error message:', errorMessage);
console.error('Is duplicate error:', isDuplicateError);
// Mensaje más específico para errores de duplicado
console.log('isDuplicateError:', isDuplicateError);
if (isDuplicateError) {
validationError = `⚠️ ${errorMessage}\n\nPor favor, cambie el código de clase a uno diferente.`;
} else {
validationError = `⚠️ ${errorMessage}`;
}
console.log('MENSAJE ASIGNADO (update):', validationError);
toast.error(errorMessage, { duration: 8000 });
console.error('Toast shown, about to throw error');
throw error;
}
}
function handleNew() {
selectedClass = null;
formData = {
class_code: '',
description_es: '',
description_en: '',
material_key: '',
unit_of_measure: '',
fraction: '',
us_fraction: '',
unit_measure_trade: '',
bom: ''
};
}
async function handleRefresh() {
await loadClasses();
toast.success('Clases actualizadas');
}
function handleDelete() {
if (!selectedClass) {
toast.error('Selecciona una clase para borrar');
return;
}
showDeleteDialog = true;
}
async function confirmDelete() {
if (!selectedClass) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('No hay empresa seleccionada');
return;
}
const classToDelete = selectedClass;
try {
// El backend ahora elimina automáticamente la extensión FA si existe
await classesApi.delete(classToDelete.id, companyId);
toast.success(`Clase ${classToDelete.class_code} eliminada correctamente`);
// Recargar lista
await loadClasses();
selectedClass = null;
showDeleteDialog = false;
formData = {
class_code: '',
description_es: '',
description_en: '',
material_key: '',
unit_of_measure: '',
fraction: '',
us_fraction: '',
unit_measure_trade: '',
bom: ''
};
} catch (error) {
console.error('Error deleting class:', error);
toast.error('Error al eliminar la clase');
}
}
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
<!-- Título -->
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold">CATALOGO DE CLASES DE ACTIVO FIJO</h1>
<p class="text-sm text-muted-foreground">
Gestiona y consulta las clases de activo fijo
</p>
</div>
<!-- Contenedor principal con grid y detalles -->
<div class="flex-1 flex gap-4 overflow-hidden">
<!-- Panel izquierdo: Grid/Tabla de clases -->
<div class="flex-1 flex flex-col gap-4 overflow-hidden">
<!-- Sección de Filtros -->
<div class="border rounded-lg bg-card">
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">
Filtra las clases por diferentes criterios (los filtros se aplican automáticamente)
</span>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label class="text-xs">Clase</Label>
<Input
bind:value={searchTerm}
placeholder="Ej: AF001"
class="h-9"
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Descripción</Label>
<Input
bind:value={searchDescription}
placeholder="Buscar descripción..."
class="h-9"
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Tipo</Label>
<Input
bind:value={searchType}
placeholder="MP, SC, DESP..."
class="h-9"
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Fracción</Label>
<Input
bind:value={searchFraction}
placeholder="Fracción arancelaria"
class="h-9"
/>
</div>
</div>
</div>
</div>
<!-- Tabla de clases -->
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-3 border-b bg-white dark:bg-muted/50">
<h2 class="text-sm font-semibold">Listado de Clases</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
Mostrando de {filteredClasses.length} registros
</span>
<Button variant="outline" size="sm" onclick={handleRefresh}>
<RefreshCw class="h-4 w-4 mr-2" />
Actualizar
</Button>
</div>
</div>
<!-- Tabla de clases -->
<div class="flex-1 overflow-auto">
<table class="w-full text-sm">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white sticky top-0 z-10 border-b">
<tr>
<th class="px-2 py-2 text-left w-8">
<input type="checkbox" class="h-4 w-4" />
</th>
<th class="px-2 py-2 text-left">Clase</th>
<th class="px-2 py-2 text-left">Descripción Español</th>
<th class="px-2 py-2 text-left">Descripción Inglés</th>
<th class="px-2 py-2 text-left">Tipo</th>
<th class="px-2 py-2 text-left">U.M</th>
<th class="px-2 py-2 text-left">Fracción</th> <th class="px-2 py-2 text-left">U.M.T.</th> <th class="px-2 py-2 text-left">Fracción US</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr>
<td colspan="10" class="text-center py-8 text-muted-foreground">Cargando...</td>
</tr>
{:else if filteredClasses.length === 0}
<tr>
<td colspan="10" class="text-center py-8 text-muted-foreground">
No hay clases de activo fijo registradas
</td>
</tr>
{:else}
{#each filteredClasses as cls (cls.id)}
<tr
class="border-b cursor-pointer transition-colors {selectedClass?.id ===
cls.id
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => selectClass(cls)}
>
<td class="px-2 py-1">
<input
type="checkbox"
checked={selectedClass?.id === cls.id}
class="h-4 w-4"
/>
</td>
<td class="px-2 py-1">
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-mono font-bold text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
{cls.class_code}
</span>
</td>
<td class="px-2 py-1 font-medium text-sm">{cls.description_es || ''}</td>
<td class="px-2 py-1">{cls.description_en || ''}</td>
<td class="px-2 py-1">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider {cls.material_key === 'MP' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : cls.material_key === 'SC' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' : cls.material_key === 'DESP' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-400'}">
{cls.material_key || ''}
</span>
</td>
<td class="px-2 py-1 text-muted-foreground">{cls.unit_of_measure || ''}</td>
<td class="px-2 py-1 font-mono text-xs text-orange-600 dark:text-orange-400">{cls.fraction || ''}</td> <td class="px-2 py-1 text-xs text-muted-foreground">-</td> <td class="px-2 py-1">{cls.us_fraction || '-'}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
<!-- Panel derecho: Detalles y edición -->
<div class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden">
<div class="p-4 border-b">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">Código de Clase</p>
<h2 class="text-3xl font-black font-mono tracking-tighter">
{formData.class_code || '---'}
</h2>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
<div class="space-y-3">
<div>
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Descripción ES</Label>
<p class="text-sm font-semibold leading-tight">{formData.description_es || 'Sin descripción'}</p>
</div>
<div class="pt-2 border-t border-dashed">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Description EN</Label>
<p class="text-sm italic text-muted-foreground">{formData.description_en || 'No translation available'}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4 pt-4 border-t">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Tipo Activo</Label>
<div class="flex items-center gap-2">
<Folder class="h-3 w-3 text-blue-500" />
<span class="text-sm font-bold">{formData.material_key || '-'}</span>
</div>
</div>
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">U.M. Com.</Label>
<span class="text-sm font-bold">{formData.unit_of_measure || '-'}</span>
</div>
</div>
<div class="p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-100 dark:border-orange-900">
<Label class="text-[10px] uppercase text-orange-600 dark:text-orange-400 font-bold">Fracción Arancelaria</Label>
<p class="text-lg font-mono font-bold text-orange-700 dark:text-orange-300">
{formData.fraction || '0000.00.00'}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer fijo con botones de acción -->
<div class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5]">
<div class="px-4 py-4 max-w-[1400px] mx-auto">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
<Button size="sm" onclick={() => {
selectedClass = null;
validationError = '';
showInsertDialog = true;
}}>
<Plus class="h-4 w-4 mr-1" />
Insertar
</Button>
<Button variant="outline" size="sm" onclick={() => {
if (!selectedClass) {
toast.error('Selecciona una clase para editar');
return;
}
validationError = '';
showInsertDialog = true;
}} disabled={!selectedClass}>Editar</Button>
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedClass}>Borrar</Button>
</div>
</div>
</div>
<!-- Dialog para Insertar/Editar Clase de Activo Fijo -->
<Dialog.Root bind:open={showInsertDialog}>
<Dialog.Content class="!max-w-[1600px] !w-[1600px] !h-[90vh] p-0 overflow-hidden flex flex-col">
<Dialog.Header class="p-6 pb-4 border-b">
<Dialog.Title>{selectedClass ? 'Editar' : 'Nueva'} Clase de Activo Fijo</Dialog.Title>
</Dialog.Header>
<!-- Mensaje de error de validación -->
{#if validationError}
<div class="mx-6 mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-sm font-bold mt-0.5">
!
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-red-800 mb-1">Error de Validación</h3>
<p class="text-sm text-red-700 whitespace-pre-line">{validationError}</p>
</div>
<button
onclick={() => validationError = ''}
class="flex-shrink-0 text-red-400 hover:text-red-600"
aria-label="Cerrar mensaje de error">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}
<div class="flex-1 overflow-auto p-6">
<FixedAssetClassForm
initialData={selectedClass}
externalError={validationError}
onClearError={() => validationError = ''}
onSave={async (data: Partial<FixedAssetClassExtended>) => {
// Evitar múltiples clics
if (isSaving) {
console.log('⚠️ Ya está guardando, ignorando clic');
return;
}
isSaving = true;
validationError = '';
console.log('========================================');
console.log('=== INICIO ONSAVE ===');
console.log('Datos recibidos:', data);
console.log('selectedClass:', selectedClass);
console.log('========================================');
try {
const cleanData = $state.snapshot(data);
const companyId = companyStore.activeCompany?.id;
const token = await getToken();
if (!companyId) {
throw new Error('No hay empresa seleccionada');
}
if (!token) {
throw new Error('No estás autenticado');
}
let response;
if (selectedClass?.id) {
// === ACTUALIZACIÓN ===
console.log('🔄 MODO: ACTUALIZACIÓN');
console.log('ID de clase:', selectedClass.id);
response = await classesApi.update(selectedClass.id, {
class_code: cleanData.class_code?.trim() || '',
description_es: cleanData.description_es?.trim() || '',
description_en: cleanData.description_en?.trim() || '',
material_key: cleanData.material_key?.trim() || '',
unit_of_measure: cleanData.unit_of_measure?.trim() || '',
fraction: cleanData.fraction?.trim() || '',
us_fraction: cleanData.us_fraction || '',
physical_review: cleanData.physical_review ? 1 : 0,
iva_exempt_fraction: cleanData.iva_exempt_fraction || ''
}, companyId);
// ¡IMPORTANTE! fetchApi NO lanza excepciones, retorna { error, status }
if (response.error) {
console.error('❌ Error en respuesta de actualización:', response);
throw new Error(response.error);
}
console.log('✅ Actualización exitosa');
} else {
// === CREACIÓN ===
console.log(' MODO: CREACIÓN');
const payload = {
client_id: 2,
class_code: cleanData.class_code?.trim() || '',
description_es: cleanData.description_es?.trim() || '',
description_en: cleanData.description_en?.trim() || '',
material_key: cleanData.material_key?.trim() || '',
unit_of_measure: cleanData.unit_of_measure?.trim() || '',
fraction: cleanData.fraction?.trim() || '',
us_fraction: cleanData.us_fraction?.trim() || '',
sub_key: cleanData.sub_key || '',
physical_review: cleanData.physical_review ? 1 : 0,
iva_exempt_fraction: cleanData.iva_exempt_fraction || '',
depreciation_rate: cleanData.depreciation_rate || null,
fda_code: cleanData.fda_code || null,
class_enabled: true
};
console.log('Payload:', payload);
const fetchResponse = await fetch(`http://localhost:8000/api/v1/a76/classes/fa?company_id=${companyId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (!fetchResponse.ok) {
const errorData = await fetchResponse.json();
console.error('❌ Error del servidor:', errorData);
throw errorData;
}
response = await fetchResponse.json();
console.log('✅ Creación exitosa');
}
// === ÉXITO TOTAL ===
console.log('✅ GUARDADO EXITOSO - Cerrando diálogo');
const wasUpdate = !!selectedClass?.id;
await loadClasses();
showInsertDialog = false;
selectedClass = null;
validationError = '';
toast.success(wasUpdate ? 'Clase actualizada correctamente' : 'Clase creada correctamente');
} catch (error: any) {
// === ERROR ===
console.error('========================================');
console.error('❌ ERROR CAPTURADO');
console.error('Error:', error);
console.error('Error.response:', error?.response);
console.error('Error.response.data:', error?.response?.data);
console.error('Error.detail:', error?.detail);
console.error('========================================');
let errorMsg = 'Error al guardar';
// Primero intentar con error.detail (fetch directo)
if (error?.detail) {
if (typeof error.detail === 'string') {
errorMsg = error.detail;
} else if (Array.isArray(error.detail)) {
errorMsg = error.detail.map((e: any) => e.msg || e).join(', ');
}
}
// Luego con error.response.data.detail (axios)
else if (error?.response?.data?.detail) {
if (typeof error.response.data.detail === 'string') {
errorMsg = error.response.data.detail;
} else if (Array.isArray(error.response.data.detail)) {
errorMsg = error.response.data.detail.map((e: any) => e.msg || e).join(', ');
}
}
// Por último el mensaje genérico
else if (error?.message) {
errorMsg = error.message;
}
console.error('📝 Mensaje de error extraído:', errorMsg);
validationError = errorMsg;
console.error('🔴 validationError asignado:', validationError);
console.error('🔴 showInsertDialog permanece:', showInsertDialog);
console.error('========================================');
// NO cerramos el diálogo, permanece abierto
} finally {
isSaving = false;
console.log('✅ isSaving = false');
}
}}
onCancel={() => {
showInsertDialog = false;
selectedClass = null;
}}
/>
</div>
<Dialog.Footer class="p-6 pt-4 border-t">
<Button variant="outline" onclick={() => { validationError = ''; showInsertDialog = false;
selectedClass = null;
}}>Cancelar</Button>
<Button type="button" disabled={isSaving} onclick={() => {
// Trigger the form's handleSave by getting a reference via DOM
const saveEvent = new CustomEvent('save-form');
document.dispatchEvent(saveEvent);
}}>
{#if isSaving}
<RefreshCw class="h-4 w-4 mr-2 animate-spin" />
Guardando...
{:else}
<Save class="h-4 w-4 mr-2" />
Guardar
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Dialog de confirmación para borrar -->
<Dialog.Root bind:open={showDeleteDialog}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>¿Confirmar eliminación?</Dialog.Title>
</Dialog.Header>
<div class="py-4">
<p class="text-sm text-muted-foreground">
¿Estás seguro que deseas eliminar la clase <strong class="text-foreground">{selectedClass?.class_code}</strong>?
</p>
<p class="text-sm text-muted-foreground mt-2">
{selectedClass?.description_es}
</p>
<p class="text-sm text-destructive mt-4">
Esta acción no se puede deshacer.
</p>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => showDeleteDialog = false}>Cancelar</Button>
<Button variant="destructive" onclick={confirmDelete}>Eliminar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
// Redirigir a la página principal después de guardar
function handleSaveAndClose() {
// Aquí se guardarían los datos
window.parent.postMessage({ type: 'close' }, '*');
}
</script>
<div class="p-4">
<iframe
src="/dashboard/merchandise/fixed_asset_classes"
class="w-full h-[80vh] border-0"
title="Clase de Activo Fijo"
></iframe>
</div>