Partidas BOM y partes para SCAI

This commit is contained in:
2026-03-11 11:11:36 -05:00
parent 2ac07b55b6
commit 9f3ae8db42
34 changed files with 5101 additions and 1040 deletions

View File

@@ -0,0 +1,56 @@
"""
Modelo ORM para la lista de materiales (BOM) de una parte - 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,
String,
PrimaryKeyConstraint,
ForeignKeyConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
class BillOfMaterial(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla inv_bom: Lista de materiales para una parte.
"""
__tablename__ = "inv_bom"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_bom_pkey"),
ForeignKeyConstraint(
["parent_part_id"], ["a76.parts.id"], name="fk_inv_bom_parent"
),
ForeignKeyConstraint(
["component_part_id"], ["a76.parts.id"], name="fk_inv_bom_component"
),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
parent_part_id: Mapped[int] = mapped_column(Integer, nullable=False)
component_part_id: Mapped[int] = mapped_column(Integer, nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(19, 8), default=Decimal('1.0'))
# Nuevos campos Legacy
uom_code: Mapped[str] = mapped_column(String(5), nullable=False)
procedure_type: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # TEMPORAL, DEFINITIVA, CTM
is_percentage: Mapped[bool] = mapped_column(default=True)
raw_material: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True)
waste: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True)
merma: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8), nullable=True)
# Relaciones
parent_part: Mapped["Part"] = relationship("Part", foreign_keys=[parent_part_id], back_populates="bom_items")
component_part: Mapped["Part"] = relationship("Part", foreign_keys=[component_part_id])
def __repr__(self) -> str:
return f"<BillOfMaterial(id={self.id}, parent={self.parent_part_id}, component={self.component_part_id}, uom={self.uom_code})>"

View File

@@ -14,6 +14,7 @@ from sqlalchemy import (
Boolean,
ForeignKeyConstraint
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
@@ -100,9 +101,60 @@ class InvPart(Base, TenantScopedMixin, TimestampMixin):
dtb: Mapped[Optional[str]] = mapped_column(String(19)) # DTB
dtg: Mapped[Optional[str]] = mapped_column(String(19)) # DTG
# --- CAMPOS ADICIONALES FRONTEND ---
substitute_part: Mapped[Optional[str]] = mapped_column(String(70))
complementary_part: Mapped[Optional[str]] = mapped_column(String(70))
preference_part: Mapped[Optional[str]] = mapped_column(String(70))
use_alternate_quantity: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
un_number: Mapped[Optional[str]] = mapped_column(String(30))
shipping_name: Mapped[Optional[str]] = mapped_column(String(200))
hazard_notes: Mapped[Optional[str]] = mapped_column(String(500))
repair_unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
repair_added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
fraction_9801: Mapped[Optional[str]] = mapped_column(String(10))
immex_type: Mapped[Optional[str]] = mapped_column(String(10))
disable_movements: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
pga_program_code: Mapped[Optional[str]] = mapped_column(String(10))
usmca_fraction: Mapped[Optional[str]] = mapped_column(String(10))
scrap_part_number: Mapped[Optional[str]] = mapped_column(String(70))
waste_part_number: Mapped[Optional[str]] = mapped_column(String(70))
scrap_description_en: Mapped[Optional[str]] = mapped_column(String(500))
scrap_description_es: Mapped[Optional[str]] = mapped_column(String(500))
scrap_export_fraction: Mapped[Optional[str]] = mapped_column(String(10))
scrap_us_fraction: Mapped[Optional[str]] = mapped_column(String(10))
equivalent_uom_2: Mapped[Optional[str]] = mapped_column(String(5))
conversion_factor_2: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
has_auxiliary: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
auxiliary_uom: Mapped[Optional[str]] = mapped_column(String(5))
auxiliary_conversion: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
auxiliary_unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
mex_packing: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
sales_order: Mapped[Optional[str]] = mapped_column(String(50))
use_rule_8: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
sector: Mapped[Optional[str]] = mapped_column(String(150))
origin_country: Mapped[Optional[str]] = mapped_column(String(3), default='MEX')
fraction_type: Mapped[Optional[str]] = mapped_column(String(10))
# JSONB para almacenar clientes con restricción de no descarga
non_discharge_clients: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True, default=[])
# --- RELACIÓN ---
# Usamos string "Part" para evitar problemas de carga
master_info: Mapped["Part"] = relationship("Part", back_populates="inv_data")
@property
def bom_items(self):
"""Exponer los BOM items de la parte maestra para Pydantic"""
if self.master_info:
return self.master_info.bom_items
return []
@property
def countries(self):
"""Exponer los países de la parte maestra para Pydantic"""
if self.master_info:
return self.master_info.inv_countries
return []
def __repr__(self) -> str:
return f"<InvPart(id={self.id}, part_type='{self.part_type}')>"

View File

@@ -0,0 +1,81 @@
"""
DTOs (Data Transfer Objects) para relación entre partes y países - Anexo 24
"""
from typing import Optional
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class PartCountryCreateDTO(BaseModel):
"""DTO para crear una relación entre parte y país"""
part_id: int = Field(..., description="Part ID")
country_code: str = Field(..., max_length=3, description="Country code (ISO 3166-1 alpha-3)")
fraction: Optional[str] = Field(None, max_length=20, description="Tariff fraction")
preference: str = Field(default="GENERAL", description="Trade preference: GENERAL, PROSEC, ALADI, TLCS")
has_certificate: bool = Field(default=False, description="Has certificate of origin")
certificate_number: Optional[str] = Field(None, max_length=50, description="Certificate of origin number")
end_date: Optional[datetime] = Field(None, description="Certificate expiration date")
previous_fractions_7m: bool = Field(default=False, description="Has fractions older than 7 months")
omission_import: bool = Field(default=False, description="Import omission flag")
omission_export: bool = Field(default=False, description="Export omission flag")
import_percentage: Optional[Decimal] = Field(None, description="Import tariff percentage")
export_percentage: Optional[Decimal] = Field(None, description="Export tariff percentage")
sector: Optional[str] = Field(None, max_length=10, description="Sector reference")
class Config:
from_attributes = True
class PartCountryUpdateDTO(BaseModel):
"""DTO para actualizar una relación entre parte y país"""
country_code: Optional[str] = Field(None, max_length=3, description="Country code")
fraction: Optional[str] = Field(None, max_length=20, description="Tariff fraction")
preference: Optional[str] = Field(None, description="Trade preference")
has_certificate: Optional[bool] = Field(None, description="Has certificate of origin")
certificate_number: Optional[str] = Field(None, max_length=50, description="Certificate number")
end_date: Optional[datetime] = Field(None, description="Certificate expiration date")
previous_fractions_7m: Optional[bool] = Field(None, description="Previous fractions flag")
omission_import: Optional[bool] = Field(None, description="Import omission flag")
omission_export: Optional[bool] = Field(None, description="Export omission flag")
import_percentage: Optional[Decimal] = Field(None, description="Import percentage")
export_percentage: Optional[Decimal] = Field(None, description="Export percentage")
sector: Optional[str] = Field(None, max_length=10, description="Sector reference")
class Config:
from_attributes = True
class PartCountryResponseDTO(BaseModel):
"""DTO para responder con datos de relación entre parte y país"""
id: int
part_id: int
country_code: str
fraction: Optional[str] = None
preference: str
has_certificate: bool
certificate_number: Optional[str] = None
end_date: Optional[datetime] = None
previous_fractions_7m: bool
omission_import: bool
omission_export: bool
import_percentage: Optional[Decimal] = None
export_percentage: Optional[Decimal] = None
sector: Optional[str] = None
class Config:
from_attributes = True
class PartCountriesBulkResponseDTO(BaseModel):
"""DTO para responder con lista de relaciones entre partes y países"""
data: list[PartCountryResponseDTO]
total: int
skip: int
limit: int

View File

@@ -0,0 +1,73 @@
"""
Modelo ORM para la relación entre partes y países - Anexo 24
"""
from datetime import datetime
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,
PrimaryKeyConstraint,
String,
Boolean,
ForeignKeyConstraint,
DateTime,
Numeric,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
class PartCountry(Base, TenantScopedMixin, TimestampMixin):
"""
Tabla inv_parte_paises: Relación entre partes y países.
Almacena información de países de origen, preferencias, certificados y omisiones.
"""
__tablename__ = "inv_parte_paises"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_parte_paises_pkey"),
ForeignKeyConstraint(
["part_id"], ["a76.parts.id"], name="fk_inv_parte_paises_part"
),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
part_id: Mapped[int] = mapped_column(Integer, nullable=False)
country_code: Mapped[str] = mapped_column(String(3), nullable=False)
# Información de fracciones
fraction: Mapped[Optional[str]] = mapped_column(String(20))
is_origin: Mapped[bool] = mapped_column(Boolean, default=False)
# Preferencia comercial
preference: Mapped[str] = mapped_column(String(15), default="GENERAL") # GENERAL, PROSEC, ALADI, TLCS
# Certificado de origen
has_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
certificate_number: Mapped[Optional[str]] = mapped_column(String(50))
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
# Fracciones anteriores a 7 meses
previous_fractions_7m: Mapped[bool] = mapped_column(Boolean, default=False)
# Omisiones (importación/exportación)
omission_import: Mapped[bool] = mapped_column(Boolean, default=False)
omission_export: Mapped[bool] = mapped_column(Boolean, default=False)
# Porcentajes
import_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2))
export_percentage: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2))
# Sector (para referencia)
sector: Mapped[Optional[str]] = mapped_column(String(10))
# Relación
part: Mapped["Part"] = relationship("Part", back_populates="inv_countries")
def __repr__(self) -> str:
return f"<PartCountry(id={self.id}, part={self.part_id}, country='{self.country_code}', preference='{self.preference}')>"

View File

@@ -0,0 +1,162 @@
"""
Rutas para gestión de relaciones entre partes y países - Anexo 24
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from .dto import (
PartCountryCreateDTO,
PartCountryResponseDTO,
PartCountryUpdateDTO,
PartCountriesBulkResponseDTO,
)
from .models import PartCountry
from .service import PartCountryService
router = APIRouter(prefix="/part-countries", tags=["part-countries"])
@router.get(
"",
response_model=dict,
summary="Get all part-country relationships",
)
async def get_all_part_countries(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
part_id: int = Query(None),
country_code: str = Query(None),
preference: str = Query(None),
db: Session = Depends(get_core_db),
):
"""Get all part-country relationships with optional filtering and pagination"""
filters = {}
if part_id:
filters["part_id"] = part_id
if country_code:
filters["country_code"] = country_code
if preference:
filters["preference"] = preference
part_countries, total = PartCountryService.get_all(db, skip, limit, filters)
return {
"data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries],
"total": total,
"skip": skip,
"limit": limit,
}
@router.get(
"/{part_country_id}",
response_model=PartCountryResponseDTO,
summary="Get part-country relationship by ID",
)
async def get_part_country(
part_country_id: int,
db: Session = Depends(get_core_db),
):
"""Get a part-country relationship by its ID"""
part_country = PartCountryService.get_by_id(db, part_country_id)
if not part_country:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Part-country relationship not found",
)
return PartCountryResponseDTO.model_validate(part_country)
@router.get(
"/part/{part_id}",
response_model=dict,
summary="Get all countries for a part",
)
async def get_part_countries(
part_id: int,
db: Session = Depends(get_core_db),
):
"""Get all countries associated with a specific part"""
part_countries = PartCountryService.get_by_part_id(db, part_id)
return {
"data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries],
"total": len(part_countries),
}
@router.post(
"",
response_model=PartCountryResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Create part-country relationship",
)
async def create_part_country(
part_country_data: PartCountryCreateDTO,
db: Session = Depends(get_core_db),
):
"""Create a new part-country relationship"""
part_country = PartCountryService.create(db, part_country_data)
return PartCountryResponseDTO.model_validate(part_country)
@router.post(
"/part/{part_id}/bulk",
response_model=dict,
status_code=status.HTTP_201_CREATED,
summary="Bulk create/update part-country relationships",
)
async def bulk_create_part_countries(
part_id: int,
countries_data: List[PartCountryCreateDTO],
db: Session = Depends(get_core_db),
):
"""Create or replace all part-country relationships for a part"""
part_countries = PartCountryService.bulk_create(db, part_id, countries_data)
return {
"data": [PartCountryResponseDTO.model_validate(pc) for pc in part_countries],
"total": len(part_countries),
}
@router.put(
"/{part_country_id}",
response_model=PartCountryResponseDTO,
summary="Update part-country relationship",
)
async def update_part_country(
part_country_id: int,
part_country_data: PartCountryUpdateDTO,
db: Session = Depends(get_core_db),
):
"""Update a part-country relationship"""
part_country = PartCountryService.update(db, part_country_id, part_country_data)
if not part_country:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Part-country relationship not found",
)
return PartCountryResponseDTO.model_validate(part_country)
@router.delete(
"/{part_country_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete part-country relationship",
)
async def delete_part_country(
part_country_id: int,
db: Session = Depends(get_core_db),
):
"""Delete a part-country relationship"""
success = PartCountryService.delete(db, part_country_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Part-country relationship not found",
)

View File

@@ -0,0 +1,186 @@
"""
Capa de servicio para lógica de negocio de relación entre partes y países
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import PartCountryCreateDTO, PartCountryResponseDTO, PartCountryUpdateDTO
from .models import PartCountry
logger = logging.getLogger(__name__)
class PartCountryService:
"""Servicio para gestión de relaciones entre partes y países"""
@staticmethod
def get_all(
db: Session,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[PartCountry], int]:
"""Get all part-country relationships with pagination"""
query = db.query(PartCountry)
if filters:
if filters.get("part_id"):
query = query.filter(PartCountry.part_id == filters["part_id"])
if filters.get("country_code"):
query = query.filter(
PartCountry.country_code.ilike(f"%{filters['country_code']}%")
)
if filters.get("preference"):
query = query.filter(PartCountry.preference == filters["preference"])
total = query.count()
part_countries = query.offset(skip).limit(limit).all()
return part_countries, total
@staticmethod
def get_by_id(db: Session, part_country_id: int) -> Optional[PartCountry]:
"""Get part-country relationship by ID"""
return db.query(PartCountry).filter(PartCountry.id == part_country_id).first()
@staticmethod
def get_by_part_id(db: Session, part_id: int) -> List[PartCountry]:
"""Get all countries for a specific part"""
return db.query(PartCountry).filter(PartCountry.part_id == part_id).all()
@staticmethod
def get_by_part_and_country(db: Session, part_id: int, country_code: str) -> Optional[PartCountry]:
"""Get specific part-country relationship"""
return db.query(PartCountry).filter(
PartCountry.part_id == part_id,
PartCountry.country_code == country_code
).first()
@staticmethod
def create(db: Session, part_country_data: PartCountryCreateDTO) -> PartCountry:
"""Create a new part-country relationship"""
try:
db_part_country = PartCountry(
**part_country_data.model_dump(exclude_unset=True)
)
db.add(db_part_country)
db.commit()
db.refresh(db_part_country)
return db_part_country
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating part-country relationship: {str(e)}")
raise HTTPException(
status_code=400,
detail="Relationship already exists or invalid foreign key",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating part-country relationship: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error creating relationship: {str(e)}",
)
@staticmethod
def update(
db: Session, part_country_id: int, part_country_data: PartCountryUpdateDTO
) -> Optional[PartCountry]:
"""Update part-country relationship"""
try:
db_part_country = db.query(PartCountry).filter(
PartCountry.id == part_country_id
).first()
if not db_part_country:
return None
update_data = part_country_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_part_country, field, value)
db.add(db_part_country)
db.commit()
db.refresh(db_part_country)
return db_part_country
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating part-country relationship: {str(e)}")
raise HTTPException(
status_code=400,
detail="Cannot update: constraint violation",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating part-country relationship: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error updating relationship: {str(e)}",
)
@staticmethod
def delete(db: Session, part_country_id: int) -> bool:
"""Delete part-country relationship"""
try:
db_part_country = db.query(PartCountry).filter(
PartCountry.id == part_country_id
).first()
if not db_part_country:
return False
db.delete(db_part_country)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting part-country relationship: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error deleting relationship: {str(e)}",
)
@staticmethod
def bulk_create(db: Session, part_id: int, countries_data: List[PartCountryCreateDTO]) -> List[PartCountry]:
"""Create multiple part-country relationships for a part"""
try:
# Delete existing relationships for this part
db.query(PartCountry).filter(PartCountry.part_id == part_id).delete()
db.commit()
# Create new relationships
db_part_countries = []
for country_data in countries_data:
country_data.part_id = part_id
db_part_country = PartCountry(
**country_data.model_dump(exclude_unset=True)
)
db_part_countries.append(db_part_country)
db.add_all(db_part_countries)
db.commit()
for pc in db_part_countries:
db.refresh(pc)
return db_part_countries
except Exception as e:
db.rollback()
logger.error(f"Error bulk creating part-country relationships: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error creating relationships: {str(e)}",
)