import logging from typing import Any, Dict, List, Optional from fastapi import HTTPException from sqlalchemy import or_, insert as sa_insert, update as sa_update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, defer, joinedload, load_only # Importamos el modelo PRINCIPAL from .models import Part # Importamos TODOS los DTOs necesarios from .dto import ( PartCreateDTO, PartUpdateDTO, PartResponseDTO, PartBasicDTO # ¡Importante tener este! ) logger = logging.getLogger(__name__) class PartService: """Servicio para gestión de Partes (Anexo 76 + Anexo 24)""" # Estos campos están en el modelo pero NO en la DB todavía, faltan las migraciones del usuario 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", "agency_code_definition", "carta_porte", "client_part_names", "part_identifiers", "substitute_parts", "aphis_data" ] @classmethod def get_all( cls, db: Session, tenant_id: int, company_id: Optional[int], skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, sort_by: Optional[str] = None, sort_order: Optional[str] = "asc", ) -> tuple[List[Part], int]: query = db.query(Part).filter(Part.tenant_id == tenant_id) if company_id is not None: query = query.filter(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 from api.v1.modules.a24.fa.fa_parts.models import FaPart # Usamos joinedload + load_only para ser 100% seguros de qué columnas se piden load_inv_opt = joinedload(Part.inv_data).load_only(*[getattr(InvPart, c) for c in cls.SAFE_INV_COLUMNS]) load_fa_opt = joinedload(Part.fa_data) query = query.options(load_inv_opt, load_fa_opt) if filters: logger.info(f"Applying filters to Part list: {filters}") if filters.get("q"): search = f"%{filters['q']}%" query = query.filter( or_( Part.part_number.ilike(search), Part.description_spanish.ilike(search), Part.description_english.ilike(search), Part.commercial_part_number.ilike(search) ) ) # Otros filtros... # Apply sorting if sort_by: column = getattr(Part, sort_by, None) if column: if sort_order == "desc": query = query.order_by(column.desc()) else: query = query.order_by(column.asc()) else: # Default sorting: Newest first query = query.order_by(Part.id.desc()) 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 @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 ) # Cargar inv_data de forma segura y fa_data from api.v1.modules.a24.inv.inv_parts.models import InvPart from api.v1.modules.a24.fa.fa_parts.models import FaPart load_inv_opt = joinedload(Part.inv_data).load_only(*[getattr(InvPart, c) for c in cls.SAFE_INV_COLUMNS]) load_fa_opt = joinedload(Part.fa_data) query = query.options(load_inv_opt, load_fa_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() print(f"DEBUG: PartService.create - full data: {data}") # Separar datos anidados 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 aphis_records_data = None if inv_dict: countries_data = inv_dict.pop('countries', None) aphis_records_data = inv_dict.pop('aphis_records', 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) # 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.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) # 7. Crear registros Aphis if aphis_records_data: from api.v1.modules.a24.inv.inv_aphis.models import ( InvPartAphisGeneral, InvPartAphisCharacteristic, InvPartAphisStypePitems, InvPartAphisLpcos, InvPartAphisEntities, InvPartAphisContainers, InvPartAphisRouting ) for a_data in aphis_records_data: a_data.pop('id', None) char_data = a_data.pop('characteristics', []) stype_data = a_data.pop('stype_pitems', []) lpco_data = a_data.pop('lpcos', []) entity_data = a_data.pop('entities', []) container_data = a_data.pop('containers', []) routing_data = a_data.pop('routing', []) aphis_obj = InvPartAphisGeneral( **a_data, inv_part_id=db_part.id, tenant_id=tenant_id, company_id=company_id ) for c_data in char_data: c_data.pop('id', None); c_data.pop('aphis_general_id', None) aphis_obj.characteristics.append(InvPartAphisCharacteristic(**c_data, tenant_id=tenant_id, company_id=company_id)) for s_data in stype_data: s_data.pop('id', None); s_data.pop('aphis_general_id', None) aphis_obj.stype_pitems.append(InvPartAphisStypePitems(**s_data, tenant_id=tenant_id, company_id=company_id)) for l_data in lpco_data: l_data.pop('id', None); l_data.pop('aphis_general_id', None) aphis_obj.lpcos.append(InvPartAphisLpcos(**l_data, tenant_id=tenant_id, company_id=company_id)) for e_data in entity_data: e_data.pop('id', None); e_data.pop('aphis_general_id', None) aphis_obj.entities.append(InvPartAphisEntities(**e_data, tenant_id=tenant_id, company_id=company_id)) for con_data in container_data: con_data.pop('id', None); con_data.pop('aphis_general_id', None) aphis_obj.containers.append(InvPartAphisContainers(**con_data, tenant_id=tenant_id, company_id=company_id)) for r_data in routing_data: r_data.pop('id', None); r_data.pop('aphis_general_id', None) aphis_obj.routing.append(InvPartAphisRouting(**r_data, tenant_id=tenant_id, company_id=company_id)) db.add(aphis_obj) try: 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.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: 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, f"Error al guardar la parte: {err_msg}") @classmethod def update(cls, db: Session, part_id: int, tenant_id: int, part_data: PartUpdateDTO, company_id: int) -> Optional[Part]: print(f"DEBUG: PartService.update - part_id={part_id}, data={part_data.model_dump(exclude_unset=True)}") 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) bom_items_data = None if inv_dict: bom_items_data = inv_dict.pop('bom_items', None) countries_data = None aphis_records_data = None if inv_dict: countries_data = inv_dict.pop('countries', None) aphis_records_data = inv_dict.pop('aphis_records', 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 vía Core if inv_dict is not None: 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: 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) # Actualizar registros Aphis (Esquema relacional) if aphis_records_data is not None: from api.v1.modules.a24.inv.inv_aphis.models import ( InvPartAphisGeneral, InvPartAphisCharacteristic, InvPartAphisStypePitems, InvPartAphisLpcos, InvPartAphisEntities, InvPartAphisContainers, InvPartAphisRouting ) if db_part.inv_data: # Reemplazo total de registros Aphis para esta parte db_part.inv_data.aphis_records = [] for a_data in aphis_records_data: # Limpiar IDs y sub-datos para recreación a_data.pop('id', None) char_data = a_data.pop('characteristics', []) stype_data = a_data.pop('stype_pitems', []) lpco_data = a_data.pop('lpcos', []) entity_data = a_data.pop('entities', []) container_data = a_data.pop('containers', []) routing_data = a_data.pop('routing', []) aphis_obj = InvPartAphisGeneral( **a_data, tenant_id=tenant_id, company_id=company_id ) for c_data in char_data: c_data.pop('id', None) c_data.pop('aphis_general_id', None) aphis_obj.characteristics.append( InvPartAphisCharacteristic(**c_data, tenant_id=tenant_id, company_id=company_id) ) for s_data in stype_data: s_data.pop('id', None) s_data.pop('aphis_general_id', None) aphis_obj.stype_pitems.append( InvPartAphisStypePitems(**s_data, tenant_id=tenant_id, company_id=company_id) ) for l_data in lpco_data: l_data.pop('id', None) l_data.pop('aphis_general_id', None) aphis_obj.lpcos.append( InvPartAphisLpcos(**l_data, tenant_id=tenant_id, company_id=company_id) ) for e_data in entity_data: e_data.pop('id', None) e_data.pop('aphis_general_id', None) aphis_obj.entities.append( InvPartAphisEntities(**e_data, tenant_id=tenant_id, company_id=company_id) ) for con_data in container_data: con_data.pop('id', None) con_data.pop('aphis_general_id', None) aphis_obj.containers.append( InvPartAphisContainers(**con_data, tenant_id=tenant_id, company_id=company_id) ) for r_data in routing_data: r_data.pop('id', None) r_data.pop('aphis_general_id', None) aphis_obj.routing.append( InvPartAphisRouting(**r_data, tenant_id=tenant_id, company_id=company_id) ) db_part.inv_data.aphis_records.append(aphis_obj) try: db.commit() db.refresh(db_part) return db_part except IntegrityError as e: db.rollback() 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: db_part = PartService.get_by_id(db, part_id, tenant_id, company_id) if not db_part: return False try: db.delete(db_part) db.commit() return True except Exception: db.rollback() raise HTTPException(500, "Error eliminando parte")