diff --git a/.gitignore b/.gitignore index 2d9ff71d..14c07767 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ wheels/ # Environment .env .env.local - +backend/SCRIPTS/ # IDEs .vscode/ .idea/ diff --git a/backend/api/v1/modules/a76/GClass/__init__.py b/backend/api/v1/modules/a76/GClass/__init__.py new file mode 100644 index 00000000..7c89dc70 --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de Tenants +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/GClass/dto.py b/backend/api/v1/modules/a76/GClass/dto.py new file mode 100644 index 00000000..a0e9e786 --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/dto.py @@ -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 + diff --git a/backend/api/v1/modules/a76/GClass/models.py b/backend/api/v1/modules/a76/GClass/models.py new file mode 100644 index 00000000..157b3bee --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/models.py @@ -0,0 +1,60 @@ +""" +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 + +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" + + # 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: "MaterialType" = relationship("MaterialType", foreign_keys=[material_key]) + + # Inverse relationship with GParts that have this class + parts: List["GPart"] = 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"" + + diff --git a/backend/api/v1/modules/a76/GClass/routes.py b/backend/api/v1/modules/a76/GClass/routes.py new file mode 100644 index 00000000..f0b906ab --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/routes.py @@ -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 + } + + diff --git a/backend/api/v1/modules/a76/GClass/service.py b/backend/api/v1/modules/a76/GClass/service.py new file mode 100644 index 00000000..ceed5c56 --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/service.py @@ -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] + diff --git a/backend/api/v1/modules/a76/GParts/__init__.py b/backend/api/v1/modules/a76/GParts/__init__.py new file mode 100644 index 00000000..7c89dc70 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de Tenants +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/GParts/dto.py b/backend/api/v1/modules/a76/GParts/dto.py new file mode 100644 index 00000000..13524abd --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/dto.py @@ -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 + + diff --git a/backend/api/v1/modules/a76/GParts/models.py b/backend/api/v1/modules/a76/GParts/models.py new file mode 100644 index 00000000..4c0b1d04 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/models.py @@ -0,0 +1,87 @@ +""" +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 + +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" + + # 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: "Country" = relationship("Country", foreign_keys=[country_of_origin]) + currency: "CurrencyType" = 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: "GClass" = 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"" + + diff --git a/backend/api/v1/modules/a76/GParts/routes.py b/backend/api/v1/modules/a76/GParts/routes.py new file mode 100644 index 00000000..36486b04 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/routes.py @@ -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 + } + + diff --git a/backend/api/v1/modules/a76/GParts/service.py b/backend/api/v1/modules/a76/GParts/service.py new file mode 100644 index 00000000..a5815300 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/service.py @@ -0,0 +1,15 @@ +""" +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 + + + +logger = logging.getLogger(__name__) + diff --git a/backend/api/v1/modules/a76/client_&_provider/__init__.py b/backend/api/v1/modules/a76/client_&_provider/__init__.py new file mode 100644 index 00000000..7c89dc70 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de Tenants +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/client_&_provider/dto.py b/backend/api/v1/modules/a76/client_&_provider/dto.py new file mode 100644 index 00000000..be248661 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/dto.py @@ -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 + diff --git a/backend/api/v1/modules/a76/client_&_provider/models.py b/backend/api/v1/modules/a76/client_&_provider/models.py new file mode 100644 index 00000000..2c9c36cc --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/models.py @@ -0,0 +1,105 @@ +""" +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" + + # 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" + + # Primary key (foreign key) + client_id = Column(String(8), ForeignKey('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" + + # Primary key (foreign key) + client_id = Column(String(8), ForeignKey('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") + + diff --git a/backend/api/v1/modules/a76/client_&_provider/routes.py b/backend/api/v1/modules/a76/client_&_provider/routes.py new file mode 100644 index 00000000..fb9edd50 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/routes.py @@ -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 + ) + diff --git a/backend/api/v1/modules/a76/client_&_provider/service.py b/backend/api/v1/modules/a76/client_&_provider/service.py new file mode 100644 index 00000000..a9573c10 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/service.py @@ -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") + + diff --git a/backend/api/v1/modules/a76/company/__init__.py b/backend/api/v1/modules/a76/company/__init__.py new file mode 100644 index 00000000..7c89dc70 --- /dev/null +++ b/backend/api/v1/modules/a76/company/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de Tenants +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/company/dto.py b/backend/api/v1/modules/a76/company/dto.py new file mode 100644 index 00000000..592dd07b --- /dev/null +++ b/backend/api/v1/modules/a76/company/dto.py @@ -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 + diff --git a/backend/api/v1/modules/a76/company/models.py b/backend/api/v1/modules/a76/company/models.py new file mode 100644 index 00000000..bbba06f3 --- /dev/null +++ b/backend/api/v1/modules/a76/company/models.py @@ -0,0 +1,68 @@ +""" +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" + + # 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) + + diff --git a/backend/api/v1/modules/a76/company/routes.py b/backend/api/v1/modules/a76/company/routes.py new file mode 100644 index 00000000..4473591d --- /dev/null +++ b/backend/api/v1/modules/a76/company/routes.py @@ -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 + } + + diff --git a/backend/api/v1/modules/a76/company/service.py b/backend/api/v1/modules/a76/company/service.py new file mode 100644 index 00000000..405ad1a2 --- /dev/null +++ b/backend/api/v1/modules/a76/company/service.py @@ -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 + + diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index ffb93465..88480cfe 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -26,6 +26,14 @@ from .modules.public.reference_data.code_pedimento_regimens.routes import router from .modules.public.reference_data.pedimento_regimens.routes import router as pedimento_regimens_router from .modules.public.reference_data.incoterms.routes import router as incoterms_router from .modules.a76.licenses import router as licenses_router +from .modules.a76.company.routes import router as company_router +from .modules.a76.GParts.routes import router as gparts_router +from .modules.a76.GClass.routes import router as gclass_router + +# Import module with special character using importlib +import importlib +client_provider_module = importlib.import_module('.modules.a76.client_&_provider.routes', package='api.v1') +client_provider_router = client_provider_module.router # Router principal router = APIRouter() @@ -34,6 +42,10 @@ router = APIRouter() router.include_router(auth_router) router.include_router(tenants_router) router.include_router(licenses_router) +router.include_router(company_router) +router.include_router(client_provider_router) +router.include_router(gparts_router) +router.include_router(gclass_router) router.include_router(pedimento_codes_router) router.include_router(payment_methods_router) router.include_router(containers_router) diff --git a/docs/RELATIONSHIPS.md b/docs/RELATIONSHIPS.md new file mode 100644 index 00000000..362ca6fe --- /dev/null +++ b/docs/RELATIONSHIPS.md @@ -0,0 +1,107 @@ +# Relaciones entre Modelos A76 + +## Resumen de Relaciones Establecidas + +### GPart (Tabla: gparts) +El modelo `GPart` representa las partes/componentes en los sistemas SCAII, SCAF y WINSAAI. + +#### Relaciones: + +1. **Con Country (public.countries)** + - Campo: `country_of_origin` → `countries.m3_key` + - Relación: Many-to-One + - Propósito: País de origen de la parte + +2. **Con CurrencyType (public.currency_types)** + - Campo: `currency_key` → `currency_types.code` + - Relación: Many-to-One + - Propósito: Tipo de moneda para el costo unitario + +3. **Con GClass (gclasses)** + - Campos: `(client_key, part_class)` → `(client_key, class_code)` + - Relación: Many-to-One (usando primaryjoin complejo) + - Propósito: Clasificación de la parte + - Atributo: `part_class_info` + +### GClass (Tabla: gclasses) +El modelo `GClass` representa las clases de clasificación en sistemas SCAII y SCAF. + +#### Relaciones: + +1. **Con MaterialType (public.material_types)** + - Campo: `material_key` → `material_types.key` + - Relación: Many-to-One + - Propósito: Tipo de material de la clase + +2. **Con GPart (gparts)** + - Campos: `(client_key, class_code)` → `(client_key, part_class)` + - Relación: One-to-Many (inversa de la relación en GPart) + - Propósito: Partes que pertenecen a esta clase + - Atributo: `parts` + +## Esquema de Relaciones + +``` +GPart +├── country (Country) # País de origen +├── currency (CurrencyType) # Tipo de moneda +└── part_class_info (GClass) # Información de clasificación + └── material_type (MaterialType) # Tipo de material + +GClass +├── material_type (MaterialType) # Tipo de material +└── parts (List[GPart]) # Partes que usan esta clase +``` + +## Uso de las Relaciones + +### En consultas: +```python +# Obtener una parte con su información completa +part = session.query(GPart).options( + joinedload(GPart.country), + joinedload(GPart.currency), + joinedload(GPart.part_class_info).joinedload(GClass.material_type) +).filter( + GPart.client_key == 1, + GPart.part_number == "PART001" +).first() + +# Acceder a los datos relacionados +print(f"País: {part.country.description_es}") +print(f"Moneda: {part.currency.currency_name}") +print(f"Clase: {part.part_class_info.description_spanish}") +print(f"Material: {part.part_class_info.material_type.description}") +``` + +### En DTOs: +Los DTOs pueden incluir información relacionada: +```python +class PartDetailResponseDTO(BaseModel): + client_key: int + part_number: str + description_spanish: Optional[str] + country_name: Optional[str] = None + currency_name: Optional[str] = None + class_description: Optional[str] = None + material_type: Optional[str] = None +``` + +## Consideraciones Técnicas + +1. **Composite Foreign Keys**: La relación entre `GPart` y `GClass` usa claves foráneas compuestas que requieren `primaryjoin` personalizado. + +2. **Viewonly Relationships**: Algunas relaciones están marcadas como `viewonly=True` para evitar problemas de escritura accidental. + +3. **Lazy Loading**: Por defecto, las relaciones usan lazy loading. Para consultas que necesiten datos relacionados, usar `joinedload` o `selectinload`. + +4. **Type Hints**: Se usan `TYPE_CHECKING` imports para evitar import circulares mientras se mantienen los type hints. + +## Futuras Relaciones + +Potenciales relaciones adicionales que se pueden agregar: + +1. **Con Sectors** (public.sectors) - para clasificación sectorial +2. **Con Transport Types** (public.transport_types) - para modo de transporte +3. **Con Customs Sections** (public.customs_sections) - para sección aduanera +4. **Relaciones con tablas subsidiarias** como `SPartes`, `QPartes`, etc. \ No newline at end of file