Merge remote-tracking branch 'origin/catalogos_a76' into catalogos-frontend

This commit is contained in:
2025-11-06 20:20:18 -06:00
26 changed files with 3902 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
"""
Módulo de GClass
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,97 @@
"""
DTOs (Data Transfer Objects) para módulo de clases SCAII y SCAF
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class ClassCreateDTO(BaseModel):
"""DTO para crear una clase"""
client_key: int = Field(..., description="Client key")
class_code: str = Field(..., max_length=8, description="Class code")
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")
material_key: Optional[str] = Field(None, max_length=10, description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)")
unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)")
fraction: Optional[str] = Field(None, max_length=10, description="Mexican tariff fraction")
us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction")
sub_key: Optional[str] = Field(None, max_length=5, description="Sub classification key")
physical_review: Optional[int] = Field(None, description="Physical review indicator")
iva_exempt_fraction: Optional[str] = Field(None, max_length=4, description="IVA exempt fraction")
class Config:
from_attributes = True
class ClassUpdateDTO(BaseModel):
"""DTO para actualizar una clase"""
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")
material_key: Optional[str] = Field(None, max_length=10, description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)")
unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)")
fraction: Optional[str] = Field(None, max_length=10, description="Mexican tariff fraction")
us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction")
sub_key: Optional[str] = Field(None, max_length=5, description="Sub classification key")
physical_review: Optional[int] = Field(None, description="Physical review indicator")
iva_exempt_fraction: Optional[str] = Field(None, max_length=4, description="IVA exempt fraction")
class Config:
from_attributes = True
class ClassResponseDTO(BaseModel):
"""DTO para respuesta de clase"""
client_key: int
class_code: str
description_spanish: Optional[str] = None
description_english: Optional[str] = None
material_key: Optional[str] = None
unit_of_measure: Optional[str] = None
fraction: Optional[str] = None
us_fraction: Optional[str] = None
sub_key: Optional[str] = None
physical_review: Optional[int] = None
iva_exempt_fraction: Optional[str] = None
class Config:
from_attributes = True
class ClassBasicDTO(BaseModel):
"""DTO para información básica de clase"""
client_key: int
class_code: str
description_spanish: Optional[str] = None
description_english: Optional[str] = None
material_key: Optional[str] = None
fraction: Optional[str] = None
class Config:
from_attributes = True
class ClassListDTO(BaseModel):
"""DTO para lista de clases"""
classes: list[ClassBasicDTO]
total: int
page: int
size: int
class Config:
from_attributes = True
class ClassSearchDTO(BaseModel):
"""DTO para búsqueda de clases"""
client_key: Optional[int] = Field(None, description="Filter by client key")
class_code: Optional[str] = Field(None, description="Search by class code")
description: Optional[str] = Field(None, description="Search in descriptions")
material_key: Optional[str] = Field(None, description="Filter by material key")
fraction: Optional[str] = Field(None, description="Filter by tariff fraction")
physical_review: Optional[int] = Field(None, description="Filter by physical review indicator")
class Config:
from_attributes = True

View File

@@ -0,0 +1,61 @@
"""
Modelos ORM para gestión de clases SCAII y SCAF
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Numeric, SmallInteger, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from core.database import Base
import enum
# Importar modelos relacionados para type hints y relationships
from typing import TYPE_CHECKING, List, Optional
if TYPE_CHECKING:
from api.v1.modules.a76.GParts.models import GPart
from api.v1.modules.public.reference_data.material_types.models import MaterialType
class GClass(Base):
"""
Modelo para la tabla GClases - Información de clases en sistemas SCAII y SCAF
"""
__tablename__ = "gclasses"
__table_args__ = {"schema": "a76"}
# Primary key compuesta
client_key = Column(Integer, primary_key=True, nullable=False)
class_code = Column(String(8), primary_key=True, nullable=False)
# Basic information
description_spanish = Column(String(500), nullable=True)
description_english = Column(String(500), nullable=True)
# Material and measurement
material_key = Column(String(10), ForeignKey('public.material_types.key'), nullable=True) # CLAVEMAT - homologated from TIPOMAT/TIPOMATEQUIPO
unit_of_measure = Column(String(5), nullable=True) # UNIMED - homologated from UNIMEDIDA
# Tariff fractions
fraction = Column(String(10), nullable=True) # Mexican tariff fraction
us_fraction = Column(String(16), nullable=True) # FRACCIONAME - US tariff fraction
# Additional classification
sub_key = Column(String(5), nullable=True) # CLAVESUB
physical_review = Column(SmallInteger, nullable=True) # REVFISICA
iva_exempt_fraction = Column(String(4), nullable=True) # FRACCIONEXENTAIVA
# Relationships
material_type = relationship("MaterialType", foreign_keys=[material_key])
# Inverse relationship with GParts that have this class
parts = relationship(
"GPart",
primaryjoin="and_(GClass.client_key == GPart.client_key, GClass.class_code == GPart.part_class)",
foreign_keys="[GPart.client_key, GPart.part_class]",
viewonly=True,
back_populates="part_class_info"
)
def __repr__(self):
return f"<GClass(client_key={self.client_key}, class_code='{self.class_code}', description='{self.description_spanish}')>"

View File

@@ -0,0 +1,261 @@
"""
Endpoints API para gestión de clases SCAII y SCAF
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from typing import List, Optional
from core.database import get_core_db
from core.security import get_current_user, has_role
from .service import ClassService
from .dto import (
ClassCreateDTO,
ClassUpdateDTO,
ClassResponseDTO,
ClassBasicDTO,
ClassListDTO,
ClassSearchDTO
)
router = APIRouter(prefix="/classes", tags=["Classes"])
@router.post("/", response_model=ClassResponseDTO, status_code=status.HTTP_201_CREATED)
async def create_class(
class_data: ClassCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Create a new class in the system
"""
service = ClassService(db)
return service.create_class(class_data)
@router.get("/", response_model=ClassListDTO)
async def list_classes(
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_key: Optional[int] = Query(None, description="Filter by client key"),
class_code: Optional[str] = Query(None, description="Search by class code"),
description: Optional[str] = Query(None, description="Search in descriptions"),
material_key: Optional[str] = Query(None, description="Filter by material key"),
fraction: Optional[str] = Query(None, description="Filter by tariff fraction"),
physical_review: Optional[int] = Query(None, description="Filter by physical review indicator"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
List classes with optional filters and pagination
"""
service = ClassService(db)
search_params = ClassSearchDTO(
client_key=client_key,
class_code=class_code,
description=description,
material_key=material_key,
fraction=fraction,
physical_review=physical_review
)
return service.list_classes(skip, limit, search_params)
@router.get("/client/{client_key}", response_model=List[ClassBasicDTO])
async def get_classes_by_client(
client_key: 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 classes for a specific client
"""
service = ClassService(db)
return service.search_by_client(client_key, skip, limit)
@router.get("/search/fraction/{fraction}", response_model=List[ClassBasicDTO])
async def search_by_fraction(
fraction: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Search classes by tariff fraction
"""
service = ClassService(db)
return service.search_by_fraction(fraction)
@router.get("/search/material/{material_key}", response_model=List[ClassBasicDTO])
async def search_by_material(
material_key: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Search classes by material key
"""
service = ClassService(db)
return service.search_by_material(material_key)
@router.get("/search/unit-measure/{unit_of_measure}", response_model=List[ClassBasicDTO])
async def get_classes_by_unit_measure(
unit_of_measure: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get classes by unit of measure
"""
service = ClassService(db)
return service.get_classes_by_unit_measure(unit_of_measure)
@router.get("/search/physical-review/{physical_review}", response_model=List[ClassBasicDTO])
async def get_classes_by_physical_review(
physical_review: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get classes by physical review indicator
"""
service = ClassService(db)
return service.get_classes_by_physical_review(physical_review)
@router.get("/statistics", response_model=dict)
async def get_classes_statistics(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get basic classes statistics
"""
service = ClassService(db)
return service.get_classes_statistics()
@router.get("/{client_key}/{class_code}", response_model=ClassResponseDTO)
async def get_class(
client_key: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get class by composite key (client_key + class_code)
"""
service = ClassService(db)
class_obj = service.get_class(client_key, class_code)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
)
return class_obj
@router.put("/{client_key}/{class_code}", response_model=ClassResponseDTO)
async def update_class(
client_key: int,
class_code: str,
class_data: ClassUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Update class information
"""
service = ClassService(db)
class_obj = service.update_class(client_key, class_code, class_data)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
)
return class_obj
@router.delete("/{client_key}/{class_code}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_class(
client_key: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Delete class from the system
Note: This will completely remove the class from the system.
"""
service = ClassService(db)
if not service.delete_class(client_key, class_code):
raise HTTPException(
status_code=404,
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
)
# Endpoints específicos para información detallada
@router.get("/{client_key}/{class_code}/basic", response_model=ClassBasicDTO)
async def get_class_basic_info(
client_key: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get basic information for a class
"""
service = ClassService(db)
class_obj = service.get_class(client_key, class_code)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
)
return ClassBasicDTO(
client_key=class_obj.client_key,
class_code=class_obj.class_code,
description_spanish=class_obj.description_spanish,
description_english=class_obj.description_english,
material_key=class_obj.material_key,
fraction=class_obj.fraction
)
@router.get("/{client_key}/{class_code}/tariff", response_model=dict)
async def get_class_tariff_info(
client_key: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get tariff information for a class (fractions, IVA exempt, etc.)
"""
service = ClassService(db)
class_obj = service.get_class(client_key, class_code)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
)
return {
"client_key": class_obj.client_key,
"class_code": class_obj.class_code,
"fraction": class_obj.fraction,
"us_fraction": class_obj.us_fraction,
"iva_exempt_fraction": class_obj.iva_exempt_fraction,
"sub_key": class_obj.sub_key,
"physical_review": class_obj.physical_review
}

View File

@@ -0,0 +1,294 @@
"""
Capa de servicio para lógica de negocio de clases SCAII y SCAF
"""
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_, and_, func
from fastapi import HTTPException
from typing import List, Optional
import logging
from .models import GClass
from .dto import (
ClassCreateDTO,
ClassUpdateDTO,
ClassResponseDTO,
ClassBasicDTO,
ClassListDTO,
ClassSearchDTO
)
logger = logging.getLogger(__name__)
class ClassService:
"""Servicio para gestión de clases SCAII y SCAF"""
def __init__(self, db: Session):
self.db = db
def create_class(self, class_data: ClassCreateDTO) -> ClassResponseDTO:
"""
Crea una nueva clase en el sistema
Args:
class_data: Datos de la clase a crear
Returns:
ClassResponseDTO con información de la clase creada
Raises:
HTTPException: Si la clase ya existe o error en la creación
"""
try:
# Verificar que no exista la clase
existing = self.db.query(GClass).filter(
and_(
GClass.client_key == class_data.client_key,
GClass.class_code == class_data.class_code
)
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"Class with client_key '{class_data.client_key}' and class_code '{class_data.class_code}' already exists"
)
# Crear clase
db_class = GClass(
client_key=class_data.client_key,
class_code=class_data.class_code,
description_spanish=class_data.description_spanish,
description_english=class_data.description_english,
material_key=class_data.material_key,
unit_of_measure=class_data.unit_of_measure,
fraction=class_data.fraction,
us_fraction=class_data.us_fraction,
sub_key=class_data.sub_key,
physical_review=class_data.physical_review,
iva_exempt_fraction=class_data.iva_exempt_fraction
)
self.db.add(db_class)
self.db.commit()
self.db.refresh(db_class)
logger.info(f"Class created: {db_class.client_key}-{db_class.class_code}")
return ClassResponseDTO.model_validate(db_class)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating class: {str(e)}")
raise HTTPException(status_code=400, detail="Class with this client_key and class_code already exists")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating class: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating class")
def get_class(self, client_key: int, class_code: str) -> Optional[ClassResponseDTO]:
"""
Obtiene una clase por clave compuesta
Args:
client_key: Clave del cliente
class_code: Código de clase
Returns:
ClassResponseDTO o None si no existe
"""
class_obj = self.db.query(GClass).filter(
and_(
GClass.client_key == client_key,
GClass.class_code == class_code
)
).first()
if not class_obj:
return None
return ClassResponseDTO.model_validate(class_obj)
def list_classes(
self,
skip: int = 0,
limit: int = 100,
search_params: Optional[ClassSearchDTO] = None
) -> ClassListDTO:
"""
Lista clases con filtros
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
search_params: Parámetros de búsqueda
Returns:
ClassListDTO con la lista paginada
"""
query = self.db.query(GClass)
# Aplicar filtros si se proporcionan
if search_params:
if search_params.client_key:
query = query.filter(GClass.client_key == search_params.client_key)
if search_params.class_code:
query = query.filter(GClass.class_code.ilike(f"%{search_params.class_code}%"))
if search_params.description:
description_pattern = f"%{search_params.description}%"
query = query.filter(
or_(
GClass.description_spanish.ilike(description_pattern),
GClass.description_english.ilike(description_pattern)
)
)
if search_params.material_key:
query = query.filter(GClass.material_key.ilike(f"%{search_params.material_key}%"))
if search_params.fraction:
query = query.filter(GClass.fraction.ilike(f"%{search_params.fraction}%"))
if search_params.physical_review is not None:
query = query.filter(GClass.physical_review == search_params.physical_review)
# Contar total
total = query.count()
# Aplicar paginación
classes = query.offset(skip).limit(limit).all()
# Convertir a DTOs básicos
class_dtos = [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
return ClassListDTO(
classes=class_dtos,
total=total,
page=(skip // limit) + 1 if limit > 0 else 1,
size=len(class_dtos)
)
def update_class(self, client_key: int, class_code: str, class_data: ClassUpdateDTO) -> Optional[ClassResponseDTO]:
"""
Actualiza una clase
Args:
client_key: Clave del cliente
class_code: Código de clase
class_data: Datos a actualizar
Returns:
ClassResponseDTO actualizado o None si no existe
"""
class_obj = self.db.query(GClass).filter(
and_(
GClass.client_key == client_key,
GClass.class_code == class_code
)
).first()
if not class_obj:
return None
try:
# Actualizar solo campos proporcionados
update_data = class_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(class_obj, field, value)
self.db.commit()
self.db.refresh(class_obj)
logger.info(f"Class updated: {client_key}-{class_code}")
return ClassResponseDTO.model_validate(class_obj)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating class {client_key}-{class_code}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating class")
def delete_class(self, client_key: int, class_code: str) -> bool:
"""
Elimina una clase
Args:
client_key: Clave del cliente
class_code: Código de clase
Returns:
True si se eliminó, False si no existe
"""
class_obj = self.db.query(GClass).filter(
and_(
GClass.client_key == client_key,
GClass.class_code == class_code
)
).first()
if not class_obj:
return False
try:
self.db.delete(class_obj)
self.db.commit()
logger.info(f"Class deleted: {client_key}-{class_code}")
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting class {client_key}-{class_code}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting class")
def search_by_fraction(self, fraction: str) -> List[ClassBasicDTO]:
"""Busca clases por fracción arancelaria"""
classes = self.db.query(GClass).filter(GClass.fraction.ilike(f"%{fraction}%")).all()
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def search_by_client(self, client_key: int, skip: int = 0, limit: int = 100) -> List[ClassBasicDTO]:
"""Obtiene todas las clases de un cliente específico"""
classes = self.db.query(GClass).filter(GClass.client_key == client_key).offset(skip).limit(limit).all()
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def search_by_material(self, material_key: str) -> List[ClassBasicDTO]:
"""Busca clases por clave de material"""
classes = self.db.query(GClass).filter(GClass.material_key.ilike(f"%{material_key}%")).all()
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def get_classes_by_physical_review(self, physical_review: int) -> List[ClassBasicDTO]:
"""Obtiene clases por indicador de revisión física"""
classes = self.db.query(GClass).filter(GClass.physical_review == physical_review).all()
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def get_classes_statistics(self) -> dict:
"""Obtiene estadísticas básicas de clases"""
total_classes = self.db.query(GClass).count()
# Contar por clientes
clients_count = self.db.query(GClass.client_key).distinct().count()
# Contar por revisión física
physical_review_stats = {}
for i in range(3): # Asumiendo valores 0, 1, 2
count = self.db.query(GClass).filter(GClass.physical_review == i).count()
physical_review_stats[f"physical_review_{i}"] = count
# Contar clases con fracciones
with_fraction = self.db.query(GClass).filter(GClass.fraction.isnot(None)).count()
with_us_fraction = self.db.query(GClass).filter(GClass.us_fraction.isnot(None)).count()
return {
"total_classes": total_classes,
"clients_with_classes": clients_count,
"classes_with_fraction": with_fraction,
"classes_with_us_fraction": with_us_fraction,
**physical_review_stats
}
def get_classes_by_unit_measure(self, unit_of_measure: str) -> List[ClassBasicDTO]:
"""Obtiene clases por unidad de medida"""
classes = self.db.query(GClass).filter(GClass.unit_of_measure == unit_of_measure).all()
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]

View File

@@ -0,0 +1,6 @@
"""
Módulo de GParts
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,182 @@
"""
DTOs (Data Transfer Objects) para módulo de partes/componentes
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from decimal import Decimal
class PartCreateDTO(BaseModel):
"""DTO para crear una parte"""
client_key: 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
enabled_disabled: Optional[int] = 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
enabled_disabled: Optional[int] = 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"""
client_key: int
part_number: str
fraction: 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_cost: Optional[Decimal] = None
currency_type: Optional[str] = None
currency_key: Optional[str] = None
# Weight information
unit_weight: Optional[Decimal] = None
weight_type: Optional[str] = None
# Classification and regulatory
us_fraction: Optional[str] = None
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
enabled_disabled: Optional[int] = None
creation_date: Optional[int] = None
modification_date: Optional[int] = None
modification_date_iso: Optional[datetime] = None
# Media
part_photo: Optional[str] = None
class Config:
from_attributes = True
class PartBasicDTO(BaseModel):
"""DTO para información básica de parte"""
client_key: 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
enabled_disabled: Optional[int] = None
class Config:
from_attributes = True
class PartListDTO(BaseModel):
"""DTO para lista de partes"""
parts: list[PartBasicDTO]
total: int
page: int
size: int
class Config:
from_attributes = True
class PartSearchDTO(BaseModel):
"""DTO para búsqueda de partes"""
client_key: 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

@@ -0,0 +1,88 @@
"""
Modelos ORM para gestión de partes/componentes
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Numeric, SmallInteger, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from core.database import Base
import enum
# Importar modelos relacionados para type hints y relationships
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
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.GClass.models import GClass
class GPart(Base):
"""
Modelo para la tabla GPartes - Información de partes en los sistemas SCAII (N), SCAF (S) Y WINSAAI (W)
"""
__tablename__ = "gparts"
__table_args__ = {"schema": "a76"}
# Primary key compuesta
client_key = Column(Integer, primary_key=True, nullable=False)
part_number = Column(String(49), primary_key=True, nullable=False)
# Basic information
fraction = Column(String(10), nullable=True)
description_spanish = Column(String(500), nullable=True)
description_english = Column(String(500), nullable=True)
part_class = Column(String(8), nullable=True)
unit_of_measure = Column(String(5), nullable=True)
commercial_part_number = Column(String(70), nullable=True)
country_of_origin = Column(String(3), ForeignKey('public.countries.m3_key'), nullable=True)
# Pricing and currency
unit_cost = Column(Numeric(23, 8), nullable=True)
currency_type = Column(String(2), nullable=True)
currency_key = Column(String(3), ForeignKey('public.currency_types.code'), nullable=True)
# Weight information
unit_weight = Column(Numeric(19, 8), nullable=True)
weight_type = Column(String(6), nullable=True)
# Classification and regulatory
us_fraction = Column(String(16), nullable=True) # FRACCIONAME
fda_key = Column(String(20), nullable=True)
fcc_key = Column(String(30), nullable=True)
license_code = Column(String(3), nullable=True)
eccn = Column(String(20), nullable=True) # Export Control Classification Number
export_code = Column(String(2), nullable=True)
exclusion_symbol = Column(String(19), nullable=True) # SIMBOLOEXCLIC
# Additional information
supplier = Column(String(14), nullable=True)
alternate_unit_measure = Column(String(14), nullable=True)
added_value = Column(Numeric(23, 8), nullable=True)
# Status and dates
enabled_disabled = Column(SmallInteger, nullable=True)
creation_date = Column(Integer, nullable=True) # FECHACREACIONPARTE
modification_date = Column(Integer, nullable=True) # FECHAMODIFICA
modification_date_iso = Column(DateTime(timezone=True), nullable=True) # FECHAMODIFICA_ISO
# Media
part_photo = Column(String(255), nullable=True)
# Relationships
country = relationship("Country", foreign_keys=[country_of_origin])
currency = relationship("CurrencyType", foreign_keys=[currency_key])
# Relationship with GClass through composite foreign key
# Note: This requires both client_key and part_class to match client_key and class_code in GClass
part_class_info = relationship(
"GClass",
primaryjoin="and_(GPart.client_key == GClass.client_key, GPart.part_class == GClass.class_code)",
foreign_keys="[GPart.client_key, GPart.part_class]",
viewonly=True,
back_populates="parts"
)
def __repr__(self):
return f"<GPart(client_key={self.client_key}, part_number='{self.part_number}', description='{self.description_spanish}')>"

View File

@@ -0,0 +1,273 @@
"""
Endpoints API para gestión de partes/componentes
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from typing import List, Optional
from core.database import get_core_db
from core.security import get_current_user, has_role
from .service import PartService
from .dto import (
PartCreateDTO,
PartUpdateDTO,
PartResponseDTO,
PartBasicDTO,
PartListDTO,
PartSearchDTO
)
router = APIRouter(prefix="/parts", tags=["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
"""
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_key: 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
"""
service = PartService(db)
search_params = PartSearchDTO(
client_key=client_key,
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_key}", response_model=List[PartBasicDTO])
async def get_parts_by_client(
client_key: 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
"""
service = PartService(db)
return service.search_by_client(client_key, 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
"""
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
"""
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
"""
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
"""
service = PartService(db)
return service.get_parts_statistics()
@router.get("/{client_key}/{part_number}", response_model=PartResponseDTO)
async def get_part(
client_key: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get part by composite key (client_key + part_number)
"""
service = PartService(db)
part = service.get_part(client_key, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
)
return part
@router.put("/{client_key}/{part_number}", response_model=PartResponseDTO)
async def update_part(
client_key: int,
part_number: str,
part_data: PartUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Update part information
"""
service = PartService(db)
part = service.update_part(client_key, part_number, part_data)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
)
return part
@router.delete("/{client_key}/{part_number}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part(
client_key: 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.
"""
service = PartService(db)
if not service.delete_part(client_key, part_number):
raise HTTPException(
status_code=404,
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
)
@router.patch("/{client_key}/{part_number}/toggle-status", response_model=PartResponseDTO)
async def toggle_part_status(
client_key: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Toggle part enabled/disabled status
"""
service = PartService(db)
part = service.toggle_status(client_key, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
)
return part
# Endpoints específicos para información detallada
@router.get("/{client_key}/{part_number}/basic", response_model=PartBasicDTO)
async def get_part_basic_info(
client_key: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get basic information for a part
"""
service = PartService(db)
part = service.get_part(client_key, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
)
return PartBasicDTO(
client_key=part.client_key,
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,
enabled_disabled=part.enabled_disabled
)
@router.get("/{client_key}/{part_number}/regulatory", response_model=dict)
async def get_part_regulatory_info(
client_key: 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.)
"""
service = PartService(db)
part = service.get_part(client_key, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
)
return {
"client_key": part.client_key,
"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
}

View File

@@ -0,0 +1,275 @@
"""
Capa de servicio para lógica de negocio de partes/componentes
"""
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_, and_, func
from fastapi import HTTPException
from typing import List, Optional
import logging
from datetime import datetime
from .models import GPart
from .dto import PartCreateDTO, PartUpdateDTO
logger = logging.getLogger(__name__)
class PartService:
"""
Servicio para gestión de partes/componentes
"""
@staticmethod
def create_part(db: Session, part_data: PartCreateDTO) -> GPart:
"""
Crear una nueva parte
"""
try:
db_part = GPart(**part_data.model_dump())
db.add(db_part)
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_key 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")
@staticmethod
def get_part(db: Session, client_key: int, part_number: str) -> Optional[GPart]:
"""
Obtener una parte por clave de cliente y número de parte
"""
try:
return db.query(GPart).filter(
and_(
GPart.client_key == client_key,
GPart.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_key: Optional[int] = None,
fraction: Optional[str] = None,
country_of_origin: Optional[str] = None
) -> tuple[List[GPart], int]:
"""
Obtener partes con paginación y filtros
"""
try:
query = db.query(GPart)
# Aplicar filtros
if search:
query = query.filter(or_(
GPart.description_spanish.ilike(f"%{search}%"),
GPart.description_english.ilike(f"%{search}%"),
GPart.part_number.ilike(f"%{search}%")
))
if client_key is not None:
query = query.filter(GPart.client_key == client_key)
if fraction:
query = query.filter(GPart.fraction == fraction)
if country_of_origin:
query = query.filter(GPart.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_key: int) -> List[GPart]:
"""
Obtener todas las partes de un cliente específico
"""
try:
return db.query(GPart).filter(GPart.client_key == client_key).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[GPart]:
"""
Buscar partes por fracción arancelaria
"""
try:
return db.query(GPart).filter(
or_(
GPart.fraction.ilike(f"%{fraction}%"),
GPart.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[GPart]:
"""
Buscar partes por proveedor
"""
try:
return db.query(GPart).filter(GPart.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[GPart]:
"""
Buscar partes por país de origen
"""
try:
return db.query(GPart).filter(GPart.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_key: int, part_number: str, part_data: PartUpdateDTO) -> Optional[GPart]:
"""
Actualizar una parte existente
"""
try:
db_part = PartService.get_part(db, client_key, 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_key: int, part_number: str) -> bool:
"""
Eliminar una parte
"""
try:
db_part = PartService.get_part(db, client_key, part_number)
if not db_part:
return False
db.delete(db_part)
db.commit()
return True
except Exception as e:
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_key: int, part_number: str) -> Optional[GPart]:
"""
Cambiar el estado habilitado/deshabilitado de una parte
"""
try:
db_part = PartService.get_part(db, client_key, part_number)
if not db_part:
return None
# Toggle status (assuming 1 = enabled, 0 = disabled)
db_part.enabled_disabled = 1 if db_part.enabled_disabled == 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(GPart).count()
# Partes por cliente
parts_by_client = db.query(
GPart.client_key,
func.count(GPart.part_number).label('count')
).group_by(GPart.client_key).all()
# Partes por país de origen
parts_by_country = db.query(
GPart.country_of_origin,
func.count(GPart.part_number).label('count')
).filter(GPart.country_of_origin.isnot(None))\
.group_by(GPart.country_of_origin).all()
# Partes habilitadas vs deshabilitadas
enabled_parts = db.query(GPart).filter(GPart.enabled_disabled == 1).count()
disabled_parts = db.query(GPart).filter(GPart.enabled_disabled == 0).count()
return {
"total_parts": total_parts,
"enabled_parts": enabled_parts,
"disabled_parts": disabled_parts,
"parts_by_client": [{"client_key": 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_key: int, part_number: str) -> Optional[dict]:
"""
Obtener información regulatoria específica de una parte
"""
try:
db_part = PartService.get_part(db, client_key, part_number)
if not db_part:
return None
return {
"client_key": db_part.client_key,
"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")

View File

@@ -0,0 +1,6 @@
"""
Módulo de Client & Provider
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,165 @@
"""
DTOs (Data Transfer Objects) para módulo de clientes y proveedores
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
from decimal import Decimal
# DTOs para dirección
class ClientProviderAddressDTO(BaseModel):
"""DTO para dirección de cliente/proveedor"""
municipality: Optional[str] = Field(None, max_length=150, description="Municipality")
streets: Optional[str] = Field(None, max_length=100, description="Streets")
neighborhood: Optional[str] = Field(None, max_length=40, description="Neighborhood")
interior_number: Optional[str] = Field(None, max_length=20, description="Interior number")
exterior_number: Optional[str] = Field(None, max_length=20, description="Exterior number")
postal_code: Optional[str] = Field(None, max_length=15, description="Postal code")
city: Optional[str] = Field(None, max_length=30, description="City")
state: Optional[str] = Field(None, max_length=30, description="State")
country: Optional[str] = Field(None, max_length=3, description="Country code")
phone: Optional[str] = Field(None, max_length=30, description="Phone number")
fax_number: Optional[str] = Field(None, max_length=30, description="Fax number")
email: Optional[str] = Field(None, max_length=100, description="Email address")
contact: Optional[str] = Field(None, max_length=50, description="Contact person")
reference: Optional[str] = Field(None, max_length=250, description="Reference")
class Config:
from_attributes = True
# DTOs para programas
class ClientProviderProgramsDTO(BaseModel):
"""DTO para programas de cliente/proveedor"""
program: Optional[str] = Field(None, max_length=7, description="Program")
program_number: Optional[str] = Field(None, max_length=40, description="Program number")
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(None, max_length=20, description="PROSEC authorization")
secon_auth_date: Optional[int] = Field(None, description="SECON authorization date")
manufacturer_id: Optional[str] = Field(None, max_length=25, description="Manufacturer ID")
tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID")
broker: Optional[str] = Field(None, max_length=6, description="Broker")
import_broker: Optional[str] = Field(None, max_length=6, description="Import broker")
transfer_key: Optional[str] = Field(None, max_length=8, description="Transfer key")
secon_authorization: Optional[str] = Field(None, max_length=20, description="SECON authorization")
applied_proportion: Optional[Decimal] = Field(None, description="Applied proportion")
is_certified_company: Optional[str] = Field(None, max_length=1, description="Is certified company")
certified_company_registry: Optional[str] = Field(None, max_length=40, description="Certified company registry")
donation_auth_number: Optional[str] = Field(None, max_length=50, description="Donation authorization number")
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
tax_registry_number: Optional[str] = Field(None, max_length=40, description="Tax registry number")
subassembly_service: Optional[int] = Field(None, description="Subassembly service")
autse_dates: Optional[int] = Field(None, description="AUTSE dates")
autse_number: Optional[str] = Field(None, max_length=300, description="AUTSE number")
class Config:
from_attributes = True
# DTOs principales
class ClientProviderCreateDTO(BaseModel):
"""DTO para crear cliente/proveedor"""
client_id: str = Field(..., max_length=8, description="Client ID")
type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign")
name: Optional[str] = Field(None, max_length=256, description="Name")
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider")
linking: Optional[str] = Field(None, max_length=1, description="Linking")
transform_subassembly: Optional[str] = Field(None, max_length=1, description="Transform subassembly")
extra_information: Optional[str] = Field(None, max_length=399, description="Extra information")
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
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")
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information")
programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information")
class Config:
from_attributes = True
class ClientProviderUpdateDTO(BaseModel):
"""DTO para actualizar cliente/proveedor"""
type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign")
name: Optional[str] = Field(None, max_length=256, description="Name")
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider")
linking: Optional[str] = Field(None, max_length=1, description="Linking")
transform_subassembly: Optional[str] = Field(None, max_length=1, description="Transform subassembly")
extra_information: Optional[str] = Field(None, max_length=399, description="Extra information")
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
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")
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information")
programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information")
class Config:
from_attributes = True
class ClientProviderResponseDTO(BaseModel):
"""DTO para respuesta de cliente/proveedor"""
client_id: str
type_nat_foreign: Optional[str] = None
name: Optional[str] = None
short_name: Optional[str] = None
rfc: Optional[str] = None
curp: Optional[str] = None
client_or_provider: Optional[str] = None
linking: Optional[str] = None
transform_subassembly: Optional[str] = None
extra_information: Optional[str] = None
web_key: Optional[str] = None
responsible: Optional[str] = None
position: Optional[str] = None
incoterm: Optional[str] = None
is_national_provider: Optional[str] = None
enabled_disabled: Optional[int] = None
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = None
programs: Optional[ClientProviderProgramsDTO] = None
class Config:
from_attributes = True
# DTOs para respuestas específicas
class ClientProviderBasicDTO(BaseModel):
"""DTO para información básica de cliente/proveedor"""
client_id: str
name: Optional[str] = None
short_name: Optional[str] = None
rfc: Optional[str] = None
client_or_provider: Optional[str] = None
enabled_disabled: Optional[int] = None
class Config:
from_attributes = True
class ClientProviderListDTO(BaseModel):
"""DTO para lista de clientes/proveedores"""
clients: list[ClientProviderBasicDTO]
total: int
page: int
size: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,108 @@
"""
Modelos ORM para gestión de clientes y proveedores
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, SmallInteger, Numeric, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from core.database import Base
import enum
class GClientProvider(Base):
"""
Modelo para la tabla GClientesPro - Información de clientes y proveedores
"""
__tablename__ = "gclient_provider"
__table_args__ = {"schema": "a76"}
# Primary key
client_id = Column(String(8), primary_key=True, nullable=False)
# Basic information
type_nat_foreign = Column(String(1), nullable=True) # TIPO NACIONAL/EXTRANJERO
name = Column(String(256), nullable=True)
short_name = Column(String(10), nullable=True)
rfc = Column(String(30), nullable=True)
curp = Column(String(19), nullable=True)
client_or_provider = Column(String(1), nullable=True)
linking = Column(String(1), nullable=True)
transform_subassembly = Column(String(1), nullable=True)
extra_information = Column(String(399), nullable=True)
web_key = Column(String(40), nullable=True)
responsible = Column(String(80), nullable=True)
position = Column(String(30), nullable=True)
incoterm = Column(String(19), nullable=True)
is_national_provider = Column(String(2), nullable=True)
enabled_disabled = Column(SmallInteger, nullable=True)
# Relationships
address = relationship("GClientProviderAddress", back_populates="client_provider", uselist=False, cascade="all, delete-orphan")
programs = relationship("GClientProviderPrograms", back_populates="client_provider", uselist=False, cascade="all, delete-orphan")
class GClientProviderAddress(Base):
"""
Modelo para la tabla GClientesPro_Direccion - Dirección de clientes y proveedores
"""
__tablename__ = "gclient_provider_address"
__table_args__ = {"schema": "a76"}
# Primary key (foreign key)
client_id = Column(String(8), ForeignKey('a76.gclient_provider.client_id', ondelete='CASCADE'), primary_key=True, nullable=False)
# Address information
municipality = Column(String(150), nullable=True)
streets = Column(String(100), nullable=True)
neighborhood = Column(String(40), nullable=True)
interior_number = Column(String(20), nullable=True)
exterior_number = Column(String(20), nullable=True)
postal_code = Column(String(15), nullable=True)
city = Column(String(30), nullable=True)
state = Column(String(30), nullable=True)
country = Column(String(3), nullable=True)
phone = Column(String(30), nullable=True)
fax_number = Column(String(30), nullable=True)
email = Column(String(100), nullable=True)
contact = Column(String(50), nullable=True)
reference = Column(String(250), nullable=True)
# Relationship
client_provider = relationship("GClientProvider", back_populates="address")
class GClientProviderPrograms(Base):
"""
Modelo para la tabla GClientesPro_Programas - Programas de clientes y proveedores
"""
__tablename__ = "gclient_provider_programs"
__table_args__ = {"schema": "a76"}
# Primary key (foreign key)
client_id = Column(String(8), ForeignKey('a76.gclient_provider.client_id', ondelete='CASCADE'), primary_key=True, nullable=False)
# Program information
program = Column(String(7), nullable=True)
program_number = Column(String(40), nullable=True)
prosec = Column(SmallInteger, nullable=True)
prosec_authorization = Column(String(20), nullable=True)
secon_auth_date = Column(Integer, nullable=True)
manufacturer_id = Column(String(25), nullable=True)
tax_id = Column(String(30), nullable=True)
broker = Column(String(6), nullable=True)
import_broker = Column(String(6), nullable=True)
transfer_key = Column(String(8), nullable=True)
secon_authorization = Column(String(20), nullable=True)
applied_proportion = Column(Numeric(7, 2), nullable=True)
is_certified_company = Column(String(1), nullable=True)
certified_company_registry = Column(String(40), nullable=True)
donation_auth_number = Column(String(50), nullable=True)
ctpat_svi = Column(String(100), nullable=True)
tax_registry_number = Column(String(40), nullable=True)
subassembly_service = Column(SmallInteger, nullable=True)
autse_dates = Column(Integer, nullable=True)
autse_number = Column(String(300), nullable=True)
# Relationship
client_provider = relationship("GClientProvider", back_populates="programs")

View File

@@ -0,0 +1,221 @@
"""
Endpoints API para gestión de clientes y proveedores
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from typing import List, Optional
from core.database import get_core_db
from core.security import get_current_user, has_role
from .service import ClientProviderService
from .dto import (
ClientProviderCreateDTO,
ClientProviderUpdateDTO,
ClientProviderResponseDTO,
ClientProviderBasicDTO,
ClientProviderListDTO
)
router = APIRouter(prefix="/clients-providers", tags=["Clients & Providers"])
@router.post("/", response_model=ClientProviderResponseDTO, status_code=status.HTTP_201_CREATED)
async def create_client_provider(
client_data: ClientProviderCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Create a new client or provider in the system
"""
service = ClientProviderService(db)
return service.create_client_provider(client_data)
@router.get("/", response_model=ClientProviderListDTO)
async def list_clients_providers(
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"),
search: Optional[str] = Query(None, description="Search text for name, RFC, or ID"),
client_or_provider: Optional[str] = Query(None, regex="^[CP]$", description="Filter by type: C=Client, P=Provider"),
enabled_only: bool = Query(False, description="Show only enabled records"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
List clients and providers with optional filters and pagination
"""
service = ClientProviderService(db)
return service.list_clients_providers(skip, limit, search, client_or_provider, enabled_only)
@router.get("/clients", response_model=List[ClientProviderBasicDTO])
async def get_clients_only(
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 only clients (client_or_provider = 'C')
"""
service = ClientProviderService(db)
return service.get_clients_only(skip, limit)
@router.get("/providers", response_model=List[ClientProviderBasicDTO])
async def get_providers_only(
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 only providers (client_or_provider = 'P')
"""
service = ClientProviderService(db)
return service.get_providers_only(skip, limit)
@router.get("/search/rfc/{rfc}", response_model=List[ClientProviderBasicDTO])
async def search_by_rfc(
rfc: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Search clients/providers by RFC
"""
service = ClientProviderService(db)
return service.search_by_rfc(rfc)
@router.get("/{client_id}", response_model=ClientProviderResponseDTO)
async def get_client_provider(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get client/provider by ID with all related information
"""
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return client
@router.put("/{client_id}", response_model=ClientProviderResponseDTO)
async def update_client_provider(
client_id: str,
client_data: ClientProviderUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Update client/provider information
"""
service = ClientProviderService(db)
client = service.update_client_provider(client_id, client_data)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return client
@router.delete("/{client_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_client_provider(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Delete client/provider from the system
Note: This will completely remove the client/provider and all related data.
"""
service = ClientProviderService(db)
if not service.delete_client_provider(client_id):
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
@router.patch("/{client_id}/toggle-status", response_model=ClientProviderResponseDTO)
async def toggle_client_provider_status(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Toggle client/provider enabled/disabled status
"""
service = ClientProviderService(db)
client = service.toggle_status(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return client
# Endpoints específicos para información detallada
@router.get("/{client_id}/address", response_model=dict)
async def get_client_provider_address(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get only address information for a client/provider
"""
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return {
"client_id": client.client_id,
"address": client.address
}
@router.get("/{client_id}/programs", response_model=dict)
async def get_client_provider_programs(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get only programs information for a client/provider
"""
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return {
"client_id": client.client_id,
"programs": client.programs
}
@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO)
async def get_client_provider_basic_info(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get basic information for a client/provider (without address and programs)
"""
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return ClientProviderBasicDTO(
client_id=client.client_id,
name=client.name,
short_name=client.short_name,
rfc=client.rfc,
client_or_provider=client.client_or_provider,
enabled_disabled=client.enabled_disabled
)

View File

@@ -0,0 +1,309 @@
"""
Capa de servicio para lógica de negocio de clientes y proveedores
"""
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_, and_
from fastapi import HTTPException
from typing import List, Optional
import logging
from .models import GClientProvider, GClientProviderAddress, GClientProviderPrograms
from .dto import (
ClientProviderCreateDTO,
ClientProviderUpdateDTO,
ClientProviderResponseDTO,
ClientProviderBasicDTO,
ClientProviderListDTO,
ClientProviderAddressDTO,
ClientProviderProgramsDTO
)
logger = logging.getLogger(__name__)
class ClientProviderService:
"""Servicio para gestión de clientes y proveedores"""
def __init__(self, db: Session):
self.db = db
def create_client_provider(self, client_data: ClientProviderCreateDTO) -> ClientProviderResponseDTO:
"""
Crea un nuevo cliente/proveedor en el sistema
Args:
client_data: Datos del cliente/proveedor a crear
Returns:
ClientProviderResponseDTO con información del cliente/proveedor creado
Raises:
HTTPException: Si el cliente ya existe o error en la creación
"""
try:
# Verificar que no exista el cliente
existing = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_data.client_id).first()
if existing:
raise HTTPException(status_code=400, detail=f"Client with ID '{client_data.client_id}' already exists")
# Crear cliente/proveedor principal
db_client = GClientProvider(
client_id=client_data.client_id,
type_nat_foreign=client_data.type_nat_foreign,
name=client_data.name,
short_name=client_data.short_name,
rfc=client_data.rfc,
curp=client_data.curp,
client_or_provider=client_data.client_or_provider,
linking=client_data.linking,
transform_subassembly=client_data.transform_subassembly,
extra_information=client_data.extra_information,
web_key=client_data.web_key,
responsible=client_data.responsible,
position=client_data.position,
incoterm=client_data.incoterm,
is_national_provider=client_data.is_national_provider,
enabled_disabled=client_data.enabled_disabled
)
self.db.add(db_client)
self.db.flush() # Para obtener el ID antes del commit
# Crear dirección si se proporciona
if client_data.address:
db_address = GClientProviderAddress(
client_id=client_data.client_id,
**client_data.address.model_dump(exclude_unset=True)
)
self.db.add(db_address)
# Crear programas si se proporciona
if client_data.programs:
db_programs = GClientProviderPrograms(
client_id=client_data.client_id,
**client_data.programs.model_dump(exclude_unset=True)
)
self.db.add(db_programs)
self.db.commit()
self.db.refresh(db_client)
logger.info(f"Client/Provider created: {db_client.client_id} - {db_client.name}")
return self._get_client_with_relations(client_data.client_id)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating client/provider: {str(e)}")
raise HTTPException(status_code=400, detail="Client/Provider with this ID already exists")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating client/provider: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating client/provider")
def get_client_provider(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
"""
Obtiene un cliente/proveedor por ID
Args:
client_id: ID del cliente/proveedor
Returns:
ClientProviderResponseDTO o None si no existe
"""
return self._get_client_with_relations(client_id)
def _get_client_with_relations(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
"""Método privado para obtener cliente con relaciones"""
client = self.db.query(GClientProvider).options(
joinedload(GClientProvider.address),
joinedload(GClientProvider.programs)
).filter(GClientProvider.client_id == client_id).first()
if not client:
return None
return ClientProviderResponseDTO.model_validate(client)
def list_clients_providers(
self,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
client_or_provider: Optional[str] = None,
enabled_only: bool = False
) -> ClientProviderListDTO:
"""
Lista clientes/proveedores con filtros
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
search: Texto de búsqueda (nombre, RFC, ID)
client_or_provider: Filtrar por tipo (C=Cliente, P=Proveedor)
enabled_only: Si True, solo retorna activos
Returns:
ClientProviderListDTO con la lista paginada
"""
query = self.db.query(GClientProvider)
# Aplicar filtros
if search:
search_pattern = f"%{search}%"
query = query.filter(
or_(
GClientProvider.name.ilike(search_pattern),
GClientProvider.short_name.ilike(search_pattern),
GClientProvider.rfc.ilike(search_pattern),
GClientProvider.client_id.ilike(search_pattern)
)
)
if client_or_provider:
query = query.filter(GClientProvider.client_or_provider == client_or_provider)
if enabled_only:
query = query.filter(GClientProvider.enabled_disabled == 1)
# Contar total
total = query.count()
# Aplicar paginación
clients = query.offset(skip).limit(limit).all()
# Convertir a DTOs básicos
client_dtos = [ClientProviderBasicDTO.model_validate(client) for client in clients]
return ClientProviderListDTO(
clients=client_dtos,
total=total,
page=(skip // limit) + 1 if limit > 0 else 1,
size=len(client_dtos)
)
def update_client_provider(self, client_id: str, client_data: ClientProviderUpdateDTO) -> Optional[ClientProviderResponseDTO]:
"""
Actualiza un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a actualizar
client_data: Datos a actualizar
Returns:
ClientProviderResponseDTO actualizado o None si no existe
"""
client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first()
if not client:
return None
try:
# Actualizar campos del cliente principal
update_data = client_data.model_dump(exclude_unset=True, exclude={'address', 'programs'})
for field, value in update_data.items():
setattr(client, field, value)
# Actualizar dirección
if client_data.address:
address = self.db.query(GClientProviderAddress).filter(GClientProviderAddress.client_id == client_id).first()
if address:
# Actualizar dirección existente
address_data = client_data.address.model_dump(exclude_unset=True)
for field, value in address_data.items():
setattr(address, field, value)
else:
# Crear nueva dirección
address = GClientProviderAddress(
client_id=client_id,
**client_data.address.model_dump(exclude_unset=True)
)
self.db.add(address)
# Actualizar programas
if client_data.programs:
programs = self.db.query(GClientProviderPrograms).filter(GClientProviderPrograms.client_id == client_id).first()
if programs:
# Actualizar programas existentes
programs_data = client_data.programs.model_dump(exclude_unset=True)
for field, value in programs_data.items():
setattr(programs, field, value)
else:
# Crear nuevos programas
programs = GClientProviderPrograms(
client_id=client_id,
**client_data.programs.model_dump(exclude_unset=True)
)
self.db.add(programs)
self.db.commit()
logger.info(f"Client/Provider updated: {client_id}")
return self._get_client_with_relations(client_id)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating client/provider")
def delete_client_provider(self, client_id: str) -> bool:
"""
Elimina un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a eliminar
Returns:
True si se eliminó, False si no existe
"""
client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first()
if not client:
return False
try:
self.db.delete(client) # Las relaciones se eliminan en cascada
self.db.commit()
logger.info(f"Client/Provider deleted: {client_id}")
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting client/provider")
def get_clients_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]:
"""Obtiene solo clientes (C)"""
query = self.db.query(GClientProvider).filter(GClientProvider.client_or_provider == 'C')
clients = query.offset(skip).limit(limit).all()
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
def get_providers_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]:
"""Obtiene solo proveedores (P)"""
query = self.db.query(GClientProvider).filter(GClientProvider.client_or_provider == 'P')
providers = query.offset(skip).limit(limit).all()
return [ClientProviderBasicDTO.model_validate(provider) for provider in providers]
def search_by_rfc(self, rfc: str) -> List[ClientProviderBasicDTO]:
"""Busca clientes/proveedores por RFC"""
clients = self.db.query(GClientProvider).filter(GClientProvider.rfc.ilike(f"%{rfc}%")).all()
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
def toggle_status(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
"""Cambia el estado habilitado/deshabilitado"""
client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first()
if not client:
return None
# Toggle status (1 = habilitado, 0 = deshabilitado)
client.enabled_disabled = 1 if client.enabled_disabled == 0 else 0
try:
self.db.commit()
logger.info(f"Client/Provider status toggled: {client_id} -> {client.enabled_disabled}")
return self._get_client_with_relations(client_id)
except Exception as e:
self.db.rollback()
logger.error(f"Error toggling status for {client_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating status")

View File

@@ -0,0 +1,6 @@
"""
Módulo de Company
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,157 @@
"""
DTOs (Data Transfer Objects) para módulo de empresa
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class CompanyCreateDTO(BaseModel):
"""DTO para crear una empresa"""
id: str = Field(default='EMP', max_length=3, description="Company ID")
consecutive: bool = Field(default=True, description="Unique record control")
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(None, max_length=255, description="Main activity")
# Program information
program: Optional[str] = Field(None, max_length=10, description="Program")
program_number: Optional[str] = Field(None, max_length=40, description="Program number")
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(None, max_length=20, description="PROSEC authorization")
# Identifiers
manufacturer_id: Optional[str] = Field(None, max_length=25, description="Manufacturer ID")
broker_company: Optional[str] = Field(None, max_length=10, description="Broker company")
# Responsible person
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
responsible_name: Optional[str] = Field(None, max_length=20, description="Responsible first name")
responsible_last_name: Optional[str] = Field(None, max_length=20, description="Responsible last name")
responsible_mother_last_name: Optional[str] = Field(None, max_length=20, description="Responsible mother's last name")
responsible_rfc: Optional[str] = Field(None, max_length=30, description="Responsible RFC")
position: Optional[str] = Field(None, max_length=30, description="Responsible position")
# Configuration
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
has_express_line: Optional[bool] = Field(None, description="Has express line")
order_format_type: Optional[str] = Field(None, max_length=19, description="Order format type")
previous_code: Optional[int] = Field(None, description="Previous code")
is_service_company: Optional[bool] = Field(None, description="Is service company")
# Client and subassembly
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
subassembly_mode: Optional[str] = Field(None, max_length=7, description="Subassembly mode")
# Additional information
curp: Optional[str] = Field(None, max_length=19, description="CURP")
inter_db_name: Optional[str] = Field(None, max_length=100, description="Inter DB name")
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
trusted_exporter_number: Optional[str] = Field(None, max_length=50, description="Trusted exporter number")
prevalidator_key: Optional[str] = Field(None, max_length=20, description="Prevalidator key")
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
class Config:
from_attributes = True
class CompanyUpdateDTO(BaseModel):
"""DTO para actualizar una empresa"""
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(None, max_length=255, description="Main activity")
# Program information
program: Optional[str] = Field(None, max_length=10, description="Program")
program_number: Optional[str] = Field(None, max_length=40, description="Program number")
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(None, max_length=20, description="PROSEC authorization")
# Identifiers
manufacturer_id: Optional[str] = Field(None, max_length=25, description="Manufacturer ID")
broker_company: Optional[str] = Field(None, max_length=10, description="Broker company")
# Responsible person
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
responsible_name: Optional[str] = Field(None, max_length=20, description="Responsible first name")
responsible_last_name: Optional[str] = Field(None, max_length=20, description="Responsible last name")
responsible_mother_last_name: Optional[str] = Field(None, max_length=20, description="Responsible mother's last name")
responsible_rfc: Optional[str] = Field(None, max_length=30, description="Responsible RFC")
position: Optional[str] = Field(None, max_length=30, description="Responsible position")
# Configuration
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
has_express_line: Optional[bool] = Field(None, description="Has express line")
order_format_type: Optional[str] = Field(None, max_length=19, description="Order format type")
previous_code: Optional[int] = Field(None, description="Previous code")
is_service_company: Optional[bool] = Field(None, description="Is service company")
# Client and subassembly
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
subassembly_mode: Optional[str] = Field(None, max_length=7, description="Subassembly mode")
# Additional information
curp: Optional[str] = Field(None, max_length=19, description="CURP")
inter_db_name: Optional[str] = Field(None, max_length=100, description="Inter DB name")
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
trusted_exporter_number: Optional[str] = Field(None, max_length=50, description="Trusted exporter number")
prevalidator_key: Optional[str] = Field(None, max_length=20, description="Prevalidator key")
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
class Config:
from_attributes = True
class CompanyResponseDTO(BaseModel):
"""DTO para respuesta de empresa"""
id: str
consecutive: bool
name: Optional[str] = None
rfc: Optional[str] = None
main_activity: Optional[str] = None
# Program information
program: Optional[str] = None
program_number: Optional[str] = None
prosec: Optional[int] = None
prosec_authorization: Optional[str] = None
# Identifiers
manufacturer_id: Optional[str] = None
broker_company: Optional[str] = None
# Responsible person
responsible: Optional[str] = None
responsible_name: Optional[str] = None
responsible_last_name: Optional[str] = None
responsible_mother_last_name: Optional[str] = None
responsible_rfc: Optional[str] = None
position: Optional[str] = None
# Configuration
logo: Optional[str] = None
has_express_line: Optional[bool] = None
order_format_type: Optional[str] = None
previous_code: Optional[int] = None
is_service_company: Optional[bool] = None
# Client and subassembly
client_name: Optional[str] = None
subassembly_mode: Optional[str] = None
# Additional information
curp: Optional[str] = None
inter_db_name: Optional[str] = None
ctpat_svi: Optional[str] = None
trusted_exporter_number: Optional[str] = None
prevalidator_key: Optional[str] = None
seventh_amendment: Optional[bool] = None
# Timestamps
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,69 @@
"""
Modelos ORM para gestión de empresa
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, SmallInteger
from sqlalchemy.sql import func
from core.database import Base
import enum
class GCompany(Base):
"""
Modelo para la tabla GCompany - Información de la empresa
"""
__tablename__ = "gcompany"
__table_args__ = {"schema": "a76"}
# Primary key
id = Column(String(3), primary_key=True, default='EMP', nullable=False)
# Control de registro único
consecutive = Column(Boolean, unique=True, default=True, nullable=False)
# Información básica de la empresa
name = Column(String(255), nullable=True)
rfc = Column(String(30), nullable=True)
main_activity = Column(String(255), nullable=True)
# Información del programa
program = Column(String(10), nullable=True)
program_number = Column(String(40), nullable=True)
prosec = Column(SmallInteger, nullable=True)
prosec_authorization = Column(String(20), nullable=True)
# Identificadores
manufacturer_id = Column(String(25), nullable=True)
broker_company = Column(String(10), nullable=True)
# Responsable
responsible = Column(String(80), nullable=True)
responsible_name = Column(String(20), nullable=True)
responsible_last_name = Column(String(20), nullable=True)
responsible_mother_last_name = Column(String(20), nullable=True)
responsible_rfc = Column(String(30), nullable=True)
position = Column(String(30), nullable=True)
# Configuración
logo = Column(String(255), nullable=True)
has_express_line = Column(Boolean, nullable=True)
order_format_type = Column(String(19), nullable=True)
previous_code = Column(SmallInteger, nullable=True)
is_service_company = Column(Boolean, nullable=True)
# Cliente y submaquila
client_name = Column(String(300), nullable=True)
subassembly_mode = Column(String(7), nullable=True)
# Información adicional
curp = Column(String(19), nullable=True)
inter_db_name = Column(String(100), nullable=True)
ctpat_svi = Column(String(100), nullable=True)
trusted_exporter_number = Column(String(50), nullable=True)
prevalidator_key = Column(String(20), nullable=True)
seventh_amendment = Column(Boolean, nullable=True) # FINALCONTADORAELECTRONICO renombrado
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)

View File

@@ -0,0 +1,176 @@
"""
Endpoints API para gestión de empresa
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from core.database import get_core_db
from core.security import get_current_user, has_role
from .service import CompanyService
from .dto import CompanyCreateDTO, CompanyUpdateDTO, CompanyResponseDTO
router = APIRouter(prefix="/company", tags=["Company"])
@router.post("/", response_model=CompanyResponseDTO, status_code=status.HTTP_201_CREATED)
async def create_company(
company_data: CompanyCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Create a new company in the system
Only one company can exist per system due to the unique consecutive field.
"""
service = CompanyService(db)
return service.create_company(company_data)
@router.get("/", response_model=Optional[CompanyResponseDTO])
async def get_company(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get the registered company information
Returns the unique company in the system or None if it doesn't exist.
"""
service = CompanyService(db)
company = service.get_company()
if not company:
raise HTTPException(status_code=404, detail="No company found")
return company
@router.get("/{company_id}", response_model=CompanyResponseDTO)
async def get_company_by_id(
company_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get company by specific ID
"""
service = CompanyService(db)
company = service.get_company_by_id(company_id)
if not company:
raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found")
return company
@router.put("/{company_id}", response_model=CompanyResponseDTO)
async def update_company(
company_id: str,
company_data: CompanyUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Update company information
"""
service = CompanyService(db)
company = service.update_company(company_id, company_data)
if not company:
raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found")
return company
@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_company(
company_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Delete company from the system
Note: This will completely remove the company from the system.
"""
service = CompanyService(db)
if not service.delete_company(company_id):
raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found")
@router.get("/status/exists", response_model=dict)
async def check_company_exists(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Check if a company is registered in the system
"""
service = CompanyService(db)
exists = service.exists_company()
return {"exists": exists, "message": "Company found" if exists else "No company registered"}
# Specific endpoints for important fields
@router.get("/info/basic", response_model=dict)
async def get_company_basic_info(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get basic company information (name, RFC, main activity)
"""
service = CompanyService(db)
company = service.get_company()
if not company:
raise HTTPException(status_code=404, detail="No company found")
return {
"name": company.name,
"rfc": company.rfc,
"main_activity": company.main_activity,
"logo": company.logo
}
@router.get("/info/responsible", response_model=dict)
async def get_company_responsible_info(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get company responsible person information
"""
service = CompanyService(db)
company = service.get_company()
if not company:
raise HTTPException(status_code=404, detail="No company found")
return {
"responsible": company.responsible,
"responsible_name": company.responsible_name,
"responsible_last_name": company.responsible_last_name,
"responsible_mother_last_name": company.responsible_mother_last_name,
"responsible_rfc": company.responsible_rfc,
"position": company.position
}
@router.get("/info/program", response_model=dict)
async def get_company_program_info(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get company program information
"""
service = CompanyService(db)
company = service.get_company()
if not company:
raise HTTPException(status_code=404, detail="No company found")
return {
"program": company.program,
"program_number": company.program_number,
"prosec": company.prosec,
"prosec_authorization": company.prosec_authorization,
"manufacturer_id": company.manufacturer_id
}

View File

@@ -0,0 +1,184 @@
"""
Capa de servicio para lógica de negocio de empresa
"""
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import List, Optional
import logging
from .models import GCompany
from .dto import CompanyCreateDTO, CompanyUpdateDTO, CompanyResponseDTO
logger = logging.getLogger(__name__)
class CompanyService:
"""Servicio para gestión de empresa"""
def __init__(self, db: Session):
self.db = db
def create_company(self, company_data: CompanyCreateDTO) -> CompanyResponseDTO:
"""
Crea una nueva empresa en el sistema
Args:
company_data: Datos de la empresa a crear
Returns:
CompanyResponseDTO con información de la empresa creada
Raises:
HTTPException: Si ya existe una empresa o error en la creación
"""
try:
# Verificar que no exista ya una empresa (solo puede haber una por el consecutivo único)
existing = self.db.query(GCompany).filter(GCompany.consecutive == True).first()
if existing:
raise HTTPException(status_code=400, detail="A company is already registered in the system")
# Crear empresa
db_company = GCompany(
id=company_data.id,
consecutive=company_data.consecutive,
name=company_data.name,
rfc=company_data.rfc,
main_activity=company_data.main_activity,
program=company_data.program,
program_number=company_data.program_number,
prosec=company_data.prosec,
prosec_authorization=company_data.prosec_authorization,
manufacturer_id=company_data.manufacturer_id,
broker_company=company_data.broker_company,
responsible=company_data.responsible,
responsible_name=company_data.responsible_name,
responsible_last_name=company_data.responsible_last_name,
responsible_mother_last_name=company_data.responsible_mother_last_name,
responsible_rfc=company_data.responsible_rfc,
position=company_data.position,
logo=company_data.logo,
has_express_line=company_data.has_express_line,
order_format_type=company_data.order_format_type,
previous_code=company_data.previous_code,
is_service_company=company_data.is_service_company,
client_name=company_data.client_name,
subassembly_mode=company_data.subassembly_mode,
curp=company_data.curp,
inter_db_name=company_data.inter_db_name,
ctpat_svi=company_data.ctpat_svi,
trusted_exporter_number=company_data.trusted_exporter_number,
prevalidator_key=company_data.prevalidator_key,
seventh_amendment=company_data.seventh_amendment
)
self.db.add(db_company)
self.db.commit()
self.db.refresh(db_company)
logger.info(f"Company created: {db_company.id} - {db_company.name}")
return CompanyResponseDTO.model_validate(db_company)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating company: {str(e)}")
raise HTTPException(status_code=400, detail="Integrity error: A company already exists in the system")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating company: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating company")
def get_company(self) -> Optional[CompanyResponseDTO]:
"""
Obtiene la empresa (solo puede haber una)
Returns:
CompanyResponseDTO o None si no existe
"""
company = self.db.query(GCompany).filter(GCompany.consecutive == True).first()
if not company:
return None
return CompanyResponseDTO.model_validate(company)
def get_company_by_id(self, company_id: str) -> Optional[CompanyResponseDTO]:
"""
Obtiene una empresa por ID
Args:
company_id: ID de la empresa
Returns:
CompanyResponseDTO o None si no existe
"""
company = self.db.query(GCompany).filter(GCompany.id == company_id).first()
if not company:
return None
return CompanyResponseDTO.model_validate(company)
def update_company(self, company_id: str, company_data: CompanyUpdateDTO) -> Optional[CompanyResponseDTO]:
"""
Actualiza una empresa
Args:
company_id: ID de la empresa a actualizar
company_data: Datos a actualizar
Returns:
CompanyResponseDTO actualizada o None si no existe
"""
company = self.db.query(GCompany).filter(GCompany.id == company_id).first()
if not company:
return None
# Actualizar solo campos proporcionados
update_data = company_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(company, field, value)
try:
self.db.commit()
self.db.refresh(company)
logger.info(f"Company updated: {company_id}")
return CompanyResponseDTO.model_validate(company)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating company")
def delete_company(self, company_id: str) -> bool:
"""
Elimina una empresa
Args:
company_id: ID de la empresa a eliminar
Returns:
True si se eliminó, False si no existe
"""
company = self.db.query(GCompany).filter(GCompany.id == company_id).first()
if not company:
return False
try:
self.db.delete(company)
self.db.commit()
logger.info(f"Company deleted: {company_id}")
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting company")
def exists_company(self) -> bool:
"""
Verifica si existe una empresa registrada
Returns:
True si existe una empresa, False en caso contrario
"""
return self.db.query(GCompany).filter(GCompany.consecutive == True).first() is not None