diff --git a/backend/api/v1/modules/a24/fa/fa_parts/models.py b/backend/api/v1/modules/a24/fa/fa_parts/models.py new file mode 100644 index 00000000..ba5bbe75 --- /dev/null +++ b/backend/api/v1/modules/a24/fa/fa_parts/models.py @@ -0,0 +1,48 @@ +""" +Modelo ORM para datos específicos de Activos Fijos (Q-Partes) - Anexo 24 +""" + +from typing import TYPE_CHECKING, Optional +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base +from sqlalchemy import ( + Integer, + PrimaryKeyConstraint, + String, + ForeignKeyConstraint +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + + from api.v1.modules.a76.parts.models import Part + + +class FaPart(Base, TenantScopedMixin, TimestampMixin): + """ + Tabla fa_partes: Extensión de Anexo 24 para Activos Fijos. + """ + + __tablename__ = "fa_partes" + __table_args__ = ( + PrimaryKeyConstraint("id", name="fa_partes_pkey"), + ForeignKeyConstraint( + ["id"], ["a76.parts.id"], name="fk_fa_partes_master" + ), + {"schema": "a24"}, + ) + + # El ID hereda el valor de la tabla parts + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) + + # --- CAMPOS ESPECÍFICOS FISCALES (Q-PARTES) --- + origin_country: Mapped[Optional[str]] = mapped_column(String(3)) # PAIS + sector: Mapped[Optional[str]] = mapped_column(String(8)) # SECTOR + fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION + + # --- RELACIÓN --- + # Usamos string "Part" para evitar que truene al inicializar los mappers + master_info: Mapped["Part"] = relationship("Part", back_populates="fa_data") + + def __repr__(self) -> str: + return f"" \ No newline at end of file diff --git a/backend/api/v1/modules/a24/inv/inv_parts/models.py b/backend/api/v1/modules/a24/inv/inv_parts/models.py new file mode 100644 index 00000000..467bbc2c --- /dev/null +++ b/backend/api/v1/modules/a24/inv/inv_parts/models.py @@ -0,0 +1,108 @@ +""" +Modelo ORM para datos específicos de Inventario y Manufactura (S-Partes) - 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, + PrimaryKeyConstraint, + String, + Boolean, + ForeignKeyConstraint +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +if TYPE_CHECKING: + + from api.v1.modules.a76.parts.models import Part + + +class InvPart(Base, TenantScopedMixin, TimestampMixin): + """ + Tabla inv_partes: Extensión de Anexo 24 para Inventarios (SPartes). + """ + + __tablename__ = "inv_partes" + __table_args__ = ( + PrimaryKeyConstraint("id", name="inv_partes_pkey"), + ForeignKeyConstraint( + ["id"], ["a76.parts.id"], name="fk_inv_partes_master" + ), + {"schema": "a24"}, + ) + + # Relación 1:1 - El ID es el mismo de la tabla maestra + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) + + # --- 1. ATRIBUTOS PRINCIPALES DE INVENTARIO --- + part_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOPARTE + material_type: Mapped[Optional[str]] = mapped_column(String(10)) # TIPOMAT + + reference_number: Mapped[Optional[str]] = mapped_column(String(70)) # NUMPARTEREF + flex_reference_number: Mapped[Optional[str]] = mapped_column(String(120)) # NUMPARTEREFFLEX + + # Conversiones + equivalent_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDEQUIV + conversion_factor: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # FACTORCONV + + stock_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UMEXISTENCIA + alternate_uom: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDALTERNA + conversion_uom: Mapped[Optional[str]] = mapped_column(String(9)) # UMCONVERSION + + # Valor Agregado + added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORAGREGADO + added_value_type: Mapped[Optional[str]] = mapped_column(String(2)) # TIPOVA + + assigned_client: Mapped[Optional[str]] = mapped_column(String(50)) # CLIENTEASIGNADO + supplier_code: Mapped[Optional[str]] = mapped_column(String(8)) # PROVEEDOR + is_textile: Mapped[Optional[str]] = mapped_column(String(2)) # ESTEXTIL + + # --- 2. MANUFACTURA Y PELIGROSIDAD --- + bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM / VERSIONBILL + is_repair: Mapped[Optional[str]] = mapped_column(String(3)) # ESREPARACION + is_hazardous: Mapped[Optional[str]] = mapped_column(String(1)) # ESMATPELIGROSO + + emergency_number: Mapped[Optional[str]] = mapped_column(String(30)) # NUMEMERGENCIA + danger_class: Mapped[Optional[str]] = mapped_column(String(4)) # CLASEDEPELIGRO + packaging_group: Mapped[Optional[str]] = mapped_column(String(3)) # GRUPOEMBALAJE + + # Dimensiones + width: Mapped[Optional[str]] = mapped_column(String(50)) # ANCHURA + thickness: Mapped[Optional[str]] = mapped_column(String(50)) # ESPESOR + specification: Mapped[Optional[str]] = mapped_column(String(50)) # SPEC + + # --- 3. COSTOS DETALLADOS Y ADUANA US --- + total_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTOTAL + direct_labor: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TRABAJODIREC + general_expenses: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # GASTOGRALES + total_expenses: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TOTALGASTOS + + depreciation: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # DEPRECIACION + tooling: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # TOOLING + material_consumed: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MATCONSUMED + profit: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # GANANCIA + + # Fracciones Internacionales + us_fraction_alt: Mapped[Optional[str]] = mapped_column(String(13)) # FRACEUA + ca_fraction: Mapped[Optional[str]] = mapped_column(String(13)) # FRACCANADA + ad_valorem_us: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # ADVALOREMAME + + # Nafta / USMCA + nafta_result: Mapped[Optional[str]] = mapped_column(String(19)) # RESULTADOCALCULONAFTA + nafta_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2)) # PORCENTAJECALCULONAFTA + + # Impuestos Específicos (Derechos de Trámite Admon) + dta: Mapped[Optional[str]] = mapped_column(String(19)) # DTA + dtb: Mapped[Optional[str]] = mapped_column(String(19)) # DTB + dtg: Mapped[Optional[str]] = mapped_column(String(19)) # DTG + + # --- RELACIÓN --- + # Usamos string "Part" para evitar problemas de carga + master_info: Mapped["Part"] = relationship("Part", back_populates="inv_data") + + def __repr__(self) -> str: + return f"" \ No newline at end of file diff --git a/backend/api/v1/modules/a24/router.py b/backend/api/v1/modules/a24/router.py new file mode 100644 index 00000000..c0da4ed5 --- /dev/null +++ b/backend/api/v1/modules/a24/router.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from .fa.fa_parts.models import FaPart as fa_model +from .inv.inv_parts.models import InvPart as inv_model + +router = APIRouter(prefix="/a24", tags=["Anexo 24"]) + +@router.get("/check") +def check(): + return {"status": "Anexo 24 module is operational."} + diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index e91d6156..0273bd68 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -1,107 +1,137 @@ from datetime import datetime from decimal import Decimal from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict -# --- DTO DE CREACIÓN --- -class PartCreateDTO(BaseModel): +# --- SUB-DTO: DATOS ADUANALES (FaData) --- +class FaDataDTO(BaseModel): + origin_country: Optional[str] = None + sector: Optional[str] = None + fraction_type: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + +# --- SUB-DTO: DATOS DE INVENTARIO Y COSTEO (InvData) --- +class InvDataDTO(BaseModel): + part_type: Optional[str] = None + material_type: Optional[str] = None + reference_number: Optional[str] = None + flex_reference_number: Optional[str] = None + equivalent_uom: Optional[str] = None + conversion_factor: Optional[Decimal] = None + stock_uom: Optional[str] = None + alternate_uom: Optional[str] = None + conversion_uom: Optional[str] = None + added_value: Optional[Decimal] = None + added_value_type: Optional[str] = None + assigned_client: Optional[str] = None + supplier_code: Optional[str] = None + is_textile: Optional[str] = None + bom_version: Optional[int] = None + is_repair: Optional[str] = None + is_hazardous: Optional[str] = None + emergency_number: Optional[str] = None + danger_class: Optional[str] = None + packaging_group: Optional[str] = None + width: Optional[str] = None + thickness: Optional[str] = None + specification: Optional[str] = None + + # Desglose de Costos (Anexo 24 - Valor Agregado) + total_value: Optional[Decimal] = None + direct_labor: Optional[Decimal] = None + general_expenses: Optional[Decimal] = None + total_expenses: Optional[Decimal] = None + depreciation: Optional[Decimal] = None + tooling: Optional[Decimal] = None + material_consumed: Optional[Decimal] = None + profit: Optional[Decimal] = None + + # Fracciones adicionales y TLCAN/T-MEC + us_fraction_alt: Optional[str] = None + ca_fraction: Optional[str] = None + ad_valorem_us: Optional[Decimal] = None + nafta_result: Optional[str] = None + nafta_percentage: Optional[Decimal] = None + dta: Optional[str] = None + dtb: Optional[str] = None + dtg: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class PartBase(BaseModel): client_id: int - part_number: str = Field(..., max_length=50) + part_number: str = Field(..., max_length=70) + commercial_part_number: Optional[str] = None - # Campos Generales description_spanish: Optional[str] = None description_english: Optional[str] = None part_class: Optional[str] = None - material_type: Optional[str] = None unit_of_measure: Optional[str] = "PZ" - commercial_part_number: Optional[str] = None - country_of_origin: Optional[str] = "MEX" - - # Costos y Pesos - unit_cost: Optional[Decimal] = Decimal("0.0") - currency_key: Optional[str] = "USD" - unit_weight: Optional[Decimal] = Decimal("0.0") - weight_type: Optional[str] = "KG" - - # --- LOS QUE FALTABAN Y AHORA SE GUARDARÁN --- - added_value: Optional[Decimal] = None - part_photo: Optional[str] = None - alternate_unit_measure: Optional[str] = None - license_code: Optional[str] = None - export_code: Optional[str] = None - exclusion_symbol: Optional[str] = None - - # Regulatorios - 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 - - # Estatus - is_active: Optional[bool] = True - -# --- DTO DE ACTUALIZACIÓN --- -class PartUpdateDTO(BaseModel): - description_spanish: Optional[str] = None - description_english: Optional[str] = None - part_class: Optional[str] = None - material_type: Optional[str] = None - unit_of_measure: Optional[str] = None - commercial_part_number: Optional[str] = None - country_of_origin: Optional[str] = None unit_cost: Optional[Decimal] = None currency_key: Optional[str] = None + currency_type: Optional[str] = None + unit_weight: Optional[Decimal] = None weight_type: Optional[str] = None - added_value: Optional[Decimal] = None - part_photo: Optional[str] = None - alternate_unit_measure: Optional[str] = None - license_code: Optional[str] = None - export_code: Optional[str] = None - exclusion_symbol: Optional[str] = None fraction: Optional[str] = None us_fraction: Optional[str] = None - supplier: Optional[str] = None + + # Regulatorios fda_key: Optional[str] = None fcc_key: Optional[str] = None + license_code: Optional[str] = None eccn: Optional[str] = None - is_active: Optional[bool] = None + export_code: Optional[str] = None + exclusion_symbol: Optional[str] = None + + is_active: bool = True + part_photo: Optional[str] = None + # Anidados + fa_data: Optional[FaDataDTO] = None + inv_data: Optional[InvDataDTO] = None -class PartResponseDTO(PartCreateDTO): +# --- CREACIÓN --- +class PartCreateDTO(PartBase): + # Opcional para que lo tome de la URL si no viene en el body + company_id: Optional[int] = None + +# --- ACTUALIZACIÓN --- +class PartUpdateDTO(PartBase): + client_id: Optional[int] = None + part_number: Optional[str] = None + # Todo opcional para PATCH + pass + +# --- RESPUESTA --- +class PartResponseDTO(PartBase): id: int tenant_id: int company_id: int creation_date: Optional[int] = None modification_date_iso: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) +# --- BÁSICO (Para listados ligeros) --- class PartBasicDTO(BaseModel): id: int - client_id: int part_number: str description_spanish: Optional[str] = None - is_active: Optional[bool] = True + fraction: Optional[str] = None + is_active: bool + + model_config = ConfigDict(from_attributes=True) - class Config: - from_attributes = True - -class PartListDTO(BaseModel): - parts: List[PartBasicDTO] +# --- LISTADO PAGINADO --- +class PartListResponseDTO(BaseModel): + items: List[PartResponseDTO] total: int page: int - size: int + page_size: int + pages: int -class PartSearchDTO(BaseModel): - 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/models.py b/backend/api/v1/modules/a76/parts/models.py index aad7e6b9..a5da21c2 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -1,7 +1,6 @@ """ -Modelos ORM para gestión de partes/componentes +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 @@ -9,34 +8,23 @@ from typing import TYPE_CHECKING, Optional from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base from sqlalchemy import ( - ForeignKeyConstraint, - Integer, - Numeric, - PrimaryKeyConstraint, - String, - UniqueConstraint, - Boolean, + ForeignKeyConstraint, Integer, Numeric, PrimaryKeyConstraint, + String, UniqueConstraint, Boolean, DateTime ) +# Importante usar relationship y Mapped from sqlalchemy.orm import Mapped, mapped_column, relationship if TYPE_CHECKING: from api.v1.modules.a76.classes.models import Class - from api.v1.modules.public.reference_data.countries.models import Country from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure - + from api.v1.modules.a24.fa.fa_parts.models import FaPart + from api.v1.modules.a24.inv.inv_parts.models import InvPart class Part(Base, TenantScopedMixin, TimestampMixin): - """ - Modelo para la tabla GPartes - Información de partes en los sistemas SCAII (N), SCAF (S) Y WINSAAI (W) - """ - __tablename__ = "parts" __table_args__ = ( PrimaryKeyConstraint("id", name="parts_pkey"), - ForeignKeyConstraint( - ["country_of_origin"], ["public.countries.m3_key"], name="fk_parts_country" - ), ForeignKeyConstraint( ["currency_key"], ["public.currency_types.code"], name="fk_parts_currency" ), @@ -45,6 +33,12 @@ class Part(Base, TenantScopedMixin, TimestampMixin): ["a76.units_of_measure.code", "a76.units_of_measure.tenant_id", "a76.units_of_measure.company_id"], ), + # Puente hacia la tabla de clases + ForeignKeyConstraint( + ["part_class", "tenant_id", "company_id"], + ["a76.classes.class_code", "a76.classes.tenant_id", "a76.classes.company_id"], + name="fk_parts_class" + ), UniqueConstraint( "tenant_id", "company_id", "part_number", name="client_part_ukey" ), @@ -52,81 +46,58 @@ class Part(Base, TenantScopedMixin, TimestampMixin): ) id: Mapped[int] = mapped_column(Integer, primary_key=True) - - # Unique constraint compuesta client_id: Mapped[int] = mapped_column(Integer) - part_number: Mapped[str] = mapped_column(String(50)) + part_number: Mapped[str] = mapped_column(String(70)) + commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70)) - # Basic information - fraction: Mapped[Optional[str]] = mapped_column(String(10)) description_spanish: Mapped[Optional[str]] = mapped_column(String(500)) description_english: Mapped[Optional[str]] = mapped_column(String(500)) part_class: Mapped[Optional[str]] = mapped_column(String(8)) - material_type: Mapped[Optional[str]] = mapped_column(String(10)) - unit_of_measure: Mapped[Optional[str]] = mapped_column( - String(5) - ) - commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70)) - country_of_origin: Mapped[Optional[str]] = mapped_column(String(3)) - - # Pricing and currency + unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5)) + unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) currency_type: Mapped[Optional[str]] = mapped_column(String(2)) currency_key: Mapped[Optional[str]] = mapped_column(String(3)) - # Weight information unit_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) weight_type: Mapped[Optional[str]] = mapped_column(String(6)) - # Classification and regulatory - us_fraction: Mapped[Optional[str]] = mapped_column( - String(16)) # FRACCIONAME + fraction: Mapped[Optional[str]] = mapped_column(String(10)) + us_fraction: Mapped[Optional[str]] = mapped_column(String(16)) fda_key: Mapped[Optional[str]] = mapped_column(String(20)) fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) license_code: Mapped[Optional[str]] = mapped_column(String(3)) - eccn: Mapped[Optional[str]] = mapped_column( - String(20) - ) # Export Control Classification Number + eccn: Mapped[Optional[str]] = mapped_column(String(20)) export_code: Mapped[Optional[str]] = mapped_column(String(2)) - exclusion_symbol: Mapped[Optional[str]] = mapped_column( - String(19)) # SIMBOLOEXCLIC + exclusion_symbol: Mapped[Optional[str]] = mapped_column(String(19)) - # Additional information - supplier: Mapped[Optional[str]] = mapped_column(String(14)) - alternate_unit_measure: Mapped[Optional[str]] = mapped_column(String(14)) - added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) - - # Status and dates - is_active: Mapped[Optional[bool]] = mapped_column(Boolean) - creation_date: Mapped[Optional[int] - ] = mapped_column() # FECHACREACIONPARTE - modification_date: Mapped[Optional[int]] = mapped_column() # FECHAMODIFICA - modification_date_iso: Mapped[Optional[datetime]] = ( - mapped_column() - ) # FECHAMODIFICA_ISO - - # Media + is_active: Mapped[Optional[bool]] = mapped_column(Boolean, default=True) part_photo: Mapped[Optional[str]] = mapped_column(String(255)) + + creation_date: Mapped[Optional[int]] = mapped_column() + modification_date: Mapped[Optional[int]] = mapped_column() + modification_date_iso: Mapped[Optional[datetime]] = mapped_column(DateTime) - # Relationships - country: Mapped[Optional["Country"]] = relationship( - foreign_keys=[country_of_origin] - ) + # --- RELACIONES CORREGIDAS --- currency: Mapped[Optional["CurrencyType"]] = relationship( - foreign_keys=[currency_key] + foreign_keys="[Part.currency_key]" ) unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship( - foreign_keys=[unit_of_measure] + foreign_keys="[Part.unit_of_measure, Part.tenant_id, Part.company_id]" + ) + part_class_info: Mapped[Optional["Class"]] = relationship( + "Class", + back_populates="parts", + foreign_keys="[Part.part_class, Part.tenant_id, Part.company_id]" ) - # Relationship with Class through composite foreign key - # Note: This requires both client_id and part_class to match client_id and class_code in Class - part_class_info: Mapped[Optional["Class"]] = relationship( - primaryjoin="and_(Part.client_id == Class.client_id, Part.part_class == Class.class_code)", - foreign_keys="[Part.client_id, Part.part_class]", - viewonly=True, - back_populates="parts", + # Extensiones Anexo 24 + fa_data: Mapped[Optional["FaPart"]] = relationship( + "FaPart", back_populates="master_info", uselist=False, cascade="all, delete-orphan" + ) + inv_data: Mapped[Optional["InvPart"]] = relationship( + "InvPart", back_populates="master_info", uselist=False, cascade="all, delete-orphan" ) def __repr__(self) -> str: - return f"" + return f"" \ 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 b034f729..033ad472 100644 --- a/backend/api/v1/modules/a76/parts/routes.py +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -1,13 +1,13 @@ """ 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 .dto import PartCreateDTO, PartResponseDTO, PartUpdateDTO from .service import PartService -# Ahora ya no dará error aquí + router = TenantCRUDRoutes( service=PartService, create_schema=PartCreateDTO, diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py index 00f3ce94..5db4bb5f 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -1,18 +1,26 @@ import logging -from typing import List, Optional, Any, Dict +from typing import Any, Dict, List, Optional + from fastapi import HTTPException -from sqlalchemy import and_, or_ +from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session + +# Importamos el modelo PRINCIPAL from .models import Part -from .dto import PartCreateDTO, PartUpdateDTO, PartSearchDTO + +# Importamos TODOS los DTOs necesarios +from .dto import ( + PartCreateDTO, + PartUpdateDTO, + PartResponseDTO, + PartBasicDTO # ¡Importante tener este! +) logger = logging.getLogger(__name__) class PartService: - """ - Servicio de Partes compatible con TenantCRUDRoutes - """ + """Servicio para gestión de Partes (Anexo 76 + Anexo 24)""" @staticmethod def get_all( @@ -21,39 +29,32 @@ class PartService: company_id: int, skip: int = 0, limit: int = 100, - filters: Optional[Dict[str, Any]] = None, # Agregamos este argumento explícito + filters: Optional[Dict[str, Any]] = None, ) -> tuple[List[Part], int]: - """Obtener todas las partes con paginación y filtros""" - try: - 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) - )) + + query = db.query(Part).filter( + Part.tenant_id == tenant_id, + Part.company_id == company_id + ) - if filters.get("client_id"): - query = query.filter(Part.client_id == filters["client_id"]) + if filters: + if filters.get("q"): + search = f"%{filters['q']}%" + query = query.filter( + or_( + Part.part_number.ilike(search), + Part.description_spanish.ilike(search), + Part.commercial_part_number.ilike(search) + ) + ) + # Otros filtros... - 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") + total = query.count() + items = query.offset(skip).limit(limit).all() + return items, total @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, @@ -62,67 +63,117 @@ class PartService: @staticmethod def create(db: Session, part_data: PartCreateDTO, tenant_id: int, company_id: int) -> Part: - """Crear parte (Reemplaza a create_part)""" + # 1. Preparar datos + data = part_data.model_dump() + + # Separar datos anidados + fa_dict = data.pop('fa_data', None) + inv_dict = data.pop('inv_data', None) + + # Inyectar IDs de contexto (Seguridad Multi-tenant) + data['company_id'] = company_id + data['tenant_id'] = tenant_id + + # 2. Verificar duplicados (Usando la UniqueConstraint del modelo) + existing = db.query(Part).filter( + Part.tenant_id == tenant_id, + Part.company_id == company_id, + Part.part_number == data['part_number'] + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail=f"El número de parte '{data['part_number']}' ya existe." + ) + + # 3. Crear objeto Part + db_part = Part(**data) + + # 4. Crear relaciones (Anexo 24) + # Importamos aquí para evitar ciclos, usando las rutas de tu modelo + if fa_dict: + from api.v1.modules.a24.fa.fa_parts.models import FaPart + # Importante: Pasar tenant/company también al hijo + db_part.fa_data = FaPart(**fa_dict, tenant_id=tenant_id, company_id=company_id) + + if inv_dict: + 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) + 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() + err_msg = str(e.orig) + logger.error(f"Error DB creando parte: {err_msg}") + + if "foreign key" in err_msg: + if "unit_of_measure" in err_msg: + raise HTTPException(400, "La Unidad de Medida no existe en el catálogo.") + if "currency_key" in err_msg: + raise HTTPException(400, "La Moneda no existe en el catálogo.") + if "part_class" in err_msg: + raise HTTPException(400, "La Clase no existe para este cliente.") + + # 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.") + + @staticmethod + def update(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 + + data = part_data.model_dump(exclude_unset=True) + fa_dict = data.pop('fa_data', None) + inv_dict = data.pop('inv_data', None) + + # Actualizar campos directos + for key, value in data.items(): + setattr(db_part, key, value) + + # Actualizar FA Data + if fa_dict is not None: + if db_part.fa_data: + for k, v in fa_dict.items(): + setattr(db_part.fa_data, k, v) + else: + 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 + 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) + 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) + + try: + db.commit() + db.refresh(db_part) + return db_part except IntegrityError as e: db.rollback() - 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 update( - db: Session, - part_id: int, - tenant_id: int, - part_data: PartUpdateDTO, - company_id: int - ) -> Optional[Part]: - """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.commit() - db.refresh(db_part) - return db_part - except Exception as e: - db.rollback() - logger.error(f"Error actualizando parte {part_id}: {e}") - raise HTTPException(status_code=500, detail="Error al actualizar parte") + raise HTTPException(400, f"Error actualizando: {str(e.orig)}") @staticmethod 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 + if not db_part: return False try: db.delete(db_part) db.commit() return True - except Exception as e: + except Exception: db.rollback() - 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 + raise HTTPException(500, "Error eliminando parte") \ No newline at end of file diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index e938f689..49a2a640 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -43,6 +43,7 @@ from .transportation.transporters.routes import router as transporters_router from .transportation.vehicles.routes import router as vehicles_router from api.v1.modules.public.reference_data.material_types.routes import router as material_types_router + # Router principal router = APIRouter() @@ -96,6 +97,7 @@ router.include_router(doda_router, prefix="/a76") router.include_router(prevalidators_router, prefix="/a76") router.include_router(electronic_notices_router, prefix="/a76") + # Registrar router de tipos de material públicos router.include_router( material_types_router, diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 9fd2618e..c17b0ee0 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -9,6 +9,7 @@ from fastapi import APIRouter from .modules.core.router import router as core_router from .modules.a76.router import router as a76_router from .modules.public.router import router as public_router +from .modules.a24.router import router as a24_router # Router principal router = APIRouter() @@ -17,6 +18,8 @@ router = APIRouter() router.include_router(core_router) router.include_router(a76_router) router.include_router(public_router) +# nuevas rutas de partes de anexo 24 +router.include_router(a24_router) # Health check diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index 3ae0b2a9..88538db8 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -1,9 +1,57 @@ import { api } from '$lib/api'; import type { ApiResponse } from '$lib/api'; +export interface FaData { + origin_country?: string | null; + sector?: string | null; + fraction_type?: string | null; +} + +export interface InvData { + part_type?: string | null; + material_type?: string | null; + reference_number?: string | null; + flex_reference_number?: string | null; + equivalent_uom?: string | null; + conversion_factor?: number | null; + stock_uom?: string | null; + alternate_uom?: string | null; + conversion_uom?: string | null; + added_value?: number | null; + added_value_type?: string | null; + assigned_client?: string | null; + supplier_code?: string | null; + is_textile?: string | null; + bom_version?: number | null; + is_repair?: string | null; + is_hazardous?: string | null; + emergency_number?: string | null; + danger_class?: string | null; + packaging_group?: string | null; + width?: string | null; + thickness?: string | null; + specification?: string | null; + total_value?: number | null; + direct_labor?: number | null; + general_expenses?: number | null; + total_expenses?: number | null; + depreciation?: number | null; + tooling?: number | null; + material_consumed?: number | null; + profit?: number | null; + us_fraction_alt?: string | null; + ca_fraction?: string | null; + ad_valorem_us?: number | null; + nafta_result?: string | null; + nafta_percentage?: number | null; + dta?: string | null; + dtb?: string | null; + dtg?: string | null; +} + + export interface Part { id: number; - // Llaves foráneas y IDs tenant_id: number; company_id: number; client_id: number; @@ -11,99 +59,76 @@ export interface Part { // Identificación part_number: string; commercial_part_number: string | null; - part_class: string | null; - material_type?: string | null; - // Descripciones + // Descripciones y Clase description_spanish: string | null; description_english: string | null; + part_class: string | null; + unit_of_measure: string | null; - // Físico y Origen - unit_of_measure: string; - alternate_unit_measure: string | null; - country_of_origin: string; + // Costos y Pesos + unit_cost: number | null; + currency_key: string | null; + currency_type: string | null; unit_weight: number | null; weight_type: string | null; - part_photo: string | null; - // Clasificación Arancelaria + // Regulatorio 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; + eccn: string | null; export_code: string | null; exclusion_symbol: string | null; - // Estado + // Estado y Media is_active: boolean; - created_at?: string; - updated_at?: string; + part_photo: string | null; + created_at: string; + updated_at: string; + + + fa_data?: FaData | null; + inv_data?: InvData | null; } -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; - material_type?: 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 PartCreate extends Omit { } + export interface PartUpdate extends Partial {} + export interface PartListResponse { items: Part[]; total: number; page: number; page_size: number; + pages: number; } export const partsApi = { - list: (params: { company_id: number; page?: number; page_size?: number; q?: string }) => { + + 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}` - ); + const query = new URLSearchParams({ + company_id: company_id.toString(), + skip: skip.toString(), + limit: page_size.toString(), + description: q + }); + + return api.get(`/v1/a76/parts/?${query.toString()}`); }, get: (id: number, company_id: number) => { @@ -118,6 +143,7 @@ export const partsApi = { 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}`); } diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte new file mode 100644 index 00000000..bed5413b --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -0,0 +1,711 @@ + + +
+
+ +
+

{title}

+

+ {formType === 'fa' ? 'Gestión de Activos Fijos (Q-Partes)' : 'Gestión detallada de números de parte (S-Partes).'} +

+
+
+ + {#if error} +
+ ⚠️ {error} +
+ {/if} + + {#if formType === 'fa'} +
{ e.preventDefault(); handleSubmit(); }} class="space-y-6"> + + + + +
+ + + +
+
+ + +
+
+ +
+ showClientModal = true} class="cursor-pointer font-mono" placeholder="Seleccione..."/> + +
+ {#if selectedClientName}
{selectedClientName}
{/if} +
+
+ +
+
+ +