Files
plantillas-proyectos/backend/api/v1/modules/a76/parts/service.py

377 lines
15 KiB
Python

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)
# 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: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> 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:
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()
# 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()
# 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
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
# 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)
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]:
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
if inv_dict:
countries_data = inv_dict.pop('countries', 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)
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")