Se normalizo la tabla de partes para anexo 76 y 24, ademas se parametrizo el formulario

This commit is contained in:
2026-01-08 15:39:40 -06:00
parent cddd64d730
commit 9c2715e8f0
12 changed files with 1244 additions and 961 deletions

View File

@@ -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"<FaPart(id={self.id}, sector='{self.sector}')>"

View File

@@ -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"<InvPart(id={self.id}, part_type='{self.part_type}')>"

View File

@@ -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."}

View File

@@ -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

View File

@@ -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"<Part(client_id={self.client_id}, part_number='{self.part_number}', description='{self.description_spanish}')>"
return f"<Part(id={self.id}, part_number='{self.part_number}')>"

View File

@@ -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,

View File

@@ -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")
raise HTTPException(500, "Error eliminando parte")

View File

@@ -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,

View File

@@ -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