Merge branch 'feature/creacion_modulo_mercancias' into development
This commit is contained in:
48
backend/api/v1/modules/a24/fa/fa_parts/models.py
Normal file
48
backend/api/v1/modules/a24/fa/fa_parts/models.py
Normal 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}')>"
|
||||
@@ -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"},
|
||||
|
||||
108
backend/api/v1/modules/a24/inv/inv_parts/models.py
Normal file
108
backend/api/v1/modules/a24/inv/inv_parts/models.py
Normal 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}')>"
|
||||
10
backend/api/v1/modules/a24/router.py
Normal file
10
backend/api/v1/modules/a24/router.py
Normal 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."}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}')>"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user