Merge branch 'feature/creacion_modulo_mercancias' into development

This commit is contained in:
AlexeerCT
2026-01-08 16:48:23 -06:00
43 changed files with 7233 additions and 1908 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

@@ -8,7 +8,7 @@ class SClasses(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "inv_classes" # SClases
__table_args__ = (
ForeignKeyConstraint(
["class_id"], ["a76.clases.class_id"], name="fk_sclasses_classes"
["class_id"], ["a76.classes.id"], name="fk_sclasses_classes"
),
PrimaryKeyConstraint("id", name="sclases_pk"),
{"schema": "a24"},

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

@@ -116,9 +116,7 @@ class ClientProviderCreateDTO(BaseModel):
)
position: Optional[str] = Field(None, max_length=30, description="Position")
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
is_national_provider: Optional[bool] = None
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
@@ -159,9 +157,7 @@ class ClientProviderUpdateDTO(BaseModel):
)
position: Optional[str] = Field(None, max_length=30, description="Position")
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
is_national_provider: Optional[bool] = None
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
@@ -193,7 +189,7 @@ class ClientProviderResponseDTO(BaseModel):
responsible: Optional[str] = None
position: Optional[str] = None
incoterm: Optional[str] = None
is_national_provider: Optional[str] = None
is_national_provider: Optional[bool] = None
is_active: Optional[bool] = None
tenant_id: int
company_id: int

View File

@@ -1,232 +1,137 @@
"""
DTOs (Data Transfer Objects) para módulo de partes/componentes
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field
# --- 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 PartCreateDTO(BaseModel):
"""DTO para crear una parte"""
client_id: int = Field(..., description="Client key")
part_number: str = Field(..., max_length=49, description="Part number")
fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction")
description_spanish: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
)
description_english: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
part_class: Optional[str] = Field(None, max_length=8, description="Part class")
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure"
)
commercial_part_number: Optional[str] = Field(
None, max_length=70, description="Commercial part number"
)
country_of_origin: Optional[str] = Field(
None, max_length=3, description="Country of origin code"
)
# Pricing and currency
unit_cost: Optional[Decimal] = Field(None, description="Unit cost")
currency_type: Optional[str] = Field(
None, max_length=2, description="Currency type"
)
currency_key: Optional[str] = Field(None, max_length=3, description="Currency key")
# Weight information
unit_weight: Optional[Decimal] = Field(None, description="Unit weight")
weight_type: Optional[str] = Field(None, max_length=6, description="Weight type")
# Classification and regulatory
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
)
fda_key: Optional[str] = Field(None, max_length=20, description="FDA key")
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
license_code: Optional[str] = Field(None, max_length=3, description="License code")
eccn: Optional[str] = Field(
None, max_length=20, description="Export Control Classification Number"
)
export_code: Optional[str] = Field(None, max_length=2, description="Export code")
exclusion_symbol: Optional[str] = Field(
None, max_length=19, description="Exclusion symbol"
)
# Additional information
supplier: Optional[str] = Field(None, max_length=14, description="Supplier")
alternate_unit_measure: Optional[str] = Field(
None, max_length=14, description="Alternate unit of measure"
)
added_value: Optional[Decimal] = Field(None, description="Added value")
# Status and media
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
creation_date: Optional[int] = Field(None, description="Creation date")
part_photo: Optional[str] = Field(
None, max_length=255, description="Part photo URL"
)
class Config:
from_attributes = True
class PartUpdateDTO(BaseModel):
"""DTO para actualizar una parte"""
fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction")
description_spanish: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
)
description_english: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
part_class: Optional[str] = Field(None, max_length=8, description="Part class")
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure"
)
commercial_part_number: Optional[str] = Field(
None, max_length=70, description="Commercial part number"
)
country_of_origin: Optional[str] = Field(
None, max_length=3, description="Country of origin code"
)
# Pricing and currency
unit_cost: Optional[Decimal] = Field(None, description="Unit cost")
currency_type: Optional[str] = Field(
None, max_length=2, description="Currency type"
)
currency_key: Optional[str] = Field(None, max_length=3, description="Currency key")
# Weight information
unit_weight: Optional[Decimal] = Field(None, description="Unit weight")
weight_type: Optional[str] = Field(None, max_length=6, description="Weight type")
# Classification and regulatory
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
)
fda_key: Optional[str] = Field(None, max_length=20, description="FDA key")
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
license_code: Optional[str] = Field(None, max_length=3, description="License code")
eccn: Optional[str] = Field(
None, max_length=20, description="Export Control Classification Number"
)
export_code: Optional[str] = Field(None, max_length=2, description="Export code")
exclusion_symbol: Optional[str] = Field(
None, max_length=19, description="Exclusion symbol"
)
# Additional information
supplier: Optional[str] = Field(None, max_length=14, description="Supplier")
alternate_unit_measure: Optional[str] = Field(
None, max_length=14, description="Alternate unit of measure"
)
added_value: Optional[Decimal] = Field(None, description="Added value")
# Status and media
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
part_photo: Optional[str] = Field(
None, max_length=255, description="Part photo URL"
)
class Config:
from_attributes = True
class PartResponseDTO(BaseModel):
"""DTO para respuesta de parte"""
class PartBase(BaseModel):
client_id: int
part_number: str
fraction: Optional[str] = None
part_number: str = Field(..., max_length=70)
commercial_part_number: Optional[str] = None
description_spanish: Optional[str] = None
description_english: Optional[str] = None
part_class: Optional[str] = None
unit_of_measure: Optional[str] = None
commercial_part_number: Optional[str] = None
country_of_origin: Optional[str] = None
# Pricing and currency
unit_of_measure: Optional[str] = "PZ"
unit_cost: Optional[Decimal] = None
currency_type: Optional[str] = None
currency_key: Optional[str] = None
# Weight information
currency_type: Optional[str] = None
unit_weight: Optional[Decimal] = None
weight_type: Optional[str] = None
# Classification and regulatory
fraction: Optional[str] = None
us_fraction: Optional[str] = None
# Regulatorios
fda_key: Optional[str] = None
fcc_key: Optional[str] = None
license_code: Optional[str] = None
eccn: Optional[str] = None
export_code: Optional[str] = None
exclusion_symbol: Optional[str] = None
# Additional information
supplier: Optional[str] = None
alternate_unit_measure: Optional[str] = None
added_value: Optional[Decimal] = None
# Status and dates
is_active: Optional[bool] = None
creation_date: Optional[int] = None
modification_date: Optional[int] = None
modification_date_iso: Optional[datetime] = None
# Media
is_active: bool = True
part_photo: Optional[str] = None
class Config:
from_attributes = True
# Anidados
fa_data: Optional[FaDataDTO] = None
inv_data: Optional[InvDataDTO] = None
# --- 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
model_config = ConfigDict(from_attributes=True)
# --- BÁSICO (Para listados ligeros) ---
class PartBasicDTO(BaseModel):
"""DTO para información básica de parte"""
client_id: int
id: int
part_number: str
description_spanish: Optional[str] = None
description_english: Optional[str] = None
part_class: Optional[str] = None
unit_cost: Optional[Decimal] = None
currency_key: Optional[str] = None
is_active: Optional[bool] = None
fraction: Optional[str] = None
is_active: bool
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
class PartListDTO(BaseModel):
"""DTO para lista de partes"""
parts: list[PartBasicDTO]
# --- LISTADO PAGINADO ---
class PartListResponseDTO(BaseModel):
items: List[PartResponseDTO]
total: int
page: int
size: int
page_size: int
pages: int
class Config:
from_attributes = True
class PartSearchDTO(BaseModel):
"""DTO para búsqueda de partes"""
client_id: Optional[int] = Field(None, description="Filter by client key")
part_number: Optional[str] = Field(None, description="Search by part number")
description: Optional[str] = Field(None, description="Search in descriptions")
fraction: Optional[str] = Field(None, description="Filter by tariff fraction")
supplier: Optional[str] = Field(None, description="Filter by supplier")
enabled_only: bool = Field(False, description="Show only enabled parts")
class Config:
from_attributes = True

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,80 +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))
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,393 +1,22 @@
"""
Endpoints API para gestión de partes/componentes
Endpoints API para gestión de partes (SCAII)
"""
from typing import List, Optional
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from .dto import (
PartBasicDTO,
PartCreateDTO,
PartListDTO,
PartResponseDTO,
PartSearchDTO,
PartUpdateDTO,
)
from .dto import PartCreateDTO, PartResponseDTO, PartUpdateDTO
from .service import PartService
router = APIRouter(prefix="/parts")
@router.post("/", response_model=PartResponseDTO, status_code=status.HTTP_201_CREATED)
async def create_part(
part_data: PartCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Create a new part in the system
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.create_part(part_data)
@router.get("/", response_model=PartListDTO)
async def list_parts(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(
100, ge=1, le=1000, description="Maximum number of records to return"
),
client_id: Optional[int] = Query(None, description="Filter by client key"),
part_number: Optional[str] = Query(None, description="Search by part number"),
description: Optional[str] = Query(None, description="Search in descriptions"),
fraction: Optional[str] = Query(None, description="Filter by tariff fraction"),
supplier: Optional[str] = Query(None, description="Filter by supplier"),
enabled_only: bool = Query(False, description="Show only enabled parts"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
List parts with optional filters and pagination
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
search_params = PartSearchDTO(
client_id=client_id,
part_number=part_number,
description=description,
fraction=fraction,
supplier=supplier,
enabled_only=enabled_only,
)
return service.list_parts(skip, limit, search_params)
@router.get("/client/{client_id}", response_model=List[PartBasicDTO])
async def get_parts_by_client(
client_id: int,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get all parts for a specific client
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.search_by_client(client_id, skip, limit)
@router.get("/search/fraction/{fraction}", response_model=List[PartBasicDTO])
async def search_by_fraction(
fraction: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Search parts by tariff fraction
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.search_by_fraction(fraction)
@router.get("/search/supplier/{supplier}", response_model=List[PartBasicDTO])
async def search_by_supplier(
supplier: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Search parts by supplier
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.search_by_supplier(supplier)
@router.get("/search/country/{country_code}", response_model=List[PartBasicDTO])
async def get_parts_by_country(
country_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get parts by country of origin
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.get_parts_by_country(country_code)
@router.get("/statistics", response_model=dict)
async def get_parts_statistics(
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
):
"""
Get basic parts statistics
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.get_parts_statistics()
@router.get("/{client_id}/{part_number}", response_model=PartResponseDTO)
async def get_part(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get part by composite key (client_id + part_number)
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.get_part(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return part
@router.put("/{client_id}/{part_number}", response_model=PartResponseDTO)
async def update_part(
client_id: int,
part_number: str,
part_data: PartUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Update part information
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.update_part(client_id, part_number, part_data)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return part
@router.delete("/{client_id}/{part_number}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Delete part from the system
Note: This will completely remove the part from the system.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
if not service.delete_part(client_id, part_number):
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
@router.patch(
"/{client_id}/{part_number}/toggle-status", response_model=PartResponseDTO
)
async def toggle_part_status(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Toggle part enabled/disabled status
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.toggle_status(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return part
# Endpoints específicos para información detallada
@router.get("/{client_id}/{part_number}/basic", response_model=PartBasicDTO)
async def get_part_basic_info(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get basic information for a part
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.get_part(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return PartBasicDTO(
client_id=part.client_id,
part_number=part.part_number,
description_spanish=part.description_spanish,
description_english=part.description_english,
part_class=part.part_class,
unit_cost=part.unit_cost,
currency_key=part.currency_key,
is_active=part.is_active,
)
@router.get("/{client_id}/{part_number}/regulatory", response_model=dict)
async def get_part_regulatory_info(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get regulatory information for a part (FDA, FCC, ECCN, etc.)
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.get_part(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return {
"client_id": part.client_id,
"part_number": part.part_number,
"fraction": part.fraction,
"us_fraction": part.us_fraction,
"fda_key": part.fda_key,
"fcc_key": part.fcc_key,
"license_code": part.license_code,
"eccn": part.eccn,
"export_code": part.export_code,
"exclusion_symbol": part.exclusion_symbol,
}
router = TenantCRUDRoutes(
service=PartService,
create_schema=PartCreateDTO,
update_schema=PartUpdateDTO,
response_schema=PartResponseDTO,
prefix="/parts",
tags=["a76 / parts"],
resource_name="Part",
id_name="part_id",
enable_list=True,
enable_filters=True,
).router

View File

@@ -1,309 +1,179 @@
"""
Capa de servicio para lógica de negocio de partes/componentes
"""
import logging
from typing import List, Optional
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import and_, func, or_
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import PartCreateDTO, PartUpdateDTO
# 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/componentes
"""
"""Servicio para gestión de Partes (Anexo 76 + Anexo 24)"""
@staticmethod
def create_part(db: Session, part_data: PartCreateDTO) -> Part:
"""
Crear una nueva parte
"""
def get_all(
db: Session,
tenant_id: int,
company_id: 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,
Part.company_id == company_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
@staticmethod
def get_by_id(db: Session, part_id: int, tenant_id: int, company_id: int) -> Optional[Part]:
return db.query(Part).filter(
Part.id == part_id,
Part.tenant_id == tenant_id,
Part.company_id == company_id
).first()
@staticmethod
def create(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)
# 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:
db_part = Part(**part_data.model_dump())
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()
logger.error(f"Error creating part: {e}")
raise HTTPException(
status_code=400,
detail="Part with this client_id and part_number already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Unexpected error creating part: {e}")
raise HTTPException(status_code=500, detail="Error creating part")
raise HTTPException(400, f"Error actualizando: {str(e.orig)}")
@staticmethod
def get_part(db: Session, client_id: int, part_number: str) -> Optional[Part]:
"""
Obtener una parte por clave de cliente y número de parte
"""
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:
return (
db.query(Part)
.filter(
and_(Part.client_id == client_id, Part.part_number == part_number)
)
.first()
)
except Exception as e:
logger.error(f"Error getting part: {e}")
raise HTTPException(status_code=500, detail="Error retrieving part")
@staticmethod
def get_parts_paginated(
db: Session,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
client_id: Optional[int] = None,
fraction: Optional[str] = None,
country_of_origin: Optional[str] = None,
) -> tuple[List[Part], int]:
"""
Obtener partes con paginación y filtros
"""
try:
query = db.query(Part)
# Aplicar filtros
if search:
query = query.filter(
or_(
Part.description_spanish.ilike(f"%{search}%"),
Part.description_english.ilike(f"%{search}%"),
Part.part_number.ilike(f"%{search}%"),
)
)
if client_id is not None:
query = query.filter(Part.client_id == client_id)
if fraction:
query = query.filter(Part.fraction == fraction)
if country_of_origin:
query = query.filter(Part.country_of_origin == country_of_origin)
# Contar total
total = query.count()
# Aplicar paginación
parts = query.offset(skip).limit(limit).all()
return parts, total
except Exception as e:
logger.error(f"Error getting paginated parts: {e}")
raise HTTPException(status_code=500, detail="Error retrieving parts")
@staticmethod
def get_parts_by_client(db: Session, client_id: int) -> List[Part]:
"""
Obtener todas las partes de un cliente específico
"""
try:
return db.query(Part).filter(Part.client_id == client_id).all()
except Exception as e:
logger.error(f"Error getting parts by client: {e}")
raise HTTPException(status_code=500, detail="Error retrieving client parts")
@staticmethod
def search_parts_by_fraction(db: Session, fraction: str) -> List[Part]:
"""
Buscar partes por fracción arancelaria
"""
try:
return (
db.query(Part)
.filter(
or_(
Part.fraction.ilike(f"%{fraction}%"),
Part.us_fraction.ilike(f"%{fraction}%"),
)
)
.all()
)
except Exception as e:
logger.error(f"Error searching parts by fraction: {e}")
raise HTTPException(
status_code=500, detail="Error searching parts by fraction"
)
@staticmethod
def search_parts_by_supplier(db: Session, supplier: str) -> List[Part]:
"""
Buscar partes por proveedor
"""
try:
return db.query(Part).filter(Part.supplier.ilike(f"%{supplier}%")).all()
except Exception as e:
logger.error(f"Error searching parts by supplier: {e}")
raise HTTPException(
status_code=500, detail="Error searching parts by supplier"
)
@staticmethod
def search_parts_by_country(db: Session, country_code: str) -> List[Part]:
"""
Buscar partes por país de origen
"""
try:
return db.query(Part).filter(Part.country_of_origin == country_code).all()
except Exception as e:
logger.error(f"Error searching parts by country: {e}")
raise HTTPException(
status_code=500, detail="Error searching parts by country"
)
@staticmethod
def update_part(
db: Session, client_id: int, part_number: str, part_data: PartUpdateDTO
) -> Optional[Part]:
"""
Actualizar una parte existente
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return None
# Actualizar campos
for field, value in part_data.model_dump(exclude_unset=True).items():
setattr(db_part, field, value)
db.commit()
db.refresh(db_part)
return db_part
except Exception as e:
db.rollback()
logger.error(f"Error updating part: {e}")
raise HTTPException(status_code=500, detail="Error updating part")
@staticmethod
def delete_part(db: Session, client_id: int, part_number: str) -> bool:
"""
Eliminar una parte
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return False
db.delete(db_part)
db.commit()
return True
except Exception as e:
except Exception:
db.rollback()
logger.error(f"Error deleting part: {e}")
raise HTTPException(status_code=500, detail="Error deleting part")
@staticmethod
def toggle_part_status(
db: Session, client_id: int, part_number: str
) -> Optional[Part]:
"""
Cambiar el estado habilitado/deshabilitado de una parte
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return None
# Toggle status (assuming 1 = enabled, 0 = disabled)
db_part.is_active = 1 if db_part.is_active == 0 else 0
db.commit()
db.refresh(db_part)
return db_part
except Exception as e:
db.rollback()
logger.error(f"Error toggling part status: {e}")
raise HTTPException(status_code=500, detail="Error toggling part status")
@staticmethod
def get_parts_statistics(db: Session) -> dict:
"""
Obtener estadísticas de partes
"""
try:
total_parts = db.query(Part).count()
# Partes por cliente
parts_by_client = (
db.query(Part.client_id, func.count(Part.part_number).label("count"))
.group_by(Part.client_id)
.all()
)
# Partes por país de origen
parts_by_country = (
db.query(
Part.country_of_origin, func.count(Part.part_number).label("count")
)
.filter(Part.country_of_origin.isnot(None))
.group_by(Part.country_of_origin)
.all()
)
# Partes habilitadas vs deshabilitadas
enabled_parts = db.query(Part).filter(Part.is_active == 1).count()
disabled_parts = db.query(Part).filter(Part.is_active == 0).count()
return {
"total_parts": total_parts,
"enabled_parts": enabled_parts,
"disabled_parts": disabled_parts,
"parts_by_client": [
{"client_id": item[0], "count": item[1]} for item in parts_by_client
],
"parts_by_country": [
{"country": item[0], "count": item[1]} for item in parts_by_country
],
}
except Exception as e:
logger.error(f"Error getting parts statistics: {e}")
raise HTTPException(
status_code=500, detail="Error retrieving parts statistics"
)
@staticmethod
def get_part_regulatory_info(
db: Session, client_id: int, part_number: str
) -> Optional[dict]:
"""
Obtener información regulatoria específica de una parte
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return None
return {
"client_id": db_part.client_id,
"part_number": db_part.part_number,
"fraction": db_part.fraction,
"us_fraction": db_part.us_fraction,
"fda_key": db_part.fda_key,
"fcc_key": db_part.fcc_key,
"license_code": db_part.license_code,
"eccn": db_part.eccn,
"export_code": db_part.export_code,
"exclusion_symbol": db_part.exclusion_symbol,
"country_of_origin": db_part.country_of_origin,
}
except Exception as e:
logger.error(f"Error getting part regulatory info: {e}")
raise HTTPException(
status_code=500, detail="Error retrieving part regulatory information"
)
raise HTTPException(500, "Error eliminando parte")

View File

@@ -42,6 +42,8 @@ from .general_catalogs.electronic_notices.routes import router as electronic_not
from .transportation.trailers.routes import router as trailers_router
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,3 +98,11 @@ router.include_router(error_catalogs_router, prefix="/a76")
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,
prefix="/public/reference-data",
tags=["Reference Data"]
)

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