From 9f3ae8db421335b133d45836454920f2d3b08579 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Wed, 11 Mar 2026 11:11:36 -0500 Subject: [PATCH] Partidas BOM y partes para SCAI --- backend/api/v1/common/tenant_crud_routes.py | 2 +- .../api/v1/modules/a24/inv/bom/__init__.py | 0 backend/api/v1/modules/a24/inv/bom/models.py | 56 + .../v1/modules/a24/inv/inv_parts/models.py | 52 + .../a24/inv/part_countries/__init__.py | 0 .../v1/modules/a24/inv/part_countries/dto.py | 81 + .../modules/a24/inv/part_countries/models.py | 73 + .../modules/a24/inv/part_countries/routes.py | 162 + .../modules/a24/inv/part_countries/service.py | 186 + backend/api/v1/modules/a24/router.py | 4 + .../modules/a76/audit_log/services/service.py | 9 + .../a76/audit_log/utils/serialization.py | 10 +- backend/api/v1/modules/a76/parts/dto.py | 75 +- backend/api/v1/modules/a76/parts/models.py | 21 +- backend/api/v1/modules/a76/parts/routes.py | 1 + backend/api/v1/modules/a76/parts/service.py | 240 +- backend/core/error_handlers.py | 5 + frontend/src/lib/api/dashboard/a76/parts.ts | 48 +- frontend/src/lib/api/dashboard/a76/sitar.ts | 54 + .../modales/TariffFractionSelector.svelte | 2 +- .../modales/client-selector-dialog.svelte | 2 +- .../modales/country-selector-dialog.svelte | 2 +- .../modales/currency-selector-dialog.svelte | 2 +- .../material-type-selector-dialog.svelte | 234 +- .../goods/modales/unit-measure-dialog.svelte | 2 +- .../us-fraction-selector-dialog.svelte | 2 +- .../parts/Fraction9801SelectorDialog.svelte | 15 + .../dashboard/goods/parts/PartSelector.svelte | 155 + .../parts/SubstitutePartSelectorDialog.svelte | 14 + .../TextileProviderSelectorDialog.svelte | 134 + .../goods/parts/class-selector-dialog.svelte | 2 +- .../dashboard/goods/parts/partForm.svelte | 4333 +++++++++++++---- .../routes/dashboard/goods/parts/+page.svelte | 131 +- .../goods/parts/edit/[[id]]/+page.svelte | 32 +- 34 files changed, 5101 insertions(+), 1040 deletions(-) create mode 100644 backend/api/v1/modules/a24/inv/bom/__init__.py create mode 100644 backend/api/v1/modules/a24/inv/bom/models.py create mode 100644 backend/api/v1/modules/a24/inv/part_countries/__init__.py create mode 100644 backend/api/v1/modules/a24/inv/part_countries/dto.py create mode 100644 backend/api/v1/modules/a24/inv/part_countries/models.py create mode 100644 backend/api/v1/modules/a24/inv/part_countries/routes.py create mode 100644 backend/api/v1/modules/a24/inv/part_countries/service.py create mode 100644 frontend/src/lib/api/dashboard/a76/sitar.ts create mode 100644 frontend/src/lib/components/dashboard/goods/parts/Fraction9801SelectorDialog.svelte create mode 100644 frontend/src/lib/components/dashboard/goods/parts/PartSelector.svelte create mode 100644 frontend/src/lib/components/dashboard/goods/parts/SubstitutePartSelectorDialog.svelte create mode 100644 frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 98f4b184..1559a06f 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -89,7 +89,7 @@ class TenantCRUDRoutes( enable_list: bool = False, # Enable GET list endpoint with pagination enable_filters: bool = False, # Enable custom filters in list endpoint default_page_size: int = 50, - max_page_size: int = 100, + max_page_size: int = 2000, # Permissions for each operation list_permissions: Optional[list[str]] = None, get_permissions: Optional[list[str]] = None, diff --git a/backend/api/v1/modules/a24/inv/bom/__init__.py b/backend/api/v1/modules/a24/inv/bom/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a24/inv/bom/models.py b/backend/api/v1/modules/a24/inv/bom/models.py new file mode 100644 index 00000000..932a4564 --- /dev/null +++ b/backend/api/v1/modules/a24/inv/bom/models.py @@ -0,0 +1,56 @@ +""" +Modelo ORM para la lista de materiales (BOM) de una parte - Anexo 24 +""" + +from typing import TYPE_CHECKING, Optional +from decimal import Decimal +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + Integer, + Numeric, + String, + PrimaryKeyConstraint, + ForeignKeyConstraint +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.parts.models import Part + + +class BillOfMaterial(Base, TenantScopedMixin, TimestampMixin): + """ + Tabla inv_bom: Lista de materiales para una parte. + """ + __tablename__ = "inv_bom" + __table_args__ = ( + PrimaryKeyConstraint("id", name="inv_bom_pkey"), + ForeignKeyConstraint( + ["parent_part_id"], ["a76.parts.id"], name="fk_inv_bom_parent" + ), + ForeignKeyConstraint( + ["component_part_id"], ["a76.parts.id"], name="fk_inv_bom_component" + ), + {"schema": "a24", "extend_existing": True}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + parent_part_id: Mapped[int] = mapped_column(Integer, nullable=False) + component_part_id: Mapped[int] = mapped_column(Integer, nullable=False) + quantity: Mapped[Decimal] = mapped_column(Numeric(19, 8), default=Decimal('1.0')) + + # Nuevos campos Legacy + uom_code: Mapped[str] = mapped_column(String(5), nullable=False) + procedure_type: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # TEMPORAL, DEFINITIVA, CTM + is_percentage: Mapped[bool] = mapped_column(default=True) + raw_material: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True) + waste: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True) + merma: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True) + + # Relaciones + parent_part: Mapped["Part"] = relationship("Part", foreign_keys=[parent_part_id], back_populates="bom_items") + component_part: Mapped["Part"] = relationship("Part", foreign_keys=[component_part_id]) + + def __repr__(self) -> str: + return f"" diff --git a/backend/api/v1/modules/a24/inv/inv_parts/models.py b/backend/api/v1/modules/a24/inv/inv_parts/models.py index 5e8c76e2..f5df0fa6 100644 --- a/backend/api/v1/modules/a24/inv/inv_parts/models.py +++ b/backend/api/v1/modules/a24/inv/inv_parts/models.py @@ -14,6 +14,7 @@ from sqlalchemy import ( Boolean, ForeignKeyConstraint ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship if TYPE_CHECKING: @@ -100,9 +101,60 @@ class InvPart(Base, TenantScopedMixin, TimestampMixin): dtb: Mapped[Optional[str]] = mapped_column(String(19)) # DTB dtg: Mapped[Optional[str]] = mapped_column(String(19)) # DTG + # --- CAMPOS ADICIONALES FRONTEND --- + substitute_part: Mapped[Optional[str]] = mapped_column(String(70)) + complementary_part: Mapped[Optional[str]] = mapped_column(String(70)) + preference_part: Mapped[Optional[str]] = mapped_column(String(70)) + use_alternate_quantity: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) + un_number: Mapped[Optional[str]] = mapped_column(String(30)) + shipping_name: Mapped[Optional[str]] = mapped_column(String(200)) + hazard_notes: Mapped[Optional[str]] = mapped_column(String(500)) + repair_unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) + repair_added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) + fraction_9801: Mapped[Optional[str]] = mapped_column(String(10)) + immex_type: Mapped[Optional[str]] = mapped_column(String(10)) + disable_movements: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) + pga_program_code: Mapped[Optional[str]] = mapped_column(String(10)) + usmca_fraction: Mapped[Optional[str]] = mapped_column(String(10)) + scrap_part_number: Mapped[Optional[str]] = mapped_column(String(70)) + waste_part_number: Mapped[Optional[str]] = mapped_column(String(70)) + scrap_description_en: Mapped[Optional[str]] = mapped_column(String(500)) + scrap_description_es: Mapped[Optional[str]] = mapped_column(String(500)) + scrap_export_fraction: Mapped[Optional[str]] = mapped_column(String(10)) + scrap_us_fraction: Mapped[Optional[str]] = mapped_column(String(10)) + equivalent_uom_2: Mapped[Optional[str]] = mapped_column(String(5)) + conversion_factor_2: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) + has_auxiliary: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) + auxiliary_uom: Mapped[Optional[str]] = mapped_column(String(5)) + auxiliary_conversion: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) + auxiliary_unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) + mex_packing: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) + sales_order: Mapped[Optional[str]] = mapped_column(String(50)) + use_rule_8: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) + sector: Mapped[Optional[str]] = mapped_column(String(150)) + origin_country: Mapped[Optional[str]] = mapped_column(String(3), default='MEX') + fraction_type: Mapped[Optional[str]] = mapped_column(String(10)) + + # JSONB para almacenar clientes con restricción de no descarga + non_discharge_clients: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[]) + # --- RELACIÓN --- # Usamos string "Part" para evitar problemas de carga master_info: Mapped["Part"] = relationship("Part", back_populates="inv_data") + @property + def bom_items(self): + """Exponer los BOM items de la parte maestra para Pydantic""" + if self.master_info: + return self.master_info.bom_items + return [] + + @property + def countries(self): + """Exponer los países de la parte maestra para Pydantic""" + if self.master_info: + return self.master_info.inv_countries + return [] + def __repr__(self) -> str: return f"" \ No newline at end of file diff --git a/backend/api/v1/modules/a24/inv/part_countries/__init__.py b/backend/api/v1/modules/a24/inv/part_countries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a24/inv/part_countries/dto.py b/backend/api/v1/modules/a24/inv/part_countries/dto.py new file mode 100644 index 00000000..a79938ef --- /dev/null +++ b/backend/api/v1/modules/a24/inv/part_countries/dto.py @@ -0,0 +1,81 @@ +""" +DTOs (Data Transfer Objects) para relación entre partes y países - Anexo 24 +""" + +from typing import Optional +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, Field + + +class PartCountryCreateDTO(BaseModel): + """DTO para crear una relación entre parte y país""" + + part_id: int = Field(..., description="Part ID") + country_code: str = Field(..., max_length=3, description="Country code (ISO 3166-1 alpha-3)") + fraction: Optional[str] = Field(None, max_length=20, description="Tariff fraction") + preference: str = Field(default="GENERAL", description="Trade preference: GENERAL, PROSEC, ALADI, TLCS") + has_certificate: bool = Field(default=False, description="Has certificate of origin") + certificate_number: Optional[str] = Field(None, max_length=50, description="Certificate of origin number") + end_date: Optional[datetime] = Field(None, description="Certificate expiration date") + previous_fractions_7m: bool = Field(default=False, description="Has fractions older than 7 months") + omission_import: bool = Field(default=False, description="Import omission flag") + omission_export: bool = Field(default=False, description="Export omission flag") + import_percentage: Optional[Decimal] = Field(None, description="Import tariff percentage") + export_percentage: Optional[Decimal] = Field(None, description="Export tariff percentage") + sector: Optional[str] = Field(None, max_length=10, description="Sector reference") + + class Config: + from_attributes = True + + +class PartCountryUpdateDTO(BaseModel): + """DTO para actualizar una relación entre parte y país""" + + country_code: Optional[str] = Field(None, max_length=3, description="Country code") + fraction: Optional[str] = Field(None, max_length=20, description="Tariff fraction") + preference: Optional[str] = Field(None, description="Trade preference") + has_certificate: Optional[bool] = Field(None, description="Has certificate of origin") + certificate_number: Optional[str] = Field(None, max_length=50, description="Certificate number") + end_date: Optional[datetime] = Field(None, description="Certificate expiration date") + previous_fractions_7m: Optional[bool] = Field(None, description="Previous fractions flag") + omission_import: Optional[bool] = Field(None, description="Import omission flag") + omission_export: Optional[bool] = Field(None, description="Export omission flag") + import_percentage: Optional[Decimal] = Field(None, description="Import percentage") + export_percentage: Optional[Decimal] = Field(None, description="Export percentage") + sector: Optional[str] = Field(None, max_length=10, description="Sector reference") + + class Config: + from_attributes = True + + +class PartCountryResponseDTO(BaseModel): + """DTO para responder con datos de relación entre parte y país""" + + id: int + part_id: int + country_code: str + fraction: Optional[str] = None + preference: str + has_certificate: bool + certificate_number: Optional[str] = None + end_date: Optional[datetime] = None + previous_fractions_7m: bool + omission_import: bool + omission_export: bool + import_percentage: Optional[Decimal] = None + export_percentage: Optional[Decimal] = None + sector: Optional[str] = None + + class Config: + from_attributes = True + + +class PartCountriesBulkResponseDTO(BaseModel): + """DTO para responder con lista de relaciones entre partes y países""" + + data: list[PartCountryResponseDTO] + total: int + skip: int + limit: int diff --git a/backend/api/v1/modules/a24/inv/part_countries/models.py b/backend/api/v1/modules/a24/inv/part_countries/models.py new file mode 100644 index 00000000..4e38d566 --- /dev/null +++ b/backend/api/v1/modules/a24/inv/part_countries/models.py @@ -0,0 +1,73 @@ +""" +Modelo ORM para la relación entre partes y países - Anexo 24 +""" + +from datetime import datetime +from typing import TYPE_CHECKING, Optional +from decimal import Decimal +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + Integer, + PrimaryKeyConstraint, + String, + Boolean, + ForeignKeyConstraint, + DateTime, + Numeric, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + from api.v1.modules.a76.parts.models import Part + + +class PartCountry(Base, TenantScopedMixin, TimestampMixin): + """ + Tabla inv_parte_paises: Relación entre partes y países. + Almacena información de países de origen, preferencias, certificados y omisiones. + """ + __tablename__ = "inv_parte_paises" + __table_args__ = ( + PrimaryKeyConstraint("id", name="inv_parte_paises_pkey"), + ForeignKeyConstraint( + ["part_id"], ["a76.parts.id"], name="fk_inv_parte_paises_part" + ), + {"schema": "a24", "extend_existing": True}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + part_id: Mapped[int] = mapped_column(Integer, nullable=False) + country_code: Mapped[str] = mapped_column(String(3), nullable=False) + + # Información de fracciones + fraction: Mapped[Optional[str]] = mapped_column(String(20)) + is_origin: Mapped[bool] = mapped_column(Boolean, default=False) + + # Preferencia comercial + preference: Mapped[str] = mapped_column(String(15), default="GENERAL") # GENERAL, PROSEC, ALADI, TLCS + + # Certificado de origen + has_certificate: Mapped[bool] = mapped_column(Boolean, default=False) + certificate_number: Mapped[Optional[str]] = mapped_column(String(50)) + end_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + + # Fracciones anteriores a 7 meses + previous_fractions_7m: Mapped[bool] = mapped_column(Boolean, default=False) + + # Omisiones (importación/exportación) + omission_import: Mapped[bool] = mapped_column(Boolean, default=False) + omission_export: Mapped[bool] = mapped_column(Boolean, default=False) + + # Porcentajes + import_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) + export_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) + + # Sector (para referencia) + sector: Mapped[Optional[str]] = mapped_column(String(10)) + + # Relación + part: Mapped["Part"] = relationship("Part", back_populates="inv_countries") + + def __repr__(self) -> str: + return f"" diff --git a/backend/api/v1/modules/a24/inv/part_countries/routes.py b/backend/api/v1/modules/a24/inv/part_countries/routes.py new file mode 100644 index 00000000..6256aa92 --- /dev/null +++ b/backend/api/v1/modules/a24/inv/part_countries/routes.py @@ -0,0 +1,162 @@ +""" +Rutas para gestión de relaciones entre partes y países - Anexo 24 +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from core.database import get_core_db +from .dto import ( + PartCountryCreateDTO, + PartCountryResponseDTO, + PartCountryUpdateDTO, + PartCountriesBulkResponseDTO, +) +from .models import PartCountry +from .service import PartCountryService + +router = APIRouter(prefix="/part-countries", tags=["part-countries"]) + + +@router.get( + "", + response_model=dict, + summary="Get all part-country relationships", +) +async def get_all_part_countries( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + part_id: int = Query(None), + country_code: str = Query(None), + preference: str = Query(None), + db: Session = Depends(get_core_db), +): + """Get all part-country relationships with optional filtering and pagination""" + filters = {} + if part_id: + filters["part_id"] = part_id + if country_code: + filters["country_code"] = country_code + if preference: + filters["preference"] = preference + + part_countries, total = PartCountryService.get_all(db, skip, limit, filters) + + return { + "data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries], + "total": total, + "skip": skip, + "limit": limit, + } + + +@router.get( + "/{part_country_id}", + response_model=PartCountryResponseDTO, + summary="Get part-country relationship by ID", +) +async def get_part_country( + part_country_id: int, + db: Session = Depends(get_core_db), +): + """Get a part-country relationship by its ID""" + part_country = PartCountryService.get_by_id(db, part_country_id) + if not part_country: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Part-country relationship not found", + ) + return PartCountryResponseDTO.model_validate(part_country) + + +@router.get( + "/part/{part_id}", + response_model=dict, + summary="Get all countries for a part", +) +async def get_part_countries( + part_id: int, + db: Session = Depends(get_core_db), +): + """Get all countries associated with a specific part""" + part_countries = PartCountryService.get_by_part_id(db, part_id) + + return { + "data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries], + "total": len(part_countries), + } + + +@router.post( + "", + response_model=PartCountryResponseDTO, + status_code=status.HTTP_201_CREATED, + summary="Create part-country relationship", +) +async def create_part_country( + part_country_data: PartCountryCreateDTO, + db: Session = Depends(get_core_db), +): + """Create a new part-country relationship""" + part_country = PartCountryService.create(db, part_country_data) + return PartCountryResponseDTO.model_validate(part_country) + + +@router.post( + "/part/{part_id}/bulk", + response_model=dict, + status_code=status.HTTP_201_CREATED, + summary="Bulk create/update part-country relationships", +) +async def bulk_create_part_countries( + part_id: int, + countries_data: List[PartCountryCreateDTO], + db: Session = Depends(get_core_db), +): + """Create or replace all part-country relationships for a part""" + part_countries = PartCountryService.bulk_create(db, part_id, countries_data) + + return { + "data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries], + "total": len(part_countries), + } + + +@router.put( + "/{part_country_id}", + response_model=PartCountryResponseDTO, + summary="Update part-country relationship", +) +async def update_part_country( + part_country_id: int, + part_country_data: PartCountryUpdateDTO, + db: Session = Depends(get_core_db), +): + """Update a part-country relationship""" + part_country = PartCountryService.update(db, part_country_id, part_country_data) + if not part_country: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Part-country relationship not found", + ) + return PartCountryResponseDTO.model_validate(part_country) + + +@router.delete( + "/{part_country_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete part-country relationship", +) +async def delete_part_country( + part_country_id: int, + db: Session = Depends(get_core_db), +): + """Delete a part-country relationship""" + success = PartCountryService.delete(db, part_country_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Part-country relationship not found", + ) diff --git a/backend/api/v1/modules/a24/inv/part_countries/service.py b/backend/api/v1/modules/a24/inv/part_countries/service.py new file mode 100644 index 00000000..fb8bb1d7 --- /dev/null +++ b/backend/api/v1/modules/a24/inv/part_countries/service.py @@ -0,0 +1,186 @@ +""" +Capa de servicio para lógica de negocio de relación entre partes y países +""" + +import logging +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .dto import PartCountryCreateDTO, PartCountryResponseDTO, PartCountryUpdateDTO +from .models import PartCountry + +logger = logging.getLogger(__name__) + + +class PartCountryService: + """Servicio para gestión de relaciones entre partes y países""" + + @staticmethod + def get_all( + db: Session, + skip: int = 0, + limit: int = 50, + filters: Optional[Dict[str, Any]] = None, + ) -> Tuple[List[PartCountry], int]: + """Get all part-country relationships with pagination""" + query = db.query(PartCountry) + + if filters: + if filters.get("part_id"): + query = query.filter(PartCountry.part_id == filters["part_id"]) + if filters.get("country_code"): + query = query.filter( + PartCountry.country_code.ilike(f"%{filters['country_code']}%") + ) + if filters.get("preference"): + query = query.filter(PartCountry.preference == filters["preference"]) + + total = query.count() + part_countries = query.offset(skip).limit(limit).all() + + return part_countries, total + + @staticmethod + def get_by_id(db: Session, part_country_id: int) -> Optional[PartCountry]: + """Get part-country relationship by ID""" + return db.query(PartCountry).filter(PartCountry.id == part_country_id).first() + + @staticmethod + def get_by_part_id(db: Session, part_id: int) -> List[PartCountry]: + """Get all countries for a specific part""" + return db.query(PartCountry).filter(PartCountry.part_id == part_id).all() + + @staticmethod + def get_by_part_and_country(db: Session, part_id: int, country_code: str) -> Optional[PartCountry]: + """Get specific part-country relationship""" + return db.query(PartCountry).filter( + PartCountry.part_id == part_id, + PartCountry.country_code == country_code + ).first() + + @staticmethod + def create(db: Session, part_country_data: PartCountryCreateDTO) -> PartCountry: + """Create a new part-country relationship""" + try: + db_part_country = PartCountry( + **part_country_data.model_dump(exclude_unset=True) + ) + + db.add(db_part_country) + db.commit() + db.refresh(db_part_country) + + return db_part_country + + except IntegrityError as e: + db.rollback() + logger.error(f"IntegrityError creating part-country relationship: {str(e)}") + raise HTTPException( + status_code=400, + detail="Relationship already exists or invalid foreign key", + ) + except Exception as e: + db.rollback() + logger.error(f"Error creating part-country relationship: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error creating relationship: {str(e)}", + ) + + @staticmethod + def update( + db: Session, part_country_id: int, part_country_data: PartCountryUpdateDTO + ) -> Optional[PartCountry]: + """Update part-country relationship""" + try: + db_part_country = db.query(PartCountry).filter( + PartCountry.id == part_country_id + ).first() + + if not db_part_country: + return None + + update_data = part_country_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_part_country, field, value) + + db.add(db_part_country) + db.commit() + db.refresh(db_part_country) + + return db_part_country + + except IntegrityError as e: + db.rollback() + logger.error(f"IntegrityError updating part-country relationship: {str(e)}") + raise HTTPException( + status_code=400, + detail="Cannot update: constraint violation", + ) + except Exception as e: + db.rollback() + logger.error(f"Error updating part-country relationship: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error updating relationship: {str(e)}", + ) + + @staticmethod + def delete(db: Session, part_country_id: int) -> bool: + """Delete part-country relationship""" + try: + db_part_country = db.query(PartCountry).filter( + PartCountry.id == part_country_id + ).first() + + if not db_part_country: + return False + + db.delete(db_part_country) + db.commit() + + return True + + except Exception as e: + db.rollback() + logger.error(f"Error deleting part-country relationship: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error deleting relationship: {str(e)}", + ) + + @staticmethod + def bulk_create(db: Session, part_id: int, countries_data: List[PartCountryCreateDTO]) -> List[PartCountry]: + """Create multiple part-country relationships for a part""" + try: + # Delete existing relationships for this part + db.query(PartCountry).filter(PartCountry.part_id == part_id).delete() + db.commit() + + # Create new relationships + db_part_countries = [] + for country_data in countries_data: + country_data.part_id = part_id + db_part_country = PartCountry( + **country_data.model_dump(exclude_unset=True) + ) + db_part_countries.append(db_part_country) + + db.add_all(db_part_countries) + db.commit() + + for pc in db_part_countries: + db.refresh(pc) + + return db_part_countries + + except Exception as e: + db.rollback() + logger.error(f"Error bulk creating part-country relationships: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error creating relationships: {str(e)}", + ) diff --git a/backend/api/v1/modules/a24/router.py b/backend/api/v1/modules/a24/router.py index 06a92dcc..f011e110 100644 --- a/backend/api/v1/modules/a24/router.py +++ b/backend/api/v1/modules/a24/router.py @@ -7,6 +7,7 @@ from fastapi import APIRouter # Importar routers de submódulos from .fa.fa_classes.routes import router as fa_classes_router from .fa.fa_item_lines.routes import router as fa_item_lines_router +from .inv.part_countries.routes import router as part_countries_router # Router principal de A24 @@ -17,3 +18,6 @@ router.include_router(fa_classes_router, prefix="/a24", tags=["a24 / fa / classe router.include_router( fa_item_lines_router, prefix="/a24", tags=["a24 / fa / item-lines"] ) + +# Registrar routers de INV (Inventory) +router.include_router(part_countries_router, prefix="/a24", tags=["a24 / inv / part-countries"]) diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py index 7625241a..10ca1f55 100644 --- a/backend/api/v1/modules/a76/audit_log/services/service.py +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -33,6 +33,15 @@ def _make_json_safe(obj: Any) -> Any: return str(obj) if isinstance(obj, bytes): return obj.decode("utf-8", errors="replace") + + if hasattr(obj, "__dict__"): + d = dict(obj.__dict__) + d.pop("_sa_instance_state", None) + return {k: _make_json_safe(v) for k, v in d.items()} + + if not isinstance(obj, (int, float, str, bool)): + return str(obj) + return obj diff --git a/backend/api/v1/modules/a76/audit_log/utils/serialization.py b/backend/api/v1/modules/a76/audit_log/utils/serialization.py index 604de7b0..dedfdc19 100644 --- a/backend/api/v1/modules/a76/audit_log/utils/serialization.py +++ b/backend/api/v1/modules/a76/audit_log/utils/serialization.py @@ -29,8 +29,14 @@ def serialize_value(value: Any) -> Any: elif isinstance(value, dict): return {key: serialize_value(val) for key, val in value.items()} else: - # For any other type, try to return as-is (str, int, float, bool, None) - # If it fails JSON serialization later, at least we tried + if hasattr(value, "__dict__"): + d = dict(value.__dict__) + d.pop("_sa_instance_state", None) + return {k: serialize_value(v) for k, v in d.items()} + + if not isinstance(value, (int, float, str, bool)): + return str(value) + return value diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index 0273bd68..e84aa70e 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -11,6 +11,38 @@ class FaDataDTO(BaseModel): model_config = ConfigDict(from_attributes=True) +# --- SUB-DTO: RELACIONES CON PAÍSES --- +class PartCountryDataDTO(BaseModel): + country_code: Optional[str] = None + fraction: Optional[str] = None + preference: Optional[str] = "GENERAL" + has_certificate: Optional[bool] = False + certificate_number: Optional[str] = None + end_date: Optional[datetime] = None + previous_fractions_7m: Optional[bool] = False + omission_import: Optional[bool] = False + omission_export: Optional[bool] = False + import_percentage: Optional[Decimal] = None + export_percentage: Optional[Decimal] = None + sector: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + +# --- SUB-DTO: COMPONENTES BOM --- +class InvBomItemDTO(BaseModel): + id: Optional[int] = None + component_part_id: Optional[int] = Field(None, gt=0, description="ID de la parte componente (debe ser > 0)") + component_part_number: Optional[str] = None + quantity: Optional[Decimal] = Field(Decimal('1.0'), gt=0, description="Cantidad debe ser > 0") + uom_code: Optional[str] = Field(None, max_length=5, description="Código de unidad de medida") + procedure_type: Optional[str] = None + is_percentage: Optional[bool] = True + raw_material: Optional[Decimal] = None + waste: Optional[Decimal] = None + merma: Optional[Decimal] = None + + model_config = ConfigDict(from_attributes=True) + # --- SUB-DTO: DATOS DE INVENTARIO Y COSTEO (InvData) --- class InvDataDTO(BaseModel): part_type: Optional[str] = None @@ -57,11 +89,52 @@ class InvDataDTO(BaseModel): dtb: Optional[str] = None dtg: Optional[str] = None + # --- CAMPOS ADICIONALES FRONTEND --- + substitute_part: Optional[str] = None + complementary_part: Optional[str] = None + preference_part: Optional[str] = None + use_alternate_quantity: Optional[bool] = False + un_number: Optional[str] = None + shipping_name: Optional[str] = None + hazard_notes: Optional[str] = None + repair_unit_cost: Optional[Decimal] = None + repair_added_value: Optional[Decimal] = None + fraction_9801: Optional[str] = None + immex_type: Optional[str] = None + disable_movements: Optional[bool] = False + pga_program_code: Optional[str] = None + usmca_fraction: Optional[str] = None + scrap_part_number: Optional[str] = None + waste_part_number: Optional[str] = None + scrap_description_en: Optional[str] = None + scrap_description_es: Optional[str] = None + scrap_export_fraction: Optional[str] = None + scrap_us_fraction: Optional[str] = None + equivalent_uom_2: Optional[str] = None + conversion_factor_2: Optional[Decimal] = None + has_auxiliary: Optional[bool] = False + auxiliary_uom: Optional[str] = None + auxiliary_conversion: Optional[Decimal] = None + auxiliary_unit_cost: Optional[Decimal] = None + mex_packing: Optional[Decimal] = None + sales_order: Optional[str] = None + use_rule_8: Optional[bool] = False + sector: Optional[str] = None + origin_country: Optional[str] = None + fraction_type: Optional[str] = None + non_discharge_clients: Optional[List[dict]] = None + + # Lista de materiales (BOM) + bom_items: Optional[List[InvBomItemDTO]] = None + + # Lista de países de origen + countries: Optional[List[PartCountryDataDTO]] = None + model_config = ConfigDict(from_attributes=True) class PartBase(BaseModel): - client_id: int + client_id: Optional[int] = None part_number: str = Field(..., max_length=70) commercial_part_number: Optional[str] = None diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py index df231753..3fd9bd1e 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -4,7 +4,7 @@ Modelos ORM para gestión de partes/componentes - Anexo 76 (Master Data) from datetime import datetime from decimal import Decimal -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, List from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base @@ -25,6 +25,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.a24.fa.fa_parts.models import FaPart from api.v1.modules.a24.inv.inv_parts.models import InvPart +from api.v1.modules.a24.inv.bom.models import BillOfMaterial if TYPE_CHECKING: @@ -32,6 +33,7 @@ if TYPE_CHECKING: from api.v1.modules.a76.general_catalogs.units_of_measure.models import ( UnitOfMeasure, ) + from api.v1.modules.a24.inv.part_countries.models import PartCountry class Part(Base, TenantScopedMixin, TimestampMixin): @@ -66,7 +68,7 @@ class Part(Base, TenantScopedMixin, TimestampMixin): ) id: Mapped[int] = mapped_column(Integer, primary_key=True) - client_id: Mapped[int] = mapped_column(Integer) + client_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) part_number: Mapped[str] = mapped_column(String(70)) commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70)) @@ -123,5 +125,20 @@ class Part(Base, TenantScopedMixin, TimestampMixin): cascade="all, delete-orphan", ) + # Bom Components (as parent) + bom_items: Mapped[List["BillOfMaterial"]] = relationship( + "BillOfMaterial", + foreign_keys=[BillOfMaterial.parent_part_id], + cascade="all, delete-orphan", + back_populates="parent_part" + ) + + # Países Anexo 24 + inv_countries: Mapped[List["PartCountry"]] = relationship( + "PartCountry", + cascade="all, delete-orphan", + back_populates="part" + ) + def __repr__(self) -> str: return f"" diff --git a/backend/api/v1/modules/a76/parts/routes.py b/backend/api/v1/modules/a76/parts/routes.py index c68c19b3..8bc55b07 100644 --- a/backend/api/v1/modules/a76/parts/routes.py +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -28,5 +28,6 @@ router.include_router( id_name="part_id", enable_list=True, enable_filters=True, + max_page_size=10000, ).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 5db4bb5f..28675910 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -2,9 +2,9 @@ import logging from typing import Any, Dict, List, Optional from fastapi import HTTPException -from sqlalchemy import or_ +from sqlalchemy import or_, insert as sa_insert, update as sa_update from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer, joinedload, load_only # Importamos el modelo PRINCIPAL from .models import Part @@ -22,8 +22,33 @@ logger = logging.getLogger(__name__) class PartService: """Servicio para gestión de Partes (Anexo 76 + Anexo 24)""" - @staticmethod + # Estos campos están en el modelo pero NO en la DB todavía (faltan las migraciones del usuario) + # Los diferimos en SELECT y los filtramos en INSERT/UPDATE para que el sistema no truene. + MISSING_INV_COLUMNS = [] + + # Campos que SÍ existen en la DB (Verificados con \d a24.inv_partes) + SAFE_INV_COLUMNS = [ + "id", "part_type", "material_type", "reference_number", "flex_reference_number", + "equivalent_uom", "conversion_factor", "stock_uom", "alternate_uom", "conversion_uom", + "added_value", "added_value_type", "assigned_client", "supplier_code", "is_textile", + "bom_version", "is_repair", "is_hazardous", "emergency_number", "danger_class", + "packaging_group", "width", "thickness", "specification", "total_value", "direct_labor", + "general_expenses", "total_expenses", "depreciation", "tooling", "material_consumed", + "profit", "us_fraction_alt", "ca_fraction", "ad_valorem_us", "nafta_result", + "nafta_percentage", "dta", "dtb", "dtg", "tenant_id", "company_id", + "substitute_part", "complementary_part", "preference_part", "use_alternate_quantity", + "un_number", "shipping_name", "hazard_notes", "repair_unit_cost", "repair_added_value", + "fraction_9801", "immex_type", "disable_movements", "pga_program_code", "usmca_fraction", + "scrap_part_number", "waste_part_number", "scrap_description_en", "scrap_description_es", + "scrap_export_fraction", "scrap_us_fraction", "equivalent_uom_2", "conversion_factor_2", + "has_auxiliary", "auxiliary_uom", "auxiliary_conversion", "auxiliary_unit_cost", + "mex_packing", "sales_order", "use_rule_8", "sector", "origin_country", "fraction_type", + "non_discharge_clients" + ] + + @classmethod def get_all( + cls, db: Session, tenant_id: int, company_id: int, @@ -37,6 +62,13 @@ class PartService: Part.company_id == company_id ) + # Cargar inv_data de forma segura (Solo las columnas que existen) + from api.v1.modules.a24.inv.inv_parts.models import InvPart + + # Usamos joinedload + load_only para ser 100% seguros de qué columnas se piden + load_opt = joinedload(Part.inv_data).load_only(*[getattr(InvPart, c) for c in cls.SAFE_INV_COLUMNS]) + query = query.options(load_opt) + if filters: if filters.get("q"): search = f"%{filters['q']}%" @@ -51,18 +83,39 @@ class PartService: total = query.count() items = query.offset(skip).limit(limit).all() + + # FIX PROACTIVO: Inyectar None en campos inexistentes para evitar que Pydantic dispare la carga perezosa + for item in items: + if item.inv_data: + for col in cls.MISSING_INV_COLUMNS: + item.inv_data.__dict__[col] = None + return items, total - @staticmethod - def get_by_id(db: Session, part_id: int, tenant_id: int, company_id: int) -> Optional[Part]: - return db.query(Part).filter( + @classmethod + def get_by_id(cls, db: Session, part_id: int, tenant_id: int, company_id: int) -> Optional[Part]: + query = 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: + # Cargar inv_data de forma segura + from api.v1.modules.a24.inv.inv_parts.models import InvPart + load_opt = joinedload(Part.inv_data).load_only(*[getattr(InvPart, c) for c in cls.SAFE_INV_COLUMNS]) + query = query.options(load_opt) + + item = query.first() + + # FIX PROACTIVO: Inyectar None + if item and item.inv_data: + for col in cls.MISSING_INV_COLUMNS: + item.inv_data.__dict__[col] = None + + return item + + @classmethod + def create(cls, db: Session, part_data: PartCreateDTO, tenant_id: int, company_id: int) -> Part: # 1. Preparar datos data = part_data.model_dump() @@ -70,6 +123,16 @@ class PartService: fa_dict = data.pop('fa_data', None) inv_dict = data.pop('inv_data', None) + # Extraer BOM items si existen en inv_data + bom_items_data = None + if inv_dict: + bom_items_data = inv_dict.pop('bom_items', None) + + # Extraer datos de países si existen en inv_data + countries_data = None + if inv_dict: + countries_data = inv_dict.pop('countries', None) + # Inyectar IDs de contexto (Seguridad Multi-tenant) data['company_id'] = company_id data['tenant_id'] = tenant_id @@ -97,20 +160,76 @@ class PartService: # Importante: Pasar tenant/company también al hijo db_part.fa_data = FaPart(**fa_dict, tenant_id=tenant_id, company_id=company_id) + # Filtrar campos que no existen en la DB (Safe filtering) para insert Core + safe_inv_data = None if inv_dict: + safe_inv_data = {k: v for k, v in inv_dict.items() if k not in cls.MISSING_INV_COLUMNS} + + # 5. Agregar la parte primero para obtener el ID (flush) + db.add(db_part) + db.flush() # Obtener el ID generado sin hacer commit + + # 5b. Insertar INV Data vía Core (Evita que el ORM use columnas inexistentes) + if safe_inv_data is not None: from api.v1.modules.a24.inv.inv_parts.models import InvPart - db_part.inv_data = InvPart(**inv_dict, tenant_id=tenant_id, company_id=company_id) + db.execute( + sa_insert(InvPart.__table__).values( + id=db_part.id, + tenant_id=tenant_id, + company_id=company_id, + **safe_inv_data + ) + ) + + # Ahora crear BOM items con el parent_part_id + if bom_items_data: + from api.v1.modules.a24.inv.bom.models import BillOfMaterial + for item in bom_items_data: + # El DTO puede traer component_part_number pero el modelo usa IDs + # En creación asumimos que enviamos component_part_id + item.pop('id', None) # Limpiar ID si viene + item.pop('component_part_number', None) # Limpiar part_number + item['parent_part_id'] = db_part.id # Asignar el parent_part_id + bom_obj = BillOfMaterial( + **item, + tenant_id=tenant_id, + company_id=company_id + ) + db_part.bom_items.append(bom_obj) + + # 6. Crear relaciones con países + if countries_data: + from api.v1.modules.a24.inv.part_countries.models import PartCountry + for country in countries_data: + country_obj = PartCountry( + part_id=db_part.id if db_part.id else None, # Se asignará después del flush + **country, + tenant_id=tenant_id, + company_id=company_id + ) + db_part.inv_countries.append(country_obj) try: - db.add(db_part) - db.commit() + db.commit() # db.add ya se hizo en el flush anterior db.refresh(db_part) return db_part except IntegrityError as e: db.rollback() err_msg = str(e.orig) - logger.error(f"Error DB creando parte: {err_msg}") + logger.exception("Error DB creando parte") + + # Mensajes específicos para BOM + if "inv_bom_component" in err_msg or "fk_inv_bom_component" in err_msg: + raise HTTPException( + 400, + "Error en BOM: La parte componente no existe. Verifique que haya seleccionado una parte válida." + ) + if "inv_bom_parent" in err_msg or "fk_inv_bom_parent" in err_msg: + raise HTTPException( + 400, + "Error en BOM: La parte padre no existe. Contacte al administrador." + ) if "foreign key" in err_msg: if "unit_of_measure" in err_msg: @@ -123,10 +242,10 @@ class PartService: # El error genérico si falla Company/Client raise HTTPException(400, "Error de referencia: Verifique Cliente, Compañía o Catálogos.") - raise HTTPException(400, "Error al guardar la parte.") + raise HTTPException(400, f"Error al guardar la parte: {err_msg}") - @staticmethod - def update(db: Session, part_id: int, tenant_id: int, part_data: PartUpdateDTO, company_id: int) -> Optional[Part]: + @classmethod + def update(cls, db: Session, part_id: int, tenant_id: int, part_data: PartUpdateDTO, company_id: int) -> Optional[Part]: db_part = PartService.get_by_id(db, part_id, tenant_id, company_id) if not db_part: return None @@ -134,6 +253,14 @@ class PartService: data = part_data.model_dump(exclude_unset=True) fa_dict = data.pop('fa_data', None) inv_dict = data.pop('inv_data', None) + + bom_items_data = None + if inv_dict: + bom_items_data = inv_dict.pop('bom_items', None) + + countries_data = None + if inv_dict: + countries_data = inv_dict.pop('countries', None) # Actualizar campos directos for key, value in data.items(): @@ -148,14 +275,66 @@ class PartService: from api.v1.modules.a24.fa.fa_parts.models import FaPart db_part.fa_data = FaPart(**fa_dict, tenant_id=tenant_id, company_id=company_id) - # Actualizar INV Data + # Actualizar INV Data vía Core if inv_dict is not None: - if db_part.inv_data: - for k, v in inv_dict.items(): - setattr(db_part.inv_data, k, v) + from api.v1.modules.a24.inv.inv_parts.models import InvPart + safe_inv_dict = {k: v for k, v in inv_dict.items() if k in cls.SAFE_INV_COLUMNS} + + # Verificar si ya existe el registro en inv_partes + has_inv = db.query(InvPart.id).filter(InvPart.id == part_id).first() is not None + + if has_inv: + if safe_inv_dict: + db.execute( + sa_update(InvPart.__table__) + .where(InvPart.__table__.c.id == part_id) + .values(**safe_inv_dict) + ) else: - from api.v1.modules.a24.inv.inv_parts.models import InvPart - db_part.inv_data = InvPart(**inv_dict, tenant_id=tenant_id, company_id=company_id) + db.execute( + sa_insert(InvPart.__table__) + .values( + id=part_id, + tenant_id=tenant_id, + company_id=company_id, + **safe_inv_dict + ) + ) + # Expirar para que se recargue con el load_only fix si se accede + db.expire(db_part, ["inv_data"]) + + # Actualizar BOM items + if bom_items_data is not None: + from api.v1.modules.a24.inv.bom.models import BillOfMaterial + # Estrategia: Reemplazo total por ahora para simplicidad (típico en BOMs de formularios) + # Si se requiere edición fina por ID se puede implementar luego + db_part.bom_items = [] + for item in bom_items_data: + # Limpiar ID si viene para que SQLAlchemy cree nuevos o los maneje + item.pop('id', None) + item.pop('component_part_number', None) + # Asignar el parent_part_id (la parte que se está editando) + item['parent_part_id'] = db_part.id + bom_obj = BillOfMaterial( + **item, + tenant_id=tenant_id, + company_id=company_id + ) + db_part.bom_items.append(bom_obj) + + # Actualizar relaciones con países + if countries_data is not None: + from api.v1.modules.a24.inv.part_countries.models import PartCountry + # Estrategia: Reemplazo total (típico en formularios) + db_part.inv_countries = [] + for country in countries_data: + country_obj = PartCountry( + part_id=db_part.id, + **country, + tenant_id=tenant_id, + company_id=company_id + ) + db_part.inv_countries.append(country_obj) try: db.commit() @@ -163,7 +342,22 @@ class PartService: return db_part except IntegrityError as e: db.rollback() - raise HTTPException(400, f"Error actualizando: {str(e.orig)}") + err_msg = str(e.orig) + logger.exception("Error DB actualizando parte") + + # Mensajes de error específicos para BOM + if "inv_bom_component" in err_msg or "fk_inv_bom_component" in err_msg: + raise HTTPException( + 400, + "Error en BOM: La parte componente no existe en la base de datos. Verifique que haya seleccionado una parte válida." + ) + if "inv_bom_parent" in err_msg or "fk_inv_bom_parent" in err_msg: + raise HTTPException( + 400, + "Error en BOM: La parte padre no existe. Contacte al administrador." + ) + + raise HTTPException(400, f"Error actualizando: {err_msg}") @staticmethod def delete(db: Session, part_id: int, tenant_id: int, company_id: int) -> bool: diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 3cbd397c..e4527184 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -126,6 +126,7 @@ async def validation_exception_handler( } ) + print(f"DEBUG REQUEST VALIDATION ERRORS: {errors}") logger.warning( f"Validation Error en {request.url.path}", extra={"errors": errors}, @@ -151,6 +152,8 @@ async def validation_exception_handler( return response +import traceback + async def inner_validation_exception_handler( request: Request, exc: ValidationError, @@ -158,6 +161,7 @@ async def inner_validation_exception_handler( """ Manejador para errores de validación de Pydantic lanzados internamente (como en tenant_crud_routes). """ + traceback.print_exc() errors = [] for error in exc.errors(): loc_parts = [str(loc) for loc in error["loc"] if loc != "body"] @@ -172,6 +176,7 @@ async def inner_validation_exception_handler( } ) + print(f"DEBUG VALIDATION ERRORS: {errors}") logger.warning( f"Inner Validation Error en {request.url.path}", extra={"errors": errors}, diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index b4e30d2e..88ab372f 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -47,6 +47,38 @@ export interface InvData { dta?: string | null; dtb?: string | null; dtg?: string | null; + + usmca_fraction?: string | null; + scrap_part_number?: string | null; + waste_part_number?: string | null; + scrap_description_en?: string | null; + scrap_description_es?: string | null; + scrap_export_fraction?: string | null; + scrap_us_fraction?: string | null; + equivalent_uom_2?: string | null; + conversion_factor_2?: number | null; + has_auxiliary?: boolean | null; + auxiliary_uom?: string | null; + auxiliary_conversion?: number | null; + auxiliary_unit_cost?: number | null; + mex_packing?: string | null; + sales_order?: string | null; + use_rule_8?: boolean | null; + + fraction_9801?: string | null; + fraction_type?: string | null; + use_alternate_quantity?: boolean | null; + repair_unit_cost?: number | null; + repair_added_value?: number | null; + sector?: string | null; + origin_country?: string | null; + supplier?: string | null; + hazard_notes?: string | null; + shipping_name?: string | null; + un_number?: string | null; + complementary_part?: string | null; + preference_part?: string | null; + substitute_part?: string | null; } @@ -119,14 +151,18 @@ export const partsApi = { q?: string }) => { const { company_id, page = 1, page_size = 50, q = '' } = params; - const skip = (page - 1) * page_size; - const query = new URLSearchParams({ + const queryParams: Record = { company_id: company_id.toString(), - skip: skip.toString(), - limit: page_size.toString(), - description: q - }); + page: page.toString(), + page_size: page_size.toString(), + }; + + if (q) { + queryParams.description = q; + } + + const query = new URLSearchParams(queryParams); return api.get(`/v1/a76/parts/?${query.toString()}`); }, diff --git a/frontend/src/lib/api/dashboard/a76/sitar.ts b/frontend/src/lib/api/dashboard/a76/sitar.ts new file mode 100644 index 00000000..829b1b42 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/sitar.ts @@ -0,0 +1,54 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface SitarTLCS { + FRACCION: string; + PAIS: string; + TASATXT: string; + TASANUM?: string | null; + TLC: string; + NOTA: string | null; + DOF: string | null; + OBSERVACION?: string | null; + NICO?: string; + SYSID: number; +} + +export interface SitarPROSEC { + FRACCION: string; + PRODUCTO: string; + TASA: string; + SECTOR: string; + ANEXO: string; + DOF: string; + NOTAS: string | null; + NICO?: string; + SYSID: number; +} + +export interface SitarALADI { + FRACCION: string; + ACUERDO: string; + PAIS: string; + TASATXT: string; + TASANUM?: string | null; + DOF: string; + NOTAS: string | null; + NICO?: string; + SYSID: number; +} + +export async function getSitarTLCS(filters: { fraccion: string; nico?: string }): Promise> { + const queryParams = new URLSearchParams(filters); + return await api.get(`/v1/sitar/tlcs/?${queryParams.toString()}`); +} + +export async function getSitarPROSEC(filters: { fraccion: string; nico?: string }): Promise> { + const queryParams = new URLSearchParams(filters); + return await api.get(`/v1/sitar/prosec/?${queryParams.toString()}`); +} + +export async function getSitarALADI(filters: { fraccion: string; nico?: string }): Promise> { + const queryParams = new URLSearchParams(filters); + return await api.get(`/v1/sitar/aladi2/?${queryParams.toString()}`); +} diff --git a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte index 30a4b97f..52bd7d34 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte @@ -71,7 +71,7 @@ - + CATALOGO DE FRACCIONES SITAR - SCAII diff --git a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte index 8ef366a7..8015e841 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte @@ -72,7 +72,7 @@ - + Seleccionar Cliente diff --git a/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte index 951341da..60b11237 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte @@ -132,7 +132,7 @@ - + Seleccionar País diff --git a/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte index 4008778d..28263e4a 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte @@ -72,7 +72,7 @@ - + Seleccionar Moneda diff --git a/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte index 33ca5f02..3aedd5ff 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/material-type-selector-dialog.svelte @@ -1,134 +1,126 @@ - - - - Seleccionar Tipo de Material - Catálogo general. - + + + + Seleccionar Tipo de Material + Catálogo general. + -
- - -
+
+ + +
-
- {#if loading} -
- -

Cargando catálogo...

-
- {:else if filteredItems.length === 0} -
-

No se encontraron resultados.

-
- {:else} - - - - - - - - - - - {#each filteredItems as item} - {@const Icon = getCategoryIcon(item.type)} - - - - - - - - - - {/each} - -
ClaveDescripciónTipoAcción
{item.key}{item.description} -
- - {item.type} -
-
- -
- {/if} -
- - - -
-
\ No newline at end of file +
+ {#if loading} +
+ +

Cargando catálogo...

+
+ {:else if filteredItems.length === 0} +
+

No se encontraron resultados.

+
+ {:else} + + + + + + + + + + {#each filteredItems as item} + {@const Icon = getCategoryIcon(item.type)} + handleSelect(item)} + > + + + + + + + {/each} + +
ClaveDescripciónTipo
{item.key}{item.description} +
+ + {item.type} +
+
+ {/if} +
+ + + +
+
diff --git a/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte index 43f7e76b..ff8b057e 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/unit-measure-dialog.svelte @@ -73,7 +73,7 @@ - + Seleccionar Unidad de Medida diff --git a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte index 1f514190..4ba7a2a6 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte @@ -76,7 +76,7 @@ - + Seleccionar Fracción Americana diff --git a/frontend/src/lib/components/dashboard/goods/parts/Fraction9801SelectorDialog.svelte b/frontend/src/lib/components/dashboard/goods/parts/Fraction9801SelectorDialog.svelte new file mode 100644 index 00000000..a4af6d17 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/parts/Fraction9801SelectorDialog.svelte @@ -0,0 +1,15 @@ + + + + diff --git a/frontend/src/lib/components/dashboard/goods/parts/PartSelector.svelte b/frontend/src/lib/components/dashboard/goods/parts/PartSelector.svelte new file mode 100644 index 00000000..1961f2ad --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/parts/PartSelector.svelte @@ -0,0 +1,155 @@ + + + + + + SELECCIONAR PARTE (SUSTITUTO) + +
+
+ +
+ + { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + parts = []; + currentPage = 1; + totalParts = 0; + hasMoreParts = true; + loadParts(searchPart, 1); + }, 500); + }} + /> +
+
+
{ + const target = e.currentTarget; + if ( + target.scrollHeight - target.scrollTop <= target.clientHeight + 50 && + hasMoreParts && + !isLoadingParts + ) { + loadParts(searchPart, currentPage + 1); + } + }} + > + + + + + + + + + + {#each parts as part (part.id)} + { + onSelect(part); + open = false; + }} + > + + + + + {:else} + + + + {/each} + +
No. ParteDescripción (ESP)U.M.
{part.part_number}{part.description_spanish || '-'}{part.unit_of_measure}
+ {#if isLoadingParts} +
+ + Buscando partes... +
+ {:else} + No se encontraron partes + {/if} +
+
+ {#if isLoadingParts && parts.length > 0} +
+ +
+ {/if} +
+
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/parts/SubstitutePartSelectorDialog.svelte b/frontend/src/lib/components/dashboard/goods/parts/SubstitutePartSelectorDialog.svelte new file mode 100644 index 00000000..2f2ef49d --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/parts/SubstitutePartSelectorDialog.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte b/frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte new file mode 100644 index 00000000..40149a23 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte @@ -0,0 +1,134 @@ + + + + + + Seleccionar Proveedor (Textil) + + Busca y selecciona el proveedor para el material textil. + + + +
+ + +
+ +
+ {#if loading} +
+ +

Cargando catálogo...

+
+ {:else if filteredProviders.length === 0} +
+

No se encontraron proveedores.

+
+ {:else} + + + + + + + + + + {#each filteredProviders as provider} + handleSelect(provider)} + > + + + + + {/each} + +
IDRFCNombre
{provider.id}{provider.rfc} +
+ + {provider.name} +
+
+ {/if} +
+ + +
+ Mostrando {filteredProviders.length} registro(s) +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte index 04a8ce34..7a954a0a 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/class-selector-dialog.svelte @@ -75,7 +75,7 @@ - + Seleccionar Clase (Anexo 24) diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index feb73f3e..9295485f 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -1,904 +1,3579 @@
-
-
-
- -

{title}

- - {isEdit ? "Editar" : "Nueva"} - -
-

- {formType === 'fa' ? 'Gestión de Activo Fijo' : 'Gestión de Inventario'} -

-
-
+
+
+
+ +

{title}

+ + {isEdit ? 'Editar' : 'Nueva'} + +
+

+ {formType === 'fa' ? 'Gestión de Activo Fijo' : 'Gestión de Inventario'} +

+
+
- + + {#if error} +
+ ⚠️ {error} +
+ {/if} - {#if error} -
- ⚠️ {error!} -
- {/if} +
+ {#if formType === 'fa'} +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-6" + > + + + +
+ +
+
+ +
+ + +
+
+
+ +
+
+ + (showClientModal = true)} + class="cursor-pointer pl-9 transition-colors hover:bg-muted/50" + placeholder="Seleccione un cliente..." + /> +
+ +
+
+
-
+
+
+ +