From 8d189aaaf1c538be8eb1a345b31099b683e28153 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 5 Jan 2026 17:37:20 -0600 Subject: [PATCH] Se termino deintegrar el crud de partes y clases --- backend/api/v1/modules/a76/parts/dto.py | 228 ++------ backend/api/v1/modules/a76/parts/routes.py | 405 +------------- backend/api/v1/modules/a76/parts/service.py | 363 +++--------- frontend/src/lib/api/dashboard/a76/parts.ts | 122 ++++ .../lib/components/dashboard/parts/columns.ts | 178 ++++++ .../dashboard/parts/create-edit-dialog.svelte | 520 ++++++++++++++++++ .../dashboard/parts/data-table-actions.svelte | 101 ++++ .../dashboard/parts/data-table.svelte | 123 +++++ .../dashboard/goods/classes/+page.server.ts | 15 +- .../dashboard/goods/parts/+page.server.ts | 60 ++ .../routes/dashboard/goods/parts/+page.svelte | 147 ++++- .../goods/parts/edit/[[id]]/+page.svelte | 441 +++++++++++++++ 12 files changed, 1843 insertions(+), 860 deletions(-) create mode 100644 frontend/src/lib/api/dashboard/a76/parts.ts create mode 100644 frontend/src/lib/components/dashboard/parts/columns.ts create mode 100644 frontend/src/lib/components/dashboard/parts/create-edit-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/parts/data-table-actions.svelte create mode 100644 frontend/src/lib/components/dashboard/parts/data-table.svelte create mode 100644 frontend/src/routes/dashboard/goods/parts/+page.server.ts create mode 100644 frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index e21e7772..e8c891fa 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -1,232 +1,78 @@ -""" -DTOs (Data Transfer Objects) para módulo de partes/componentes -Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS -""" - from datetime import datetime from decimal import Decimal -from typing import Optional - +from typing import List, Optional from pydantic import BaseModel, Field - class PartCreateDTO(BaseModel): - """DTO para crear una parte""" - - client_id: int = Field(..., description="Client key") - part_number: str = Field(..., max_length=49, description="Part number") - fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction") - description_spanish: Optional[str] = Field( - None, max_length=500, description="Description in Spanish" - ) - description_english: Optional[str] = Field( - None, max_length=500, description="Description in English" - ) - part_class: Optional[str] = Field(None, max_length=8, description="Part class") - unit_of_measure: Optional[str] = Field( - None, max_length=5, description="Unit of measure" - ) - commercial_part_number: Optional[str] = Field( - None, max_length=70, description="Commercial part number" - ) - country_of_origin: Optional[str] = Field( - None, max_length=3, description="Country of origin code" - ) - - # Pricing and currency - unit_cost: Optional[Decimal] = Field(None, description="Unit cost") - currency_type: Optional[str] = Field( - None, max_length=2, description="Currency type" - ) - currency_key: Optional[str] = Field(None, max_length=3, description="Currency key") - - # Weight information - unit_weight: Optional[Decimal] = Field(None, description="Unit weight") - weight_type: Optional[str] = Field(None, max_length=6, description="Weight type") - - # Classification and regulatory - us_fraction: Optional[str] = Field( - None, max_length=16, description="US tariff fraction" - ) - fda_key: Optional[str] = Field(None, max_length=20, description="FDA key") - fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") - license_code: Optional[str] = Field(None, max_length=3, description="License code") - eccn: Optional[str] = Field( - None, max_length=20, description="Export Control Classification Number" - ) - export_code: Optional[str] = Field(None, max_length=2, description="Export code") - exclusion_symbol: Optional[str] = Field( - None, max_length=19, description="Exclusion symbol" - ) - - # Additional information - supplier: Optional[str] = Field(None, max_length=14, description="Supplier") - alternate_unit_measure: Optional[str] = Field( - None, max_length=14, description="Alternate unit of measure" - ) - added_value: Optional[Decimal] = Field(None, description="Added value") - - # Status and media - is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") - creation_date: Optional[int] = Field(None, description="Creation date") - part_photo: Optional[str] = Field( - None, max_length=255, description="Part photo URL" - ) - - class Config: - from_attributes = True - + client_id: int + part_number: str = Field(..., max_length=50) + description_spanish: Optional[str] = None + description_english: Optional[str] = None + part_class: Optional[str] = None + unit_of_measure: Optional[str] = "PZ" + commercial_part_number: Optional[str] = None + country_of_origin: Optional[str] = "MEX" + unit_cost: Optional[Decimal] = Decimal("0.0") + currency_key: Optional[str] = "USD" + unit_weight: Optional[Decimal] = Decimal("0.0") + weight_type: Optional[str] = "KG" + fraction: Optional[str] = None + us_fraction: Optional[str] = None + supplier: Optional[str] = None + fda_key: Optional[str] = None + fcc_key: Optional[str] = None + eccn: Optional[str] = None + is_active: Optional[bool] = True class PartUpdateDTO(BaseModel): - """DTO para actualizar una parte""" - - fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction") - description_spanish: Optional[str] = Field( - None, max_length=500, description="Description in Spanish" - ) - description_english: Optional[str] = Field( - None, max_length=500, description="Description in English" - ) - part_class: Optional[str] = Field(None, max_length=8, description="Part class") - unit_of_measure: Optional[str] = Field( - None, max_length=5, description="Unit of measure" - ) - commercial_part_number: Optional[str] = Field( - None, max_length=70, description="Commercial part number" - ) - country_of_origin: Optional[str] = Field( - None, max_length=3, description="Country of origin code" - ) - - # Pricing and currency - unit_cost: Optional[Decimal] = Field(None, description="Unit cost") - currency_type: Optional[str] = Field( - None, max_length=2, description="Currency type" - ) - currency_key: Optional[str] = Field(None, max_length=3, description="Currency key") - - # Weight information - unit_weight: Optional[Decimal] = Field(None, description="Unit weight") - weight_type: Optional[str] = Field(None, max_length=6, description="Weight type") - - # Classification and regulatory - us_fraction: Optional[str] = Field( - None, max_length=16, description="US tariff fraction" - ) - fda_key: Optional[str] = Field(None, max_length=20, description="FDA key") - fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") - license_code: Optional[str] = Field(None, max_length=3, description="License code") - eccn: Optional[str] = Field( - None, max_length=20, description="Export Control Classification Number" - ) - export_code: Optional[str] = Field(None, max_length=2, description="Export code") - exclusion_symbol: Optional[str] = Field( - None, max_length=19, description="Exclusion symbol" - ) - - # Additional information - supplier: Optional[str] = Field(None, max_length=14, description="Supplier") - alternate_unit_measure: Optional[str] = Field( - None, max_length=14, description="Alternate unit of measure" - ) - added_value: Optional[Decimal] = Field(None, description="Added value") - - # Status and media - is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") - part_photo: Optional[str] = Field( - None, max_length=255, description="Part photo URL" - ) - - class Config: - from_attributes = True - - -class PartResponseDTO(BaseModel): - """DTO para respuesta de parte""" - - client_id: int - part_number: str - fraction: Optional[str] = None description_spanish: Optional[str] = None description_english: Optional[str] = None part_class: Optional[str] = None unit_of_measure: Optional[str] = None commercial_part_number: Optional[str] = None country_of_origin: Optional[str] = None - - # Pricing and currency unit_cost: Optional[Decimal] = None - currency_type: Optional[str] = None currency_key: Optional[str] = None - - # Weight information unit_weight: Optional[Decimal] = None weight_type: Optional[str] = None - - # Classification and regulatory + fraction: Optional[str] = None us_fraction: Optional[str] = None + supplier: Optional[str] = None fda_key: Optional[str] = None fcc_key: Optional[str] = None - license_code: Optional[str] = None eccn: Optional[str] = None - export_code: Optional[str] = None - exclusion_symbol: Optional[str] = None - - # Additional information - supplier: Optional[str] = None - alternate_unit_measure: Optional[str] = None - added_value: Optional[Decimal] = None - - # Status and dates is_active: Optional[bool] = None - creation_date: Optional[int] = None - modification_date: Optional[int] = None - modification_date_iso: Optional[datetime] = None - # Media - part_photo: Optional[str] = None +class PartResponseDTO(PartCreateDTO): + id: int + tenant_id: int + company_id: int + creation_date: Optional[int] = None + modification_date_iso: Optional[datetime] = None class Config: from_attributes = True - class PartBasicDTO(BaseModel): - """DTO para información básica de parte""" - + id: int client_id: int part_number: str description_spanish: Optional[str] = None - description_english: Optional[str] = None - part_class: Optional[str] = None - unit_cost: Optional[Decimal] = None - currency_key: Optional[str] = None - is_active: Optional[bool] = None + is_active: Optional[bool] = True class Config: from_attributes = True - class PartListDTO(BaseModel): - """DTO para lista de partes""" - - parts: list[PartBasicDTO] + parts: List[PartBasicDTO] total: int page: int size: int - class Config: - from_attributes = True - - class PartSearchDTO(BaseModel): - """DTO para búsqueda de partes""" - - client_id: Optional[int] = Field(None, description="Filter by client key") - part_number: Optional[str] = Field(None, description="Search by part number") - description: Optional[str] = Field(None, description="Search in descriptions") - fraction: Optional[str] = Field(None, description="Filter by tariff fraction") - supplier: Optional[str] = Field(None, description="Filter by supplier") - enabled_only: bool = Field(False, description="Show only enabled parts") - - class Config: - from_attributes = True + client_id: Optional[int] = None + part_number: Optional[str] = None + description: Optional[str] = None + fraction: Optional[str] = None + supplier: Optional[str] = None + enabled_only: bool = False \ No newline at end of file diff --git a/backend/api/v1/modules/a76/parts/routes.py b/backend/api/v1/modules/a76/parts/routes.py index 0f5996b6..b034f729 100644 --- a/backend/api/v1/modules/a76/parts/routes.py +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -1,393 +1,22 @@ """ -Endpoints API para gestión de partes/componentes +Endpoints API para gestión de partes (SCAII) """ +# ESTA ES LA LÍNEA QUE FALTA: +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes -from typing import List, Optional - -from core.database import get_core_db -from core.security import get_current_user -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from .dto import ( - PartBasicDTO, - PartCreateDTO, - PartListDTO, - PartResponseDTO, - PartSearchDTO, - PartUpdateDTO, -) +from .dto import PartCreateDTO, PartResponseDTO, PartUpdateDTO from .service import PartService -router = APIRouter(prefix="/parts") - - -@router.post("/", response_model=PartResponseDTO, status_code=status.HTTP_201_CREATED) -async def create_part( - part_data: PartCreateDTO, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Create a new part in the system - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - return service.create_part(part_data) - - -@router.get("/", response_model=PartListDTO) -async def list_parts( - skip: int = Query(0, ge=0, description="Number of records to skip"), - limit: int = Query( - 100, ge=1, le=1000, description="Maximum number of records to return" - ), - client_id: Optional[int] = Query(None, description="Filter by client key"), - part_number: Optional[str] = Query(None, description="Search by part number"), - description: Optional[str] = Query(None, description="Search in descriptions"), - fraction: Optional[str] = Query(None, description="Filter by tariff fraction"), - supplier: Optional[str] = Query(None, description="Filter by supplier"), - enabled_only: bool = Query(False, description="Show only enabled parts"), - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - List parts with optional filters and pagination - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - search_params = PartSearchDTO( - client_id=client_id, - part_number=part_number, - description=description, - fraction=fraction, - supplier=supplier, - enabled_only=enabled_only, - ) - return service.list_parts(skip, limit, search_params) - - -@router.get("/client/{client_id}", response_model=List[PartBasicDTO]) -async def get_parts_by_client( - client_id: int, - skip: int = Query(0, ge=0), - limit: int = Query(100, ge=1, le=1000), - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Get all parts for a specific client - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - return service.search_by_client(client_id, skip, limit) - - -@router.get("/search/fraction/{fraction}", response_model=List[PartBasicDTO]) -async def search_by_fraction( - fraction: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Search parts by tariff fraction - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - return service.search_by_fraction(fraction) - - -@router.get("/search/supplier/{supplier}", response_model=List[PartBasicDTO]) -async def search_by_supplier( - supplier: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Search parts by supplier - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - return service.search_by_supplier(supplier) - - -@router.get("/search/country/{country_code}", response_model=List[PartBasicDTO]) -async def get_parts_by_country( - country_code: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Get parts by country of origin - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - return service.get_parts_by_country(country_code) - - -@router.get("/statistics", response_model=dict) -async def get_parts_statistics( - db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user) -): - """ - Get basic parts statistics - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - return service.get_parts_statistics() - - -@router.get("/{client_id}/{part_number}", response_model=PartResponseDTO) -async def get_part( - client_id: int, - part_number: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Get part by composite key (client_id + part_number) - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - part = service.get_part(client_id, part_number) - if not part: - raise HTTPException( - status_code=404, - detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", - ) - return part - - -@router.put("/{client_id}/{part_number}", response_model=PartResponseDTO) -async def update_part( - client_id: int, - part_number: str, - part_data: PartUpdateDTO, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Update part information - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - part = service.update_part(client_id, part_number, part_data) - if not part: - raise HTTPException( - status_code=404, - detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", - ) - return part - - -@router.delete("/{client_id}/{part_number}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_part( - client_id: int, - part_number: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Delete part from the system - - Note: This will completely remove the part from the system. - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - if not service.delete_part(client_id, part_number): - raise HTTPException( - status_code=404, - detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", - ) - - -@router.patch( - "/{client_id}/{part_number}/toggle-status", response_model=PartResponseDTO -) -async def toggle_part_status( - client_id: int, - part_number: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Toggle part enabled/disabled status - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - part = service.toggle_status(client_id, part_number) - if not part: - raise HTTPException( - status_code=404, - detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", - ) - return part - - -# Endpoints específicos para información detallada -@router.get("/{client_id}/{part_number}/basic", response_model=PartBasicDTO) -async def get_part_basic_info( - client_id: int, - part_number: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Get basic information for a part - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - part = service.get_part(client_id, part_number) - if not part: - raise HTTPException( - status_code=404, - detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", - ) - - return PartBasicDTO( - client_id=part.client_id, - part_number=part.part_number, - description_spanish=part.description_spanish, - description_english=part.description_english, - part_class=part.part_class, - unit_cost=part.unit_cost, - currency_key=part.currency_key, - is_active=part.is_active, - ) - - -@router.get("/{client_id}/{part_number}/regulatory", response_model=dict) -async def get_part_regulatory_info( - client_id: int, - part_number: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -): - """ - Get regulatory information for a part (FDA, FCC, ECCN, etc.) - """ - # Validate access to the tenant and company - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") - - if not tenant_id or not company_id: - raise HTTPException( - status_code=403, detail="Access denied: Tenant or Company not found" - ) - - service = PartService(db) - part = service.get_part(client_id, part_number) - if not part: - raise HTTPException( - status_code=404, - detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found", - ) - - return { - "client_id": part.client_id, - "part_number": part.part_number, - "fraction": part.fraction, - "us_fraction": part.us_fraction, - "fda_key": part.fda_key, - "fcc_key": part.fcc_key, - "license_code": part.license_code, - "eccn": part.eccn, - "export_code": part.export_code, - "exclusion_symbol": part.exclusion_symbol, - } +# Ahora ya no dará error aquí +router = TenantCRUDRoutes( + service=PartService, + create_schema=PartCreateDTO, + update_schema=PartUpdateDTO, + response_schema=PartResponseDTO, + prefix="/parts", + tags=["a76 / parts"], + resource_name="Part", + id_name="part_id", + enable_list=True, + enable_filters=True, +).router \ No newline at end of file diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py index 48c2eab9..00f3ce94 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -1,309 +1,128 @@ -""" -Capa de servicio para lógica de negocio de partes/componentes -""" - import logging -from typing import List, Optional - +from typing import List, Optional, Any, Dict from fastapi import HTTPException -from sqlalchemy import and_, func, or_ +from sqlalchemy import and_, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session - -from .dto import PartCreateDTO, PartUpdateDTO from .models import Part +from .dto import PartCreateDTO, PartUpdateDTO, PartSearchDTO logger = logging.getLogger(__name__) - class PartService: """ - Servicio para gestión de partes/componentes + Servicio de Partes compatible con TenantCRUDRoutes """ @staticmethod - def create_part(db: Session, part_data: PartCreateDTO) -> Part: - """ - Crear una nueva parte - """ + def get_all( + db: Session, + tenant_id: int, + company_id: int, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None, # Agregamos este argumento explícito + ) -> tuple[List[Part], int]: + """Obtener todas las partes con paginación y filtros""" try: - db_part = Part(**part_data.model_dump()) + query = db.query(Part).filter( + Part.tenant_id == tenant_id, + Part.company_id == company_id + ) + if filters: + if filters.get("part_number"): + query = query.filter(Part.part_number.ilike(f"%{filters['part_number']}%")) + + if filters.get("description"): + pattern = f"%{filters['description']}%" + query = query.filter(or_( + Part.description_spanish.ilike(pattern), + Part.description_english.ilike(pattern) + )) + + if filters.get("client_id"): + query = query.filter(Part.client_id == filters["client_id"]) + + total = query.count() + items = query.offset(skip).limit(limit).all() + + return items, total + except Exception as e: + logger.error(f"Error en get_all partes: {e}") + raise HTTPException(status_code=500, detail="Error al listar partes") + + @staticmethod + def get_by_id(db: Session, part_id: int, tenant_id: int, company_id: int) -> Optional[Part]: + """Obtener una parte por su ID numérico (Reemplaza a get_part)""" + return db.query(Part).filter( + Part.id == part_id, + Part.tenant_id == tenant_id, + Part.company_id == company_id + ).first() + + @staticmethod + def create(db: Session, part_data: PartCreateDTO, tenant_id: int, company_id: int) -> Part: + """Crear parte (Reemplaza a create_part)""" + try: + data = part_data.model_dump() + data['company_id'] = company_id + data['tenant_id'] = tenant_id + + db_part = Part(**data) db.add(db_part) db.commit() db.refresh(db_part) return db_part except IntegrityError as e: db.rollback() - logger.error(f"Error creating part: {e}") - raise HTTPException( - status_code=400, - detail="Part with this client_id and part_number already exists", - ) - except Exception as e: - db.rollback() - logger.error(f"Unexpected error creating part: {e}") - raise HTTPException(status_code=500, detail="Error creating part") + msg = str(e.orig) + if "client_part_ukey" in msg: + raise HTTPException(status_code=400, detail="El número de parte ya existe para este cliente.") + raise HTTPException(status_code=400, detail=f"Error de integridad: {msg}") @staticmethod - def get_part(db: Session, client_id: int, part_number: str) -> Optional[Part]: - """ - Obtener una parte por clave de cliente y número de parte - """ - try: - return ( - db.query(Part) - .filter( - and_(Part.client_id == client_id, Part.part_number == part_number) - ) - .first() - ) - except Exception as e: - logger.error(f"Error getting part: {e}") - raise HTTPException(status_code=500, detail="Error retrieving part") - - @staticmethod - def get_parts_paginated( - db: Session, - skip: int = 0, - limit: int = 100, - search: Optional[str] = None, - client_id: Optional[int] = None, - fraction: Optional[str] = None, - country_of_origin: Optional[str] = None, - ) -> tuple[List[Part], int]: - """ - Obtener partes con paginación y filtros - """ - try: - query = db.query(Part) - - # Aplicar filtros - if search: - query = query.filter( - or_( - Part.description_spanish.ilike(f"%{search}%"), - Part.description_english.ilike(f"%{search}%"), - Part.part_number.ilike(f"%{search}%"), - ) - ) - - if client_id is not None: - query = query.filter(Part.client_id == client_id) - - if fraction: - query = query.filter(Part.fraction == fraction) - - if country_of_origin: - query = query.filter(Part.country_of_origin == country_of_origin) - - # Contar total - total = query.count() - - # Aplicar paginación - parts = query.offset(skip).limit(limit).all() - - return parts, total - except Exception as e: - logger.error(f"Error getting paginated parts: {e}") - raise HTTPException(status_code=500, detail="Error retrieving parts") - - @staticmethod - def get_parts_by_client(db: Session, client_id: int) -> List[Part]: - """ - Obtener todas las partes de un cliente específico - """ - try: - return db.query(Part).filter(Part.client_id == client_id).all() - except Exception as e: - logger.error(f"Error getting parts by client: {e}") - raise HTTPException(status_code=500, detail="Error retrieving client parts") - - @staticmethod - def search_parts_by_fraction(db: Session, fraction: str) -> List[Part]: - """ - Buscar partes por fracción arancelaria - """ - try: - return ( - db.query(Part) - .filter( - or_( - Part.fraction.ilike(f"%{fraction}%"), - Part.us_fraction.ilike(f"%{fraction}%"), - ) - ) - .all() - ) - except Exception as e: - logger.error(f"Error searching parts by fraction: {e}") - raise HTTPException( - status_code=500, detail="Error searching parts by fraction" - ) - - @staticmethod - def search_parts_by_supplier(db: Session, supplier: str) -> List[Part]: - """ - Buscar partes por proveedor - """ - try: - return db.query(Part).filter(Part.supplier.ilike(f"%{supplier}%")).all() - except Exception as e: - logger.error(f"Error searching parts by supplier: {e}") - raise HTTPException( - status_code=500, detail="Error searching parts by supplier" - ) - - @staticmethod - def search_parts_by_country(db: Session, country_code: str) -> List[Part]: - """ - Buscar partes por país de origen - """ - try: - return db.query(Part).filter(Part.country_of_origin == country_code).all() - except Exception as e: - logger.error(f"Error searching parts by country: {e}") - raise HTTPException( - status_code=500, detail="Error searching parts by country" - ) - - @staticmethod - def update_part( - db: Session, client_id: int, part_number: str, part_data: PartUpdateDTO + def update( + db: Session, + part_id: int, + tenant_id: int, + part_data: PartUpdateDTO, + company_id: int ) -> Optional[Part]: - """ - Actualizar una parte existente - """ + """Actualizar parte por ID (Reemplaza a update_part)""" + db_part = PartService.get_by_id(db, part_id, tenant_id, company_id) + if not db_part: + return None + + update_data = part_data.model_dump(exclude_unset=True) + + # Evitar que se intente actualizar el ID o las llaves de seguridad + for key in ["id", "tenant_id", "company_id"]: + update_data.pop(key, None) + + for key, value in update_data.items(): + setattr(db_part, key, value) + try: - db_part = PartService.get_part(db, client_id, part_number) - if not db_part: - return None - - # Actualizar campos - for field, value in part_data.model_dump(exclude_unset=True).items(): - setattr(db_part, field, value) - db.commit() db.refresh(db_part) return db_part except Exception as e: db.rollback() - logger.error(f"Error updating part: {e}") - raise HTTPException(status_code=500, detail="Error updating part") + logger.error(f"Error actualizando parte {part_id}: {e}") + raise HTTPException(status_code=500, detail="Error al actualizar parte") @staticmethod - def delete_part(db: Session, client_id: int, part_number: str) -> bool: - """ - Eliminar una parte - """ + def delete(db: Session, part_id: int, tenant_id: int, company_id: int) -> bool: + """Eliminar parte""" + db_part = PartService.get_by_id(db, part_id, tenant_id, company_id) + if not db_part: + return False + try: - db_part = PartService.get_part(db, client_id, part_number) - if not db_part: - return False - db.delete(db_part) db.commit() return True except Exception as e: db.rollback() - logger.error(f"Error deleting part: {e}") - raise HTTPException(status_code=500, detail="Error deleting part") - - @staticmethod - def toggle_part_status( - db: Session, client_id: int, part_number: str - ) -> Optional[Part]: - """ - Cambiar el estado habilitado/deshabilitado de una parte - """ - try: - db_part = PartService.get_part(db, client_id, part_number) - if not db_part: - return None - - # Toggle status (assuming 1 = enabled, 0 = disabled) - db_part.is_active = 1 if db_part.is_active == 0 else 0 - - db.commit() - db.refresh(db_part) - return db_part - except Exception as e: - db.rollback() - logger.error(f"Error toggling part status: {e}") - raise HTTPException(status_code=500, detail="Error toggling part status") - - @staticmethod - def get_parts_statistics(db: Session) -> dict: - """ - Obtener estadísticas de partes - """ - try: - total_parts = db.query(Part).count() - - # Partes por cliente - parts_by_client = ( - db.query(Part.client_id, func.count(Part.part_number).label("count")) - .group_by(Part.client_id) - .all() - ) - - # Partes por país de origen - parts_by_country = ( - db.query( - Part.country_of_origin, func.count(Part.part_number).label("count") - ) - .filter(Part.country_of_origin.isnot(None)) - .group_by(Part.country_of_origin) - .all() - ) - - # Partes habilitadas vs deshabilitadas - enabled_parts = db.query(Part).filter(Part.is_active == 1).count() - disabled_parts = db.query(Part).filter(Part.is_active == 0).count() - - return { - "total_parts": total_parts, - "enabled_parts": enabled_parts, - "disabled_parts": disabled_parts, - "parts_by_client": [ - {"client_id": item[0], "count": item[1]} for item in parts_by_client - ], - "parts_by_country": [ - {"country": item[0], "count": item[1]} for item in parts_by_country - ], - } - except Exception as e: - logger.error(f"Error getting parts statistics: {e}") - raise HTTPException( - status_code=500, detail="Error retrieving parts statistics" - ) - - @staticmethod - def get_part_regulatory_info( - db: Session, client_id: int, part_number: str - ) -> Optional[dict]: - """ - Obtener información regulatoria específica de una parte - """ - try: - db_part = PartService.get_part(db, client_id, part_number) - if not db_part: - return None - - return { - "client_id": db_part.client_id, - "part_number": db_part.part_number, - "fraction": db_part.fraction, - "us_fraction": db_part.us_fraction, - "fda_key": db_part.fda_key, - "fcc_key": db_part.fcc_key, - "license_code": db_part.license_code, - "eccn": db_part.eccn, - "export_code": db_part.export_code, - "exclusion_symbol": db_part.exclusion_symbol, - "country_of_origin": db_part.country_of_origin, - } - except Exception as e: - logger.error(f"Error getting part regulatory info: {e}") - raise HTTPException( - status_code=500, detail="Error retrieving part regulatory information" - ) + logger.error(f"Error eliminando parte {part_id}: {e}") + raise HTTPException(status_code=500, detail="Error al eliminar parte") \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts new file mode 100644 index 00000000..108934b2 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -0,0 +1,122 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Part { + id: number; + // Llaves foráneas y IDs + tenant_id: number; + company_id: number; + client_id: number; + + // Identificación + part_number: string; + commercial_part_number: string | null; + part_class: string | null; + + // Descripciones + description_spanish: string | null; + description_english: string | null; + + // Físico y Origen + unit_of_measure: string; + alternate_unit_measure: string | null; + country_of_origin: string; + unit_weight: number | null; + weight_type: string | null; + part_photo: string | null; + + // Clasificación Arancelaria + fraction: string | null; + us_fraction: string | null; + + // Costos y Valores + unit_cost: number | null; + currency_key: string | null; // currency_type en DB a veces es redundante, usamos key + added_value: number | null; + + // Regulatorio y Proveedores + supplier: string | null; + fda_key: string | null; + fcc_key: string | null; + eccn: string | null; + license_code: string | null; + export_code: string | null; + exclusion_symbol: string | null; + + // Estado + is_active: boolean; + created_at?: string; + updated_at?: string; +} + +export interface PartCreate { + company_id: number; + client_id: number; + part_number: string; + + // Opcionales + description_spanish?: string | null; + description_english?: string | null; + commercial_part_number?: string | null; + part_class?: string | null; + + unit_of_measure: string; + alternate_unit_measure?: string | null; + country_of_origin?: string; + unit_weight?: number | null; + weight_type?: string | null; + + fraction?: string | null; + us_fraction?: string | null; + + unit_cost?: number | null; + currency_key?: string | null; + added_value?: number | null; + + supplier?: string | null; + fda_key?: string | null; + fcc_key?: string | null; + eccn?: string | null; + license_code?: string | null; + export_code?: string | null; + exclusion_symbol?: string | null; + + part_photo?: string | null; + is_active?: boolean; +} + +export interface PartUpdate extends Partial {} + +export interface PartListResponse { + items: Part[]; + total: number; + page: number; + page_size: number; +} + +export const partsApi = { + list: (params: { company_id: number; page?: number; page_size?: number; q?: string }) => { + const { company_id, page = 1, page_size = 50, q = '' } = params; + const skip = (page - 1) * page_size; + + return api.get( + `/v1/a76/parts/?company_id=${company_id}&skip=${skip}&limit=${page_size}&description=${q}` + ); + }, + + get: (id: number, company_id: number) => { + return api.get(`/v1/a76/parts/${id}?company_id=${company_id}`); + }, + + create: (data: PartCreate, company_id: number) => { + return api.post(`/v1/a76/parts/?company_id=${company_id}`, data); + }, + + update: (id: number, data: PartUpdate, company_id: number) => { + return api.put(`/v1/a76/parts/${id}?company_id=${company_id}`, data); + }, + + delete: (id: number, company_id: number) => { + return api.delete(`/v1/a76/parts/${id}?company_id=${company_id}`); + } +}; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/parts/columns.ts b/frontend/src/lib/components/dashboard/parts/columns.ts new file mode 100644 index 00000000..e33f9a40 --- /dev/null +++ b/frontend/src/lib/components/dashboard/parts/columns.ts @@ -0,0 +1,178 @@ +import type { ColumnDef } from "@tanstack/table-core"; +import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/index.js"; +import { createRawSnippet } from "svelte"; +import DataTableActions from "./data-table-actions.svelte"; +import type { Part } from "$lib/api/dashboard/a76/parts"; + +/** + * Formatea moneda (USD/MXN) + */ +function formatCurrency(amount: number | null, currency: string | null): string { + if (amount === null || amount === undefined) return '-'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'USD', + minimumFractionDigits: 4 + }).format(amount); +} + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + // 1. STATUS (Corregido a Texto) + { + accessorKey: "is_active", + header: "Status", + cell: ({ row }) => { + const statusSnippet = createRawSnippet<[{ active: boolean }]>((getStatus) => { + const { active } = getStatus(); + return { + render: () => active + ? `Activo` + : `Inactivo` + }; + }); + return renderSnippet(statusSnippet, { active: row.original.is_active }); + } + }, + + // 2. NUMERO PARTE + { + accessorKey: "part_number", + header: "No. Parte", + cell: ({ row }) => { + const pnSnippet = createRawSnippet<[{ pn: string }]>((getPn) => { + const { pn } = getPn(); + return { + render: () => + `
${pn}
` + }; + }); + return renderSnippet(pnSnippet, { pn: row.original.part_number }); + } + }, + + // 3. DESCRIPCION (Español) + { + accessorKey: "description_spanish", + header: "Descripción", + cell: ({ row }) => { + const descSnippet = createRawSnippet<[{ desc: string }]>((getDesc) => { + const { desc } = getDesc(); + return { + render: () => + `
${desc || '-'}
` + }; + }); + return renderSnippet(descSnippet, { desc: row.original.description_spanish || '' }); + } + }, + + // 4. DESCRIPCION INGLES + { + accessorKey: "description_english", + header: "Desc. Inglés", + cell: ({ row }) => { + const descEnSnippet = createRawSnippet<[{ desc: string }]>((getDesc) => { + const { desc } = getDesc(); + return { + render: () => + `
${desc || '-'}
` + }; + }); + return renderSnippet(descEnSnippet, { desc: row.original.description_english || '' }); + } + }, + + // 5. CLASE + { + accessorKey: "part_class", + header: "Clase", + cell: ({ row }) => { + const classSnippet = createRawSnippet<[{ cls: string }]>((getCls) => { + const { cls } = getCls(); + return { + render: () => `
${cls || '-'}
` + }; + }); + return renderSnippet(classSnippet, { cls: row.original.part_class || '' }); + } + }, + + // 6. TIPO (Commercial Part Number) + { + accessorKey: "commercial_part_number", + header: "Tipo", + cell: ({ row }) => { + const typeSnippet = createRawSnippet<[{ val: string }]>((getType) => { + const { val } = getType(); + return { + render: () => `
${val || '-'}
` + }; + }); + return renderSnippet(typeSnippet, { val: row.original.commercial_part_number || '' }); + } + }, + + // 7. FRACCION + { + accessorKey: "fraction", + header: "Fracción", + cell: ({ row }) => { + const fracSnippet = createRawSnippet<[{ fr: string }]>((getFrac) => { + const { fr } = getFrac(); + return { + render: () => `${fr || '-'}` + }; + }); + return renderSnippet(fracSnippet, { fr: row.original.fraction || '' }); + } + }, + + // 8. UMT (Unidad de Medida) + { + accessorKey: "unit_of_measure", + header: "UMT", + cell: ({ row }) => { + const umtSnippet = createRawSnippet<[{ um: string }]>((getUm) => { + const { um } = getUm(); + return { + render: () => + ` + ${um} + ` + }; + }); + return renderSnippet(umtSnippet, { um: row.original.unit_of_measure }); + } + }, + + // 9. COSTO + { + accessorKey: "unit_cost", + header: "Costo", + cell: ({ row }) => { + const costSnippet = createRawSnippet<[{ amount: number | null, curr: string | null }]>((getCost) => { + const { amount, curr } = getCost(); + return { + render: () => `
${formatCurrency(amount, curr)}
` + }; + }); + return renderSnippet(costSnippet, { + amount: row.original.unit_cost, + curr: row.original.currency_key + }); + } + }, + + // ACCIONES + { + id: "actions", + header: "", + cell: ({ row }) => { + return renderComponent(DataTableActions, { item: row.original, onSuccess }); + } + } + ]; +} + +export const columns = createColumns(); \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/parts/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/parts/create-edit-dialog.svelte new file mode 100644 index 00000000..aaaaafa4 --- /dev/null +++ b/frontend/src/lib/components/dashboard/parts/create-edit-dialog.svelte @@ -0,0 +1,520 @@ + + + + + + {title} + + {isEdit ? 'Modifica los datos de la clase' : 'Completa los datos para crear una nueva clase'} + + + +
+ + {#if error} +
+ {error} +
+ {/if} + + + {#if companyStore.activeCompany} +
+
+ + + + +
+

+ {companyStore.activeCompany.name} +

+

+ ID: {companyStore.activeCompany.id} +

+
+
+
+ {/if} + + +
+ + {#if loadingClients} +
+
+ Cargando clientes... +
+ {:else if clients.length > 0} + + {:else} +
+ No hay clientes disponibles +
+ {/if} +
+ + +
+ + +
+ + +
+
+ +