505 lines
22 KiB
Python
505 lines
22 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", "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,
|
|
) -> 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
|
|
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]:
|
|
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") |