Merge remote-tracking branch 'origin/catalogos_a76' into catalogos-frontend
This commit is contained in:
6
backend/api/v1/modules/a76/GClass/__init__.py
Normal file
6
backend/api/v1/modules/a76/GClass/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de GClass
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
97
backend/api/v1/modules/a76/GClass/dto.py
Normal file
97
backend/api/v1/modules/a76/GClass/dto.py
Normal 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
|
||||
|
||||
61
backend/api/v1/modules/a76/GClass/models.py
Normal file
61
backend/api/v1/modules/a76/GClass/models.py
Normal 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}')>"
|
||||
|
||||
|
||||
261
backend/api/v1/modules/a76/GClass/routes.py
Normal file
261
backend/api/v1/modules/a76/GClass/routes.py
Normal 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
|
||||
}
|
||||
|
||||
|
||||
294
backend/api/v1/modules/a76/GClass/service.py
Normal file
294
backend/api/v1/modules/a76/GClass/service.py
Normal 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]
|
||||
|
||||
6
backend/api/v1/modules/a76/GParts/__init__.py
Normal file
6
backend/api/v1/modules/a76/GParts/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de GParts
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
182
backend/api/v1/modules/a76/GParts/dto.py
Normal file
182
backend/api/v1/modules/a76/GParts/dto.py
Normal 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
|
||||
|
||||
|
||||
88
backend/api/v1/modules/a76/GParts/models.py
Normal file
88
backend/api/v1/modules/a76/GParts/models.py
Normal 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}')>"
|
||||
|
||||
|
||||
273
backend/api/v1/modules/a76/GParts/routes.py
Normal file
273
backend/api/v1/modules/a76/GParts/routes.py
Normal 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
|
||||
}
|
||||
|
||||
|
||||
275
backend/api/v1/modules/a76/GParts/service.py
Normal file
275
backend/api/v1/modules/a76/GParts/service.py
Normal 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")
|
||||
|
||||
6
backend/api/v1/modules/a76/client_&_provider/__init__.py
Normal file
6
backend/api/v1/modules/a76/client_&_provider/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Client & Provider
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
165
backend/api/v1/modules/a76/client_&_provider/dto.py
Normal file
165
backend/api/v1/modules/a76/client_&_provider/dto.py
Normal 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
|
||||
|
||||
108
backend/api/v1/modules/a76/client_&_provider/models.py
Normal file
108
backend/api/v1/modules/a76/client_&_provider/models.py
Normal 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")
|
||||
|
||||
|
||||
221
backend/api/v1/modules/a76/client_&_provider/routes.py
Normal file
221
backend/api/v1/modules/a76/client_&_provider/routes.py
Normal 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
|
||||
)
|
||||
|
||||
309
backend/api/v1/modules/a76/client_&_provider/service.py
Normal file
309
backend/api/v1/modules/a76/client_&_provider/service.py
Normal 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")
|
||||
|
||||
|
||||
6
backend/api/v1/modules/a76/company/__init__.py
Normal file
6
backend/api/v1/modules/a76/company/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Company
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
157
backend/api/v1/modules/a76/company/dto.py
Normal file
157
backend/api/v1/modules/a76/company/dto.py
Normal 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
|
||||
|
||||
69
backend/api/v1/modules/a76/company/models.py
Normal file
69
backend/api/v1/modules/a76/company/models.py
Normal 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)
|
||||
|
||||
|
||||
176
backend/api/v1/modules/a76/company/routes.py
Normal file
176
backend/api/v1/modules/a76/company/routes.py
Normal 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
|
||||
}
|
||||
|
||||
|
||||
184
backend/api/v1/modules/a76/company/service.py
Normal file
184
backend/api/v1/modules/a76/company/service.py
Normal 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user