From ea0e57067828394539d84c7f23e970fc5572b96b Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 8 Jan 2026 16:33:43 -0600 Subject: [PATCH] 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. --- .../api/v1/modules/a24/fa/fa_classes/dto.py | 107 ++ .../v1/modules/a24/fa/fa_classes/models.py | 17 +- .../v1/modules/a24/fa/fa_classes/routes.py | 32 + .../v1/modules/a24/fa/fa_classes/service.py | 223 ++++ backend/api/v1/modules/a24/router.py | 14 + backend/api/v1/modules/a76/classes/dto.py | 79 +- backend/api/v1/modules/a76/classes/models.py | 2 +- backend/api/v1/modules/a76/classes/routes.py | 67 +- backend/api/v1/modules/a76/classes/service.py | 257 +++- backend/api/v1/router.py | 2 + backend/main.py | 25 +- .../src/lib/api/dashboard/a24/fa_classes.ts | 110 ++ frontend/src/lib/api/dashboard/a76/classes.ts | 7 + .../classes/forms/FixedAssetClassForm.svelte | 1177 +++++++++++++++++ .../customs_brokers/create-dialog.svelte | 4 +- .../src/lib/components/sidebar/modules.ts | 8 +- .../catalogs/fixed-asset-classes/+page.svelte | 914 +++++++++++++ .../fixed_asset_classes/embed/+page.svelte | 17 + 18 files changed, 3014 insertions(+), 48 deletions(-) create mode 100644 backend/api/v1/modules/a24/fa/fa_classes/dto.py create mode 100644 backend/api/v1/modules/a24/fa/fa_classes/routes.py create mode 100644 backend/api/v1/modules/a24/fa/fa_classes/service.py create mode 100644 backend/api/v1/modules/a24/router.py create mode 100644 frontend/src/lib/api/dashboard/a24/fa_classes.ts create mode 100644 frontend/src/lib/components/dashboard/classes/forms/FixedAssetClassForm.svelte create mode 100644 frontend/src/routes/dashboard/catalogs/fixed-asset-classes/+page.svelte create mode 100644 frontend/src/routes/dashboard/merchandise/fixed_asset_classes/embed/+page.svelte diff --git a/backend/api/v1/modules/a24/fa/fa_classes/dto.py b/backend/api/v1/modules/a24/fa/fa_classes/dto.py new file mode 100644 index 00000000..671a010d --- /dev/null +++ b/backend/api/v1/modules/a24/fa/fa_classes/dto.py @@ -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) diff --git a/backend/api/v1/modules/a24/fa/fa_classes/models.py b/backend/api/v1/modules/a24/fa/fa_classes/models.py index 1f8f5fc4..90c8cb1f 100644 --- a/backend/api/v1/modules/a24/fa/fa_classes/models.py +++ b/backend/api/v1/modules/a24/fa/fa_classes/models.py @@ -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 diff --git a/backend/api/v1/modules/a24/fa/fa_classes/routes.py b/backend/api/v1/modules/a24/fa/fa_classes/routes.py new file mode 100644 index 00000000..9e57a5d6 --- /dev/null +++ b/backend/api/v1/modules/a24/fa/fa_classes/routes.py @@ -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 diff --git a/backend/api/v1/modules/a24/fa/fa_classes/service.py b/backend/api/v1/modules/a24/fa/fa_classes/service.py new file mode 100644 index 00000000..4a5caacf --- /dev/null +++ b/backend/api/v1/modules/a24/fa/fa_classes/service.py @@ -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)) diff --git a/backend/api/v1/modules/a24/router.py b/backend/api/v1/modules/a24/router.py new file mode 100644 index 00000000..237f172e --- /dev/null +++ b/backend/api/v1/modules/a24/router.py @@ -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"]) diff --git a/backend/api/v1/modules/a76/classes/dto.py b/backend/api/v1/modules/a76/classes/dto.py index a59dac9c..9b5a92a8 100644 --- a/backend/api/v1/modules/a76/classes/dto.py +++ b/backend/api/v1/modules/a76/classes/dto.py @@ -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 \ No newline at end of file diff --git a/backend/api/v1/modules/a76/classes/models.py b/backend/api/v1/modules/a76/classes/models.py index e12374f7..a5d1c851 100644 --- a/backend/api/v1/modules/a76/classes/models.py +++ b/backend/api/v1/modules/a76/classes/models.py @@ -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 diff --git a/backend/api/v1/modules/a76/classes/routes.py b/backend/api/v1/modules/a76/classes/routes.py index 14860e93..df1779ed 100644 --- a/backend/api/v1/modules/a76/classes/routes.py +++ b/backend/api/v1/modules/a76/classes/routes.py @@ -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 \ No newline at end of file diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index ff7e2a93..9f124632 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -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 diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 9fd2618e..b8d7f073 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 0959a83d..f0fee380 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/frontend/src/lib/api/dashboard/a24/fa_classes.ts b/frontend/src/lib/api/dashboard/a24/fa_classes.ts new file mode 100644 index 00000000..91359f52 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a24/fa_classes.ts @@ -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> => { + 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> => { + 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> => { + 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> => { + 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> => { + return api.delete(`/v1/a24/fa/classes/${id}?company_id=${company_id}`); + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/classes.ts b/frontend/src/lib/api/dashboard/a76/classes.ts index a08c2832..cd1e6c1b 100644 --- a/frontend/src/lib/api/dashboard/a76/classes.ts +++ b/frontend/src/lib/api/dashboard/a76/classes.ts @@ -103,5 +103,12 @@ export const classesApi = { */ delete: (id: number, company_id: number): Promise> => { 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> => { + return api.post(`/v1/a76/classes/seed?company_id=${company_id}&client_id=${client_id}`, {}); } }; diff --git a/frontend/src/lib/components/dashboard/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/classes/forms/FixedAssetClassForm.svelte new file mode 100644 index 00000000..16632751 --- /dev/null +++ b/frontend/src/lib/components/dashboard/classes/forms/FixedAssetClassForm.svelte @@ -0,0 +1,1177 @@ + + +
+ +
+
+ + { + // Limpiar error local si existe + if (validationErrors.class_code) { + const errors = { ...validationErrors }; + delete errors.class_code; + validationErrors = errors; + } + }} + onblur={() => validateField('class_code')} + /> + {#if validationErrors.class_code} +

{validationErrors.class_code}

+ {/if} +
+
+ + +
+
+ + +
+ + validateField('description_es')} + /> + {#if validationErrors.description_es} +

{validationErrors.description_es}

+ {/if} +
+ + +
+ + +
+ + +
+ +
+ validateField('material_key')} + /> + + + {formData.material_description || ''} + +
+ {#if validationErrors.material_key} +

{validationErrors.material_key}

+ {/if} +
+ + +
+ +
+ validateField('unit_of_measure')} + /> + + + {formData.unit_of_measure_description || ''} + + + Clave U.M.A: {formData.unit_measure_key || ''} + +
+ {#if validationErrors.unit_of_measure} +

{validationErrors.unit_of_measure}

+ {/if} +
+ + +
+ +
+ validateField('fraction')} + /> + + + U.M.T: {formData.fraction_umt || ''} + + + Clave U.M.A: {formData.fraction_uma_key || ''} + +
+ {#if validationErrors.fraction} +

{validationErrors.fraction}

+ {/if} +
+ + +
+ +
+ + + + Ad/valorem: {formData.us_fraction_ad_valorem || '0.00'} + + + Tasa Fija: {formData.us_fraction_fixed_rate || '0.00000000'} + +
+
+ + +
+ +
+ + % + +
+ + +
+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+
+ (formData.iva_exempt_fraction = true)} + class="h-4 w-4" + /> + +
+
+ (formData.iva_exempt_fraction = false)} + class="h-4 w-4" + /> + +
+
+
+ + +
+ +
+ + +
+
+
+ + + + + + + CATALOGO DE ACTIVO FIJO + +
+
+ + +
+
+ + + + + + + + + {#each filteredMaterialTypes as material (material.key)} + selectMaterial(material)} + > + + + + {/each} + +
ClaveDescripción
{material.key}{material.description}
+
+
+ + + +
+
+ + + + + + UNIDADES DE MEDIDA + +
+
+ + +
+
+ + + + + + + + + + + {#each filteredUnits as unit (unit.code)} + selectUnit(unit)} + > + + + + + + {/each} + +
CódigoDescripciónDescription (English)Clave Mexicana
{unit.code}{unit.description}{unit.descriptionEnglish}{unit.claveMexicana}
+
+
+ + + +
+
+ + + + + + CATALOGO DE FRACCIONES SITAR - SCAII + +
+
+ + +
+
+ + + + + + + + + + + {#each tariffFractions as fraction (fraction.code)} + selectFraction(fraction)} + > + + + + + + {:else} + + + + {/each} + +
FracciónNICODescripciónU.M.T
{fraction.fraction}{fraction.nico}{fraction.description}{fraction.umt}
+ {#if isLoadingFractions} + Cargando fracciones... + {:else} + No hay fracciones disponibles + {/if} +
+
+
+ + + +
+
+ + + + + + CATALOGO DE FRACCIONES AMERICANAS + +
+
+ + +
+
+ + + + + + + + + + + + {#each usTariffFractions as fraction (fraction.id)} + selectUSFraction(fraction)} + > + + + + + + + {:else} + + + + {/each} + +
CódigoPrefijoAd valoremCosto FijoDescripción
{fraction.code}{fraction.prefix || ''}{fraction.ad_valorem || '0.00'}{fraction.fixed_cost || '0.00'}{fraction.description || ''}
+ {#if isLoadingUSFractions} + Cargando fracciones... + {:else} + No hay fracciones disponibles + {/if} +
+
+
+ + + +
+
+ + + + + + CATALOGO DE DEPRECIACION + +
+
+ + +
+
+ + + + + + + + + + {#each depreciationCatalog as item (item.id)} + selectDepreciation(item)} + > + + + + + {:else} + + + + {/each} + +
FracciónDescripción% Depreciación
{item.fraction}{item.description}{item.depreciation_rate}%
+ {#if isLoadingDepreciation} + Cargando... + {:else} + No hay registros disponibles + {/if} +
+
+
+ + + +
+
+ + + + + + CATALOGO FDA + +
+
+ + +
+
+ + + + + + + + + {#each fdaCatalog as item (item.id)} + selectFDA(item)} + > + + + + {:else} + + + + {/each} + +
Clave FDADescripción
{item.fda_key}{item.description}
+ {#if isLoadingFDA} + Cargando... + {:else} + No hay registros disponibles + {/if} +
+
+
+ + + +
+
+ + + + + + CATALOGO DE CARTA PORTE + +
+
+ + +
+
+ + + + + + + + + {#each cartaPorteCatalog as item (item.id)} + { + formData.carta_porte_code = item.code; + showCartaPorteDialog = false; + }} + > + + + + {:else} + + + + {/each} + +
CódigoDescripción
{item.code}{item.description}
+ No hay registros disponibles +
+
+
+ + + +
+
diff --git a/frontend/src/lib/components/dashboard/customs_brokers/create-dialog.svelte b/frontend/src/lib/components/dashboard/customs_brokers/create-dialog.svelte index fd76c8cf..0b4393ff 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/create-dialog.svelte +++ b/frontend/src/lib/components/dashboard/customs_brokers/create-dialog.svelte @@ -205,10 +205,10 @@ - - + + + + +
+ + + + + + + + + + + + + + {#if isLoading} + + + + {:else if filteredClasses.length === 0} + + + + {:else} + {#each filteredClasses as cls (cls.id)} + selectClass(cls)} + > + + + + + + + + + {/each} + {/if} + +
+ + ClaseDescripción EspañolDescripción InglésTipoU.MFracción U.M.T. Fracción US
Cargando...
+ No hay clases de activo fijo registradas +
+ + + + {cls.class_code} + + {cls.description_es || ''}{cls.description_en || ''} + + {cls.material_key || ''} + + {cls.unit_of_measure || ''}{cls.fraction || ''} - {cls.us_fraction || '-'}
+
+ + + + +
+
+

Código de Clase

+

+ {formData.class_code || '---'} +

+
+ +
+
+
+ +

{formData.description_es || 'Sin descripción'}

+
+
+ +

{formData.description_en || 'No translation available'}

+
+
+ +
+
+ +
+ + {formData.material_key || '-'} +
+
+
+ + {formData.unit_of_measure || '-'} +
+
+ +
+ +

+ {formData.fraction || '0000.00.00'} +

+
+
+
+ + + + +
+
+ +
+ + + +
+
+
+ + + + + + {selectedClass ? 'Editar' : 'Nueva'} Clase de Activo Fijo + + + + {#if validationError} +
+
+
+ ! +
+
+

Error de Validación

+

{validationError}

+
+ +
+
+ {/if} + +
+ validationError = ''} + onSave={async (data: Partial) => { + // 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; + }} + /> +
+ + + + +
+
+ + + + + + ¿Confirmar eliminación? + +
+

+ ¿Estás seguro que deseas eliminar la clase {selectedClass?.class_code}? +

+

+ {selectedClass?.description_es} +

+

+ Esta acción no se puede deshacer. +

+
+ + + + +
+
diff --git a/frontend/src/routes/dashboard/merchandise/fixed_asset_classes/embed/+page.svelte b/frontend/src/routes/dashboard/merchandise/fixed_asset_classes/embed/+page.svelte new file mode 100644 index 00000000..928dc6fd --- /dev/null +++ b/frontend/src/routes/dashboard/merchandise/fixed_asset_classes/embed/+page.svelte @@ -0,0 +1,17 @@ + + +
+ +