feat: Add comprehensive A76 modules with database relationships
✨ New Features: - Company module: Single company management with comprehensive business info - Client & Provider module: Manages clients/providers with address/program relationships - GParts module: Parts/components management for SCAII, SCAF, and WINSAAI systems - GClass module: Class classifications for SCAII and SCAF with tariff information 🔗 Database Relationships: - GPart ↔ GClass: Composite key relationship (client_key, part_class ↔ class_code) - GPart → Country: Foreign key to public.countries (country_of_origin) - GPart → CurrencyType: Foreign key to public.currency_types (currency_key) - GClass → MaterialType: Foreign key to public.material_types (material_key) 📊 API Endpoints Added: Company Module (/company): - POST / - Create company - GET / - Get single company Client & Provider Module (/clients-providers): - POST / - Create client/provider - GET / - List all with pagination - GET /clients - List only clients - GET /providers - List only providers - GET /search/rfc/{rfc} - Search by RFC - GET /{client_id} - Get by ID - PUT /{client_id} - Update client/provider - DELETE /{client_id} - Delete client/provider - PATCH /{client_id}/toggle-status - Toggle status - GET /{client_id}/address - Get address info - GET /{client_id}/programs - Get programs info - GET /{client_id}/basic - Get basic info GParts Module (/parts): - POST / - Create part - GET / - List all with pagination and filters - GET /client/{client_key} - Get parts by client - GET /search/fraction/{fraction} - Search by tariff fraction - GET /search/supplier/{supplier} - Search by supplier - GET /search/country/{country_code} - Search by country - GET /statistics - Get parts statistics - GET /{client_key}/{part_number} - Get specific part - PUT /{client_key}/{part_number} - Update part - DELETE /{client_key}/{part_number} - Delete part - PATCH /{client_key}/{part_number}/toggle-status - Toggle status - GET /{client_key}/{part_number}/basic - Get basic info - GET /{client_key}/{part_number}/regulatory - Get regulatory info GClass Module (/classes): - POST / - Create class - GET / - List all with pagination and filters - GET /client/{client_key} - Get classes by client - GET /search/fraction/{fraction} - Search by tariff fraction - GET /search/material/{material_key} - Search by material - GET /search/unit-measure/{unit_of_measure} - Search by unit of measure - GET /search/physical-review/{physical_review} - Search by physical review status - GET /statistics - Get class statistics - GET /{client_key}/{class_code} - Get specific class - PUT /{client_key}/{class_code} - Update class - DELETE /{client_key}/{class_code} - Delete class - GET /{client_key}/{class_code}/basic - Get basic info - GET /{client_key}/{class_code}/tariff - Get tariff information 🏗️ Architecture: - Modular design with models, DTOs, services, and routes for each entity - English field names with composite primary keys where applicable - Comprehensive CRUD operations with specialized search endpoints - SQLAlchemy relationships with proper foreign key constraints - Type-safe DTOs with Pydantic validation 📝 Documentation: - RELATIONSHIPS.md: Complete documentation of database relationships - Detailed type hints and comprehensive service methods - Consistent patterns across all modules for maintainability
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,7 +27,7 @@ wheels/
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
backend/SCRIPTS/
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
6
backend/api/v1/modules/a76/GClass/__init__.py
Normal file
6
backend/api/v1/modules/a76/GClass/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Tenants
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
97
backend/api/v1/modules/a76/GClass/dto.py
Normal file
97
backend/api/v1/modules/a76/GClass/dto.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de clases SCAII y SCAF
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ClassCreateDTO(BaseModel):
|
||||
"""DTO para crear una clase"""
|
||||
client_key: int = Field(..., description="Client key")
|
||||
class_code: str = Field(..., max_length=8, description="Class code")
|
||||
description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish")
|
||||
description_english: Optional[str] = Field(None, max_length=500, description="Description in English")
|
||||
material_key: Optional[str] = Field(None, max_length=10, description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)")
|
||||
unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)")
|
||||
fraction: Optional[str] = Field(None, max_length=10, description="Mexican tariff fraction")
|
||||
us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction")
|
||||
sub_key: Optional[str] = Field(None, max_length=5, description="Sub classification key")
|
||||
physical_review: Optional[int] = Field(None, description="Physical review indicator")
|
||||
iva_exempt_fraction: Optional[str] = Field(None, max_length=4, description="IVA exempt fraction")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una clase"""
|
||||
description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish")
|
||||
description_english: Optional[str] = Field(None, max_length=500, description="Description in English")
|
||||
material_key: Optional[str] = Field(None, max_length=10, description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)")
|
||||
unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)")
|
||||
fraction: Optional[str] = Field(None, max_length=10, description="Mexican tariff fraction")
|
||||
us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction")
|
||||
sub_key: Optional[str] = Field(None, max_length=5, description="Sub classification key")
|
||||
physical_review: Optional[int] = Field(None, description="Physical review indicator")
|
||||
iva_exempt_fraction: Optional[str] = Field(None, max_length=4, description="IVA exempt fraction")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de clase"""
|
||||
client_key: int
|
||||
class_code: str
|
||||
description_spanish: Optional[str] = None
|
||||
description_english: Optional[str] = None
|
||||
material_key: Optional[str] = None
|
||||
unit_of_measure: Optional[str] = None
|
||||
fraction: Optional[str] = None
|
||||
us_fraction: Optional[str] = None
|
||||
sub_key: Optional[str] = None
|
||||
physical_review: Optional[int] = None
|
||||
iva_exempt_fraction: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassBasicDTO(BaseModel):
|
||||
"""DTO para información básica de clase"""
|
||||
client_key: int
|
||||
class_code: str
|
||||
description_spanish: Optional[str] = None
|
||||
description_english: Optional[str] = None
|
||||
material_key: Optional[str] = None
|
||||
fraction: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassListDTO(BaseModel):
|
||||
"""DTO para lista de clases"""
|
||||
classes: list[ClassBasicDTO]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClassSearchDTO(BaseModel):
|
||||
"""DTO para búsqueda de clases"""
|
||||
client_key: Optional[int] = Field(None, description="Filter by client key")
|
||||
class_code: Optional[str] = Field(None, description="Search by class code")
|
||||
description: Optional[str] = Field(None, description="Search in descriptions")
|
||||
material_key: Optional[str] = Field(None, description="Filter by material key")
|
||||
fraction: Optional[str] = Field(None, description="Filter by tariff fraction")
|
||||
physical_review: Optional[int] = Field(None, description="Filter by physical review indicator")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
60
backend/api/v1/modules/a76/GClass/models.py
Normal file
60
backend/api/v1/modules/a76/GClass/models.py
Normal file
@@ -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"<GClass(client_key={self.client_key}, class_code='{self.class_code}', description='{self.description_spanish}')>"
|
||||
|
||||
|
||||
261
backend/api/v1/modules/a76/GClass/routes.py
Normal file
261
backend/api/v1/modules/a76/GClass/routes.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Endpoints API para gestión de clases SCAII y SCAF
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, has_role
|
||||
from .service import ClassService
|
||||
from .dto import (
|
||||
ClassCreateDTO,
|
||||
ClassUpdateDTO,
|
||||
ClassResponseDTO,
|
||||
ClassBasicDTO,
|
||||
ClassListDTO,
|
||||
ClassSearchDTO
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/classes", tags=["Classes"])
|
||||
|
||||
|
||||
@router.post("/", response_model=ClassResponseDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def create_class(
|
||||
class_data: ClassCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new class in the system
|
||||
"""
|
||||
service = ClassService(db)
|
||||
return service.create_class(class_data)
|
||||
|
||||
|
||||
@router.get("/", response_model=ClassListDTO)
|
||||
async def list_classes(
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
client_key: Optional[int] = Query(None, description="Filter by client key"),
|
||||
class_code: Optional[str] = Query(None, description="Search by class code"),
|
||||
description: Optional[str] = Query(None, description="Search in descriptions"),
|
||||
material_key: Optional[str] = Query(None, description="Filter by material key"),
|
||||
fraction: Optional[str] = Query(None, description="Filter by tariff fraction"),
|
||||
physical_review: Optional[int] = Query(None, description="Filter by physical review indicator"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List classes with optional filters and pagination
|
||||
"""
|
||||
service = ClassService(db)
|
||||
search_params = ClassSearchDTO(
|
||||
client_key=client_key,
|
||||
class_code=class_code,
|
||||
description=description,
|
||||
material_key=material_key,
|
||||
fraction=fraction,
|
||||
physical_review=physical_review
|
||||
)
|
||||
return service.list_classes(skip, limit, search_params)
|
||||
|
||||
|
||||
@router.get("/client/{client_key}", response_model=List[ClassBasicDTO])
|
||||
async def get_classes_by_client(
|
||||
client_key: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get all classes for a specific client
|
||||
"""
|
||||
service = ClassService(db)
|
||||
return service.search_by_client(client_key, skip, limit)
|
||||
|
||||
|
||||
@router.get("/search/fraction/{fraction}", response_model=List[ClassBasicDTO])
|
||||
async def search_by_fraction(
|
||||
fraction: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Search classes by tariff fraction
|
||||
"""
|
||||
service = ClassService(db)
|
||||
return service.search_by_fraction(fraction)
|
||||
|
||||
|
||||
@router.get("/search/material/{material_key}", response_model=List[ClassBasicDTO])
|
||||
async def search_by_material(
|
||||
material_key: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Search classes by material key
|
||||
"""
|
||||
service = ClassService(db)
|
||||
return service.search_by_material(material_key)
|
||||
|
||||
|
||||
@router.get("/search/unit-measure/{unit_of_measure}", response_model=List[ClassBasicDTO])
|
||||
async def get_classes_by_unit_measure(
|
||||
unit_of_measure: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get classes by unit of measure
|
||||
"""
|
||||
service = ClassService(db)
|
||||
return service.get_classes_by_unit_measure(unit_of_measure)
|
||||
|
||||
|
||||
@router.get("/search/physical-review/{physical_review}", response_model=List[ClassBasicDTO])
|
||||
async def get_classes_by_physical_review(
|
||||
physical_review: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get classes by physical review indicator
|
||||
"""
|
||||
service = ClassService(db)
|
||||
return service.get_classes_by_physical_review(physical_review)
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=dict)
|
||||
async def get_classes_statistics(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get basic classes statistics
|
||||
"""
|
||||
service = ClassService(db)
|
||||
return service.get_classes_statistics()
|
||||
|
||||
|
||||
@router.get("/{client_key}/{class_code}", response_model=ClassResponseDTO)
|
||||
async def get_class(
|
||||
client_key: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get class by composite key (client_key + class_code)
|
||||
"""
|
||||
service = ClassService(db)
|
||||
class_obj = service.get_class(client_key, class_code)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
|
||||
)
|
||||
return class_obj
|
||||
|
||||
|
||||
@router.put("/{client_key}/{class_code}", response_model=ClassResponseDTO)
|
||||
async def update_class(
|
||||
client_key: int,
|
||||
class_code: str,
|
||||
class_data: ClassUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update class information
|
||||
"""
|
||||
service = ClassService(db)
|
||||
class_obj = service.update_class(client_key, class_code, class_data)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
|
||||
)
|
||||
return class_obj
|
||||
|
||||
|
||||
@router.delete("/{client_key}/{class_code}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_class(
|
||||
client_key: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete class from the system
|
||||
|
||||
Note: This will completely remove the class from the system.
|
||||
"""
|
||||
service = ClassService(db)
|
||||
if not service.delete_class(client_key, class_code):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
|
||||
)
|
||||
|
||||
|
||||
# Endpoints específicos para información detallada
|
||||
@router.get("/{client_key}/{class_code}/basic", response_model=ClassBasicDTO)
|
||||
async def get_class_basic_info(
|
||||
client_key: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get basic information for a class
|
||||
"""
|
||||
service = ClassService(db)
|
||||
class_obj = service.get_class(client_key, class_code)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
|
||||
)
|
||||
|
||||
return ClassBasicDTO(
|
||||
client_key=class_obj.client_key,
|
||||
class_code=class_obj.class_code,
|
||||
description_spanish=class_obj.description_spanish,
|
||||
description_english=class_obj.description_english,
|
||||
material_key=class_obj.material_key,
|
||||
fraction=class_obj.fraction
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{client_key}/{class_code}/tariff", response_model=dict)
|
||||
async def get_class_tariff_info(
|
||||
client_key: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get tariff information for a class (fractions, IVA exempt, etc.)
|
||||
"""
|
||||
service = ClassService(db)
|
||||
class_obj = service.get_class(client_key, class_code)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"client_key": class_obj.client_key,
|
||||
"class_code": class_obj.class_code,
|
||||
"fraction": class_obj.fraction,
|
||||
"us_fraction": class_obj.us_fraction,
|
||||
"iva_exempt_fraction": class_obj.iva_exempt_fraction,
|
||||
"sub_key": class_obj.sub_key,
|
||||
"physical_review": class_obj.physical_review
|
||||
}
|
||||
|
||||
|
||||
294
backend/api/v1/modules/a76/GClass/service.py
Normal file
294
backend/api/v1/modules/a76/GClass/service.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de clases SCAII y SCAF
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import or_, and_, func
|
||||
from fastapi import HTTPException
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
from .models import GClass
|
||||
from .dto import (
|
||||
ClassCreateDTO,
|
||||
ClassUpdateDTO,
|
||||
ClassResponseDTO,
|
||||
ClassBasicDTO,
|
||||
ClassListDTO,
|
||||
ClassSearchDTO
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClassService:
|
||||
"""Servicio para gestión de clases SCAII y SCAF"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_class(self, class_data: ClassCreateDTO) -> ClassResponseDTO:
|
||||
"""
|
||||
Crea una nueva clase en el sistema
|
||||
|
||||
Args:
|
||||
class_data: Datos de la clase a crear
|
||||
|
||||
Returns:
|
||||
ClassResponseDTO con información de la clase creada
|
||||
|
||||
Raises:
|
||||
HTTPException: Si la clase ya existe o error en la creación
|
||||
"""
|
||||
try:
|
||||
# Verificar que no exista la clase
|
||||
existing = self.db.query(GClass).filter(
|
||||
and_(
|
||||
GClass.client_key == class_data.client_key,
|
||||
GClass.class_code == class_data.class_code
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Class with client_key '{class_data.client_key}' and class_code '{class_data.class_code}' already exists"
|
||||
)
|
||||
|
||||
# Crear clase
|
||||
db_class = GClass(
|
||||
client_key=class_data.client_key,
|
||||
class_code=class_data.class_code,
|
||||
description_spanish=class_data.description_spanish,
|
||||
description_english=class_data.description_english,
|
||||
material_key=class_data.material_key,
|
||||
unit_of_measure=class_data.unit_of_measure,
|
||||
fraction=class_data.fraction,
|
||||
us_fraction=class_data.us_fraction,
|
||||
sub_key=class_data.sub_key,
|
||||
physical_review=class_data.physical_review,
|
||||
iva_exempt_fraction=class_data.iva_exempt_fraction
|
||||
)
|
||||
|
||||
self.db.add(db_class)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_class)
|
||||
|
||||
logger.info(f"Class created: {db_class.client_key}-{db_class.class_code}")
|
||||
|
||||
return ClassResponseDTO.model_validate(db_class)
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating class: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail="Class with this client_key and class_code already exists")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating class: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error creating class")
|
||||
|
||||
def get_class(self, client_key: int, class_code: str) -> Optional[ClassResponseDTO]:
|
||||
"""
|
||||
Obtiene una clase por clave compuesta
|
||||
|
||||
Args:
|
||||
client_key: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
Returns:
|
||||
ClassResponseDTO o None si no existe
|
||||
"""
|
||||
class_obj = self.db.query(GClass).filter(
|
||||
and_(
|
||||
GClass.client_key == client_key,
|
||||
GClass.class_code == class_code
|
||||
)
|
||||
).first()
|
||||
|
||||
if not class_obj:
|
||||
return None
|
||||
return ClassResponseDTO.model_validate(class_obj)
|
||||
|
||||
def list_classes(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search_params: Optional[ClassSearchDTO] = None
|
||||
) -> ClassListDTO:
|
||||
"""
|
||||
Lista clases con filtros
|
||||
|
||||
Args:
|
||||
skip: Número de registros a omitir
|
||||
limit: Número máximo de registros a retornar
|
||||
search_params: Parámetros de búsqueda
|
||||
|
||||
Returns:
|
||||
ClassListDTO con la lista paginada
|
||||
"""
|
||||
query = self.db.query(GClass)
|
||||
|
||||
# Aplicar filtros si se proporcionan
|
||||
if search_params:
|
||||
if search_params.client_key:
|
||||
query = query.filter(GClass.client_key == search_params.client_key)
|
||||
|
||||
if search_params.class_code:
|
||||
query = query.filter(GClass.class_code.ilike(f"%{search_params.class_code}%"))
|
||||
|
||||
if search_params.description:
|
||||
description_pattern = f"%{search_params.description}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
GClass.description_spanish.ilike(description_pattern),
|
||||
GClass.description_english.ilike(description_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
if search_params.material_key:
|
||||
query = query.filter(GClass.material_key.ilike(f"%{search_params.material_key}%"))
|
||||
|
||||
if search_params.fraction:
|
||||
query = query.filter(GClass.fraction.ilike(f"%{search_params.fraction}%"))
|
||||
|
||||
if search_params.physical_review is not None:
|
||||
query = query.filter(GClass.physical_review == search_params.physical_review)
|
||||
|
||||
# Contar total
|
||||
total = query.count()
|
||||
|
||||
# Aplicar paginación
|
||||
classes = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Convertir a DTOs básicos
|
||||
class_dtos = [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
return ClassListDTO(
|
||||
classes=class_dtos,
|
||||
total=total,
|
||||
page=(skip // limit) + 1 if limit > 0 else 1,
|
||||
size=len(class_dtos)
|
||||
)
|
||||
|
||||
def update_class(self, client_key: int, class_code: str, class_data: ClassUpdateDTO) -> Optional[ClassResponseDTO]:
|
||||
"""
|
||||
Actualiza una clase
|
||||
|
||||
Args:
|
||||
client_key: Clave del cliente
|
||||
class_code: Código de clase
|
||||
class_data: Datos a actualizar
|
||||
|
||||
Returns:
|
||||
ClassResponseDTO actualizado o None si no existe
|
||||
"""
|
||||
class_obj = self.db.query(GClass).filter(
|
||||
and_(
|
||||
GClass.client_key == client_key,
|
||||
GClass.class_code == class_code
|
||||
)
|
||||
).first()
|
||||
|
||||
if not class_obj:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Actualizar solo campos proporcionados
|
||||
update_data = class_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(class_obj, field, value)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(class_obj)
|
||||
logger.info(f"Class updated: {client_key}-{class_code}")
|
||||
|
||||
return ClassResponseDTO.model_validate(class_obj)
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error updating class {client_key}-{class_code}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating class")
|
||||
|
||||
def delete_class(self, client_key: int, class_code: str) -> bool:
|
||||
"""
|
||||
Elimina una clase
|
||||
|
||||
Args:
|
||||
client_key: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
Returns:
|
||||
True si se eliminó, False si no existe
|
||||
"""
|
||||
class_obj = self.db.query(GClass).filter(
|
||||
and_(
|
||||
GClass.client_key == client_key,
|
||||
GClass.class_code == class_code
|
||||
)
|
||||
).first()
|
||||
|
||||
if not class_obj:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.db.delete(class_obj)
|
||||
self.db.commit()
|
||||
logger.info(f"Class deleted: {client_key}-{class_code}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting class {client_key}-{class_code}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting class")
|
||||
|
||||
def search_by_fraction(self, fraction: str) -> List[ClassBasicDTO]:
|
||||
"""Busca clases por fracción arancelaria"""
|
||||
classes = self.db.query(GClass).filter(GClass.fraction.ilike(f"%{fraction}%")).all()
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def search_by_client(self, client_key: int, skip: int = 0, limit: int = 100) -> List[ClassBasicDTO]:
|
||||
"""Obtiene todas las clases de un cliente específico"""
|
||||
classes = self.db.query(GClass).filter(GClass.client_key == client_key).offset(skip).limit(limit).all()
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def search_by_material(self, material_key: str) -> List[ClassBasicDTO]:
|
||||
"""Busca clases por clave de material"""
|
||||
classes = self.db.query(GClass).filter(GClass.material_key.ilike(f"%{material_key}%")).all()
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def get_classes_by_physical_review(self, physical_review: int) -> List[ClassBasicDTO]:
|
||||
"""Obtiene clases por indicador de revisión física"""
|
||||
classes = self.db.query(GClass).filter(GClass.physical_review == physical_review).all()
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def get_classes_statistics(self) -> dict:
|
||||
"""Obtiene estadísticas básicas de clases"""
|
||||
total_classes = self.db.query(GClass).count()
|
||||
|
||||
# Contar por clientes
|
||||
clients_count = self.db.query(GClass.client_key).distinct().count()
|
||||
|
||||
# Contar por revisión física
|
||||
physical_review_stats = {}
|
||||
for i in range(3): # Asumiendo valores 0, 1, 2
|
||||
count = self.db.query(GClass).filter(GClass.physical_review == i).count()
|
||||
physical_review_stats[f"physical_review_{i}"] = count
|
||||
|
||||
# Contar clases con fracciones
|
||||
with_fraction = self.db.query(GClass).filter(GClass.fraction.isnot(None)).count()
|
||||
with_us_fraction = self.db.query(GClass).filter(GClass.us_fraction.isnot(None)).count()
|
||||
|
||||
return {
|
||||
"total_classes": total_classes,
|
||||
"clients_with_classes": clients_count,
|
||||
"classes_with_fraction": with_fraction,
|
||||
"classes_with_us_fraction": with_us_fraction,
|
||||
**physical_review_stats
|
||||
}
|
||||
|
||||
def get_classes_by_unit_measure(self, unit_of_measure: str) -> List[ClassBasicDTO]:
|
||||
"""Obtiene clases por unidad de medida"""
|
||||
classes = self.db.query(GClass).filter(GClass.unit_of_measure == unit_of_measure).all()
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
6
backend/api/v1/modules/a76/GParts/__init__.py
Normal file
6
backend/api/v1/modules/a76/GParts/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Tenants
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
182
backend/api/v1/modules/a76/GParts/dto.py
Normal file
182
backend/api/v1/modules/a76/GParts/dto.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de partes/componentes
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class PartCreateDTO(BaseModel):
|
||||
"""DTO para crear una parte"""
|
||||
client_key: int = Field(..., description="Client key")
|
||||
part_number: str = Field(..., max_length=49, description="Part number")
|
||||
fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction")
|
||||
description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish")
|
||||
description_english: Optional[str] = Field(None, max_length=500, description="Description in English")
|
||||
part_class: Optional[str] = Field(None, max_length=8, description="Part class")
|
||||
unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure")
|
||||
commercial_part_number: Optional[str] = Field(None, max_length=70, description="Commercial part number")
|
||||
country_of_origin: Optional[str] = Field(None, max_length=3, description="Country of origin code")
|
||||
|
||||
# Pricing and currency
|
||||
unit_cost: Optional[Decimal] = Field(None, description="Unit cost")
|
||||
currency_type: Optional[str] = Field(None, max_length=2, description="Currency type")
|
||||
currency_key: Optional[str] = Field(None, max_length=3, description="Currency key")
|
||||
|
||||
# Weight information
|
||||
unit_weight: Optional[Decimal] = Field(None, description="Unit weight")
|
||||
weight_type: Optional[str] = Field(None, max_length=6, description="Weight type")
|
||||
|
||||
# Classification and regulatory
|
||||
us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction")
|
||||
fda_key: Optional[str] = Field(None, max_length=20, description="FDA key")
|
||||
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
|
||||
license_code: Optional[str] = Field(None, max_length=3, description="License code")
|
||||
eccn: Optional[str] = Field(None, max_length=20, description="Export Control Classification Number")
|
||||
export_code: Optional[str] = Field(None, max_length=2, description="Export code")
|
||||
exclusion_symbol: Optional[str] = Field(None, max_length=19, description="Exclusion symbol")
|
||||
|
||||
# Additional information
|
||||
supplier: Optional[str] = Field(None, max_length=14, description="Supplier")
|
||||
alternate_unit_measure: Optional[str] = Field(None, max_length=14, description="Alternate unit of measure")
|
||||
added_value: Optional[Decimal] = Field(None, description="Added value")
|
||||
|
||||
# Status and media
|
||||
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
|
||||
creation_date: Optional[int] = Field(None, description="Creation date")
|
||||
part_photo: Optional[str] = Field(None, max_length=255, description="Part photo URL")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una parte"""
|
||||
fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction")
|
||||
description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish")
|
||||
description_english: Optional[str] = Field(None, max_length=500, description="Description in English")
|
||||
part_class: Optional[str] = Field(None, max_length=8, description="Part class")
|
||||
unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure")
|
||||
commercial_part_number: Optional[str] = Field(None, max_length=70, description="Commercial part number")
|
||||
country_of_origin: Optional[str] = Field(None, max_length=3, description="Country of origin code")
|
||||
|
||||
# Pricing and currency
|
||||
unit_cost: Optional[Decimal] = Field(None, description="Unit cost")
|
||||
currency_type: Optional[str] = Field(None, max_length=2, description="Currency type")
|
||||
currency_key: Optional[str] = Field(None, max_length=3, description="Currency key")
|
||||
|
||||
# Weight information
|
||||
unit_weight: Optional[Decimal] = Field(None, description="Unit weight")
|
||||
weight_type: Optional[str] = Field(None, max_length=6, description="Weight type")
|
||||
|
||||
# Classification and regulatory
|
||||
us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction")
|
||||
fda_key: Optional[str] = Field(None, max_length=20, description="FDA key")
|
||||
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
|
||||
license_code: Optional[str] = Field(None, max_length=3, description="License code")
|
||||
eccn: Optional[str] = Field(None, max_length=20, description="Export Control Classification Number")
|
||||
export_code: Optional[str] = Field(None, max_length=2, description="Export code")
|
||||
exclusion_symbol: Optional[str] = Field(None, max_length=19, description="Exclusion symbol")
|
||||
|
||||
# Additional information
|
||||
supplier: Optional[str] = Field(None, max_length=14, description="Supplier")
|
||||
alternate_unit_measure: Optional[str] = Field(None, max_length=14, description="Alternate unit of measure")
|
||||
added_value: Optional[Decimal] = Field(None, description="Added value")
|
||||
|
||||
# Status and media
|
||||
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
|
||||
part_photo: Optional[str] = Field(None, max_length=255, description="Part photo URL")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de parte"""
|
||||
client_key: int
|
||||
part_number: str
|
||||
fraction: Optional[str] = None
|
||||
description_spanish: Optional[str] = None
|
||||
description_english: Optional[str] = None
|
||||
part_class: Optional[str] = None
|
||||
unit_of_measure: Optional[str] = None
|
||||
commercial_part_number: Optional[str] = None
|
||||
country_of_origin: Optional[str] = None
|
||||
|
||||
# Pricing and currency
|
||||
unit_cost: Optional[Decimal] = None
|
||||
currency_type: Optional[str] = None
|
||||
currency_key: Optional[str] = None
|
||||
|
||||
# Weight information
|
||||
unit_weight: Optional[Decimal] = None
|
||||
weight_type: Optional[str] = None
|
||||
|
||||
# Classification and regulatory
|
||||
us_fraction: Optional[str] = None
|
||||
fda_key: Optional[str] = None
|
||||
fcc_key: Optional[str] = None
|
||||
license_code: Optional[str] = None
|
||||
eccn: Optional[str] = None
|
||||
export_code: Optional[str] = None
|
||||
exclusion_symbol: Optional[str] = None
|
||||
|
||||
# Additional information
|
||||
supplier: Optional[str] = None
|
||||
alternate_unit_measure: Optional[str] = None
|
||||
added_value: Optional[Decimal] = None
|
||||
|
||||
# Status and dates
|
||||
enabled_disabled: Optional[int] = None
|
||||
creation_date: Optional[int] = None
|
||||
modification_date: Optional[int] = None
|
||||
modification_date_iso: Optional[datetime] = None
|
||||
|
||||
# Media
|
||||
part_photo: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartBasicDTO(BaseModel):
|
||||
"""DTO para información básica de parte"""
|
||||
client_key: int
|
||||
part_number: str
|
||||
description_spanish: Optional[str] = None
|
||||
description_english: Optional[str] = None
|
||||
part_class: Optional[str] = None
|
||||
unit_cost: Optional[Decimal] = None
|
||||
currency_key: Optional[str] = None
|
||||
enabled_disabled: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartListDTO(BaseModel):
|
||||
"""DTO para lista de partes"""
|
||||
parts: list[PartBasicDTO]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartSearchDTO(BaseModel):
|
||||
"""DTO para búsqueda de partes"""
|
||||
client_key: Optional[int] = Field(None, description="Filter by client key")
|
||||
part_number: Optional[str] = Field(None, description="Search by part number")
|
||||
description: Optional[str] = Field(None, description="Search in descriptions")
|
||||
fraction: Optional[str] = Field(None, description="Filter by tariff fraction")
|
||||
supplier: Optional[str] = Field(None, description="Filter by supplier")
|
||||
enabled_only: bool = Field(False, description="Show only enabled parts")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
87
backend/api/v1/modules/a76/GParts/models.py
Normal file
87
backend/api/v1/modules/a76/GParts/models.py
Normal file
@@ -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"<GPart(client_key={self.client_key}, part_number='{self.part_number}', description='{self.description_spanish}')>"
|
||||
|
||||
|
||||
273
backend/api/v1/modules/a76/GParts/routes.py
Normal file
273
backend/api/v1/modules/a76/GParts/routes.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Endpoints API para gestión de partes/componentes
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, has_role
|
||||
from .service import PartService
|
||||
from .dto import (
|
||||
PartCreateDTO,
|
||||
PartUpdateDTO,
|
||||
PartResponseDTO,
|
||||
PartBasicDTO,
|
||||
PartListDTO,
|
||||
PartSearchDTO
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/parts", tags=["Parts"])
|
||||
|
||||
|
||||
@router.post("/", response_model=PartResponseDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def create_part(
|
||||
part_data: PartCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new part in the system
|
||||
"""
|
||||
service = PartService(db)
|
||||
return service.create_part(part_data)
|
||||
|
||||
|
||||
@router.get("/", response_model=PartListDTO)
|
||||
async def list_parts(
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
client_key: Optional[int] = Query(None, description="Filter by client key"),
|
||||
part_number: Optional[str] = Query(None, description="Search by part number"),
|
||||
description: Optional[str] = Query(None, description="Search in descriptions"),
|
||||
fraction: Optional[str] = Query(None, description="Filter by tariff fraction"),
|
||||
supplier: Optional[str] = Query(None, description="Filter by supplier"),
|
||||
enabled_only: bool = Query(False, description="Show only enabled parts"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List parts with optional filters and pagination
|
||||
"""
|
||||
service = PartService(db)
|
||||
search_params = PartSearchDTO(
|
||||
client_key=client_key,
|
||||
part_number=part_number,
|
||||
description=description,
|
||||
fraction=fraction,
|
||||
supplier=supplier,
|
||||
enabled_only=enabled_only
|
||||
)
|
||||
return service.list_parts(skip, limit, search_params)
|
||||
|
||||
|
||||
@router.get("/client/{client_key}", response_model=List[PartBasicDTO])
|
||||
async def get_parts_by_client(
|
||||
client_key: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get all parts for a specific client
|
||||
"""
|
||||
service = PartService(db)
|
||||
return service.search_by_client(client_key, skip, limit)
|
||||
|
||||
|
||||
@router.get("/search/fraction/{fraction}", response_model=List[PartBasicDTO])
|
||||
async def search_by_fraction(
|
||||
fraction: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Search parts by tariff fraction
|
||||
"""
|
||||
service = PartService(db)
|
||||
return service.search_by_fraction(fraction)
|
||||
|
||||
|
||||
@router.get("/search/supplier/{supplier}", response_model=List[PartBasicDTO])
|
||||
async def search_by_supplier(
|
||||
supplier: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Search parts by supplier
|
||||
"""
|
||||
service = PartService(db)
|
||||
return service.search_by_supplier(supplier)
|
||||
|
||||
|
||||
@router.get("/search/country/{country_code}", response_model=List[PartBasicDTO])
|
||||
async def get_parts_by_country(
|
||||
country_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get parts by country of origin
|
||||
"""
|
||||
service = PartService(db)
|
||||
return service.get_parts_by_country(country_code)
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=dict)
|
||||
async def get_parts_statistics(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get basic parts statistics
|
||||
"""
|
||||
service = PartService(db)
|
||||
return service.get_parts_statistics()
|
||||
|
||||
|
||||
@router.get("/{client_key}/{part_number}", response_model=PartResponseDTO)
|
||||
async def get_part(
|
||||
client_key: int,
|
||||
part_number: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get part by composite key (client_key + part_number)
|
||||
"""
|
||||
service = PartService(db)
|
||||
part = service.get_part(client_key, part_number)
|
||||
if not part:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
|
||||
)
|
||||
return part
|
||||
|
||||
|
||||
@router.put("/{client_key}/{part_number}", response_model=PartResponseDTO)
|
||||
async def update_part(
|
||||
client_key: int,
|
||||
part_number: str,
|
||||
part_data: PartUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update part information
|
||||
"""
|
||||
service = PartService(db)
|
||||
part = service.update_part(client_key, part_number, part_data)
|
||||
if not part:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
|
||||
)
|
||||
return part
|
||||
|
||||
|
||||
@router.delete("/{client_key}/{part_number}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_part(
|
||||
client_key: int,
|
||||
part_number: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete part from the system
|
||||
|
||||
Note: This will completely remove the part from the system.
|
||||
"""
|
||||
service = PartService(db)
|
||||
if not service.delete_part(client_key, part_number):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{client_key}/{part_number}/toggle-status", response_model=PartResponseDTO)
|
||||
async def toggle_part_status(
|
||||
client_key: int,
|
||||
part_number: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Toggle part enabled/disabled status
|
||||
"""
|
||||
service = PartService(db)
|
||||
part = service.toggle_status(client_key, part_number)
|
||||
if not part:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
|
||||
)
|
||||
return part
|
||||
|
||||
|
||||
# Endpoints específicos para información detallada
|
||||
@router.get("/{client_key}/{part_number}/basic", response_model=PartBasicDTO)
|
||||
async def get_part_basic_info(
|
||||
client_key: int,
|
||||
part_number: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get basic information for a part
|
||||
"""
|
||||
service = PartService(db)
|
||||
part = service.get_part(client_key, part_number)
|
||||
if not part:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
|
||||
)
|
||||
|
||||
return PartBasicDTO(
|
||||
client_key=part.client_key,
|
||||
part_number=part.part_number,
|
||||
description_spanish=part.description_spanish,
|
||||
description_english=part.description_english,
|
||||
part_class=part.part_class,
|
||||
unit_cost=part.unit_cost,
|
||||
currency_key=part.currency_key,
|
||||
enabled_disabled=part.enabled_disabled
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{client_key}/{part_number}/regulatory", response_model=dict)
|
||||
async def get_part_regulatory_info(
|
||||
client_key: int,
|
||||
part_number: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get regulatory information for a part (FDA, FCC, ECCN, etc.)
|
||||
"""
|
||||
service = PartService(db)
|
||||
part = service.get_part(client_key, part_number)
|
||||
if not part:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"client_key": part.client_key,
|
||||
"part_number": part.part_number,
|
||||
"fraction": part.fraction,
|
||||
"us_fraction": part.us_fraction,
|
||||
"fda_key": part.fda_key,
|
||||
"fcc_key": part.fcc_key,
|
||||
"license_code": part.license_code,
|
||||
"eccn": part.eccn,
|
||||
"export_code": part.export_code,
|
||||
"exclusion_symbol": part.exclusion_symbol
|
||||
}
|
||||
|
||||
|
||||
15
backend/api/v1/modules/a76/GParts/service.py
Normal file
15
backend/api/v1/modules/a76/GParts/service.py
Normal file
@@ -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__)
|
||||
|
||||
6
backend/api/v1/modules/a76/client_&_provider/__init__.py
Normal file
6
backend/api/v1/modules/a76/client_&_provider/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Tenants
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
165
backend/api/v1/modules/a76/client_&_provider/dto.py
Normal file
165
backend/api/v1/modules/a76/client_&_provider/dto.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de clientes y proveedores
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
# DTOs para dirección
|
||||
class ClientProviderAddressDTO(BaseModel):
|
||||
"""DTO para dirección de cliente/proveedor"""
|
||||
municipality: Optional[str] = Field(None, max_length=150, description="Municipality")
|
||||
streets: Optional[str] = Field(None, max_length=100, description="Streets")
|
||||
neighborhood: Optional[str] = Field(None, max_length=40, description="Neighborhood")
|
||||
interior_number: Optional[str] = Field(None, max_length=20, description="Interior number")
|
||||
exterior_number: Optional[str] = Field(None, max_length=20, description="Exterior number")
|
||||
postal_code: Optional[str] = Field(None, max_length=15, description="Postal code")
|
||||
city: Optional[str] = Field(None, max_length=30, description="City")
|
||||
state: Optional[str] = Field(None, max_length=30, description="State")
|
||||
country: Optional[str] = Field(None, max_length=3, description="Country code")
|
||||
phone: Optional[str] = Field(None, max_length=30, description="Phone number")
|
||||
fax_number: Optional[str] = Field(None, max_length=30, description="Fax number")
|
||||
email: Optional[str] = Field(None, max_length=100, description="Email address")
|
||||
contact: Optional[str] = Field(None, max_length=50, description="Contact person")
|
||||
reference: Optional[str] = Field(None, max_length=250, description="Reference")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# DTOs para programas
|
||||
class ClientProviderProgramsDTO(BaseModel):
|
||||
"""DTO para programas de cliente/proveedor"""
|
||||
program: Optional[str] = Field(None, max_length=7, description="Program")
|
||||
program_number: Optional[str] = Field(None, max_length=40, description="Program number")
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(None, max_length=20, description="PROSEC authorization")
|
||||
secon_auth_date: Optional[int] = Field(None, description="SECON authorization date")
|
||||
manufacturer_id: Optional[str] = Field(None, max_length=25, description="Manufacturer ID")
|
||||
tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID")
|
||||
broker: Optional[str] = Field(None, max_length=6, description="Broker")
|
||||
import_broker: Optional[str] = Field(None, max_length=6, description="Import broker")
|
||||
transfer_key: Optional[str] = Field(None, max_length=8, description="Transfer key")
|
||||
secon_authorization: Optional[str] = Field(None, max_length=20, description="SECON authorization")
|
||||
applied_proportion: Optional[Decimal] = Field(None, description="Applied proportion")
|
||||
is_certified_company: Optional[str] = Field(None, max_length=1, description="Is certified company")
|
||||
certified_company_registry: Optional[str] = Field(None, max_length=40, description="Certified company registry")
|
||||
donation_auth_number: Optional[str] = Field(None, max_length=50, description="Donation authorization number")
|
||||
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
|
||||
tax_registry_number: Optional[str] = Field(None, max_length=40, description="Tax registry number")
|
||||
subassembly_service: Optional[int] = Field(None, description="Subassembly service")
|
||||
autse_dates: Optional[int] = Field(None, description="AUTSE dates")
|
||||
autse_number: Optional[str] = Field(None, max_length=300, description="AUTSE number")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# DTOs principales
|
||||
class ClientProviderCreateDTO(BaseModel):
|
||||
"""DTO para crear cliente/proveedor"""
|
||||
client_id: str = Field(..., max_length=8, description="Client ID")
|
||||
type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign")
|
||||
name: Optional[str] = Field(None, max_length=256, description="Name")
|
||||
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider")
|
||||
linking: Optional[str] = Field(None, max_length=1, description="Linking")
|
||||
transform_subassembly: Optional[str] = Field(None, max_length=1, description="Transform subassembly")
|
||||
extra_information: Optional[str] = Field(None, max_length=399, description="Extra information")
|
||||
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
|
||||
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
|
||||
position: Optional[str] = Field(None, max_length=30, description="Position")
|
||||
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
|
||||
is_national_provider: Optional[str] = Field(None, max_length=2, description="Is national provider")
|
||||
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
|
||||
|
||||
# Nested DTOs
|
||||
address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information")
|
||||
programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientProviderUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar cliente/proveedor"""
|
||||
type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign")
|
||||
name: Optional[str] = Field(None, max_length=256, description="Name")
|
||||
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider")
|
||||
linking: Optional[str] = Field(None, max_length=1, description="Linking")
|
||||
transform_subassembly: Optional[str] = Field(None, max_length=1, description="Transform subassembly")
|
||||
extra_information: Optional[str] = Field(None, max_length=399, description="Extra information")
|
||||
web_key: Optional[str] = Field(None, max_length=40, description="Web key")
|
||||
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
|
||||
position: Optional[str] = Field(None, max_length=30, description="Position")
|
||||
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
|
||||
is_national_provider: Optional[str] = Field(None, max_length=2, description="Is national provider")
|
||||
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
|
||||
|
||||
# Nested DTOs
|
||||
address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information")
|
||||
programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientProviderResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de cliente/proveedor"""
|
||||
client_id: str
|
||||
type_nat_foreign: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
short_name: Optional[str] = None
|
||||
rfc: Optional[str] = None
|
||||
curp: Optional[str] = None
|
||||
client_or_provider: Optional[str] = None
|
||||
linking: Optional[str] = None
|
||||
transform_subassembly: Optional[str] = None
|
||||
extra_information: Optional[str] = None
|
||||
web_key: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
incoterm: Optional[str] = None
|
||||
is_national_provider: Optional[str] = None
|
||||
enabled_disabled: Optional[int] = None
|
||||
|
||||
# Nested DTOs
|
||||
address: Optional[ClientProviderAddressDTO] = None
|
||||
programs: Optional[ClientProviderProgramsDTO] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# DTOs para respuestas específicas
|
||||
class ClientProviderBasicDTO(BaseModel):
|
||||
"""DTO para información básica de cliente/proveedor"""
|
||||
client_id: str
|
||||
name: Optional[str] = None
|
||||
short_name: Optional[str] = None
|
||||
rfc: Optional[str] = None
|
||||
client_or_provider: Optional[str] = None
|
||||
enabled_disabled: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientProviderListDTO(BaseModel):
|
||||
"""DTO para lista de clientes/proveedores"""
|
||||
clients: list[ClientProviderBasicDTO]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
105
backend/api/v1/modules/a76/client_&_provider/models.py
Normal file
105
backend/api/v1/modules/a76/client_&_provider/models.py
Normal file
@@ -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")
|
||||
|
||||
|
||||
221
backend/api/v1/modules/a76/client_&_provider/routes.py
Normal file
221
backend/api/v1/modules/a76/client_&_provider/routes.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Endpoints API para gestión de clientes y proveedores
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, has_role
|
||||
from .service import ClientProviderService
|
||||
from .dto import (
|
||||
ClientProviderCreateDTO,
|
||||
ClientProviderUpdateDTO,
|
||||
ClientProviderResponseDTO,
|
||||
ClientProviderBasicDTO,
|
||||
ClientProviderListDTO
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/clients-providers", tags=["Clients & Providers"])
|
||||
|
||||
|
||||
@router.post("/", response_model=ClientProviderResponseDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def create_client_provider(
|
||||
client_data: ClientProviderCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new client or provider in the system
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
return service.create_client_provider(client_data)
|
||||
|
||||
|
||||
@router.get("/", response_model=ClientProviderListDTO)
|
||||
async def list_clients_providers(
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
search: Optional[str] = Query(None, description="Search text for name, RFC, or ID"),
|
||||
client_or_provider: Optional[str] = Query(None, regex="^[CP]$", description="Filter by type: C=Client, P=Provider"),
|
||||
enabled_only: bool = Query(False, description="Show only enabled records"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List clients and providers with optional filters and pagination
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
return service.list_clients_providers(skip, limit, search, client_or_provider, enabled_only)
|
||||
|
||||
|
||||
@router.get("/clients", response_model=List[ClientProviderBasicDTO])
|
||||
async def get_clients_only(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get only clients (client_or_provider = 'C')
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
return service.get_clients_only(skip, limit)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=List[ClientProviderBasicDTO])
|
||||
async def get_providers_only(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get only providers (client_or_provider = 'P')
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
return service.get_providers_only(skip, limit)
|
||||
|
||||
|
||||
@router.get("/search/rfc/{rfc}", response_model=List[ClientProviderBasicDTO])
|
||||
async def search_by_rfc(
|
||||
rfc: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Search clients/providers by RFC
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
return service.search_by_rfc(rfc)
|
||||
|
||||
|
||||
@router.get("/{client_id}", response_model=ClientProviderResponseDTO)
|
||||
async def get_client_provider(
|
||||
client_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get client/provider by ID with all related information
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
client = service.get_client_provider(client_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
|
||||
return client
|
||||
|
||||
|
||||
@router.put("/{client_id}", response_model=ClientProviderResponseDTO)
|
||||
async def update_client_provider(
|
||||
client_id: str,
|
||||
client_data: ClientProviderUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update client/provider information
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
client = service.update_client_provider(client_id, client_data)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
|
||||
return client
|
||||
|
||||
|
||||
@router.delete("/{client_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_client_provider(
|
||||
client_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete client/provider from the system
|
||||
|
||||
Note: This will completely remove the client/provider and all related data.
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
if not service.delete_client_provider(client_id):
|
||||
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
|
||||
|
||||
|
||||
@router.patch("/{client_id}/toggle-status", response_model=ClientProviderResponseDTO)
|
||||
async def toggle_client_provider_status(
|
||||
client_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Toggle client/provider enabled/disabled status
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
client = service.toggle_status(client_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
|
||||
return client
|
||||
|
||||
|
||||
# Endpoints específicos para información detallada
|
||||
@router.get("/{client_id}/address", response_model=dict)
|
||||
async def get_client_provider_address(
|
||||
client_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get only address information for a client/provider
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
client = service.get_client_provider(client_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
|
||||
|
||||
return {
|
||||
"client_id": client.client_id,
|
||||
"address": client.address
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{client_id}/programs", response_model=dict)
|
||||
async def get_client_provider_programs(
|
||||
client_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get only programs information for a client/provider
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
client = service.get_client_provider(client_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
|
||||
|
||||
return {
|
||||
"client_id": client.client_id,
|
||||
"programs": client.programs
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO)
|
||||
async def get_client_provider_basic_info(
|
||||
client_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get basic information for a client/provider (without address and programs)
|
||||
"""
|
||||
service = ClientProviderService(db)
|
||||
client = service.get_client_provider(client_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
|
||||
|
||||
return ClientProviderBasicDTO(
|
||||
client_id=client.client_id,
|
||||
name=client.name,
|
||||
short_name=client.short_name,
|
||||
rfc=client.rfc,
|
||||
client_or_provider=client.client_or_provider,
|
||||
enabled_disabled=client.enabled_disabled
|
||||
)
|
||||
|
||||
309
backend/api/v1/modules/a76/client_&_provider/service.py
Normal file
309
backend/api/v1/modules/a76/client_&_provider/service.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de clientes y proveedores
|
||||
"""
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import or_, and_
|
||||
from fastapi import HTTPException
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
from .models import GClientProvider, GClientProviderAddress, GClientProviderPrograms
|
||||
from .dto import (
|
||||
ClientProviderCreateDTO,
|
||||
ClientProviderUpdateDTO,
|
||||
ClientProviderResponseDTO,
|
||||
ClientProviderBasicDTO,
|
||||
ClientProviderListDTO,
|
||||
ClientProviderAddressDTO,
|
||||
ClientProviderProgramsDTO
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientProviderService:
|
||||
"""Servicio para gestión de clientes y proveedores"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_client_provider(self, client_data: ClientProviderCreateDTO) -> ClientProviderResponseDTO:
|
||||
"""
|
||||
Crea un nuevo cliente/proveedor en el sistema
|
||||
|
||||
Args:
|
||||
client_data: Datos del cliente/proveedor a crear
|
||||
|
||||
Returns:
|
||||
ClientProviderResponseDTO con información del cliente/proveedor creado
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el cliente ya existe o error en la creación
|
||||
"""
|
||||
try:
|
||||
# Verificar que no exista el cliente
|
||||
existing = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_data.client_id).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Client with ID '{client_data.client_id}' already exists")
|
||||
|
||||
# Crear cliente/proveedor principal
|
||||
db_client = GClientProvider(
|
||||
client_id=client_data.client_id,
|
||||
type_nat_foreign=client_data.type_nat_foreign,
|
||||
name=client_data.name,
|
||||
short_name=client_data.short_name,
|
||||
rfc=client_data.rfc,
|
||||
curp=client_data.curp,
|
||||
client_or_provider=client_data.client_or_provider,
|
||||
linking=client_data.linking,
|
||||
transform_subassembly=client_data.transform_subassembly,
|
||||
extra_information=client_data.extra_information,
|
||||
web_key=client_data.web_key,
|
||||
responsible=client_data.responsible,
|
||||
position=client_data.position,
|
||||
incoterm=client_data.incoterm,
|
||||
is_national_provider=client_data.is_national_provider,
|
||||
enabled_disabled=client_data.enabled_disabled
|
||||
)
|
||||
|
||||
self.db.add(db_client)
|
||||
self.db.flush() # Para obtener el ID antes del commit
|
||||
|
||||
# Crear dirección si se proporciona
|
||||
if client_data.address:
|
||||
db_address = GClientProviderAddress(
|
||||
client_id=client_data.client_id,
|
||||
**client_data.address.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(db_address)
|
||||
|
||||
# Crear programas si se proporciona
|
||||
if client_data.programs:
|
||||
db_programs = GClientProviderPrograms(
|
||||
client_id=client_data.client_id,
|
||||
**client_data.programs.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(db_programs)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_client)
|
||||
|
||||
logger.info(f"Client/Provider created: {db_client.client_id} - {db_client.name}")
|
||||
|
||||
return self._get_client_with_relations(client_data.client_id)
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating client/provider: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail="Client/Provider with this ID already exists")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating client/provider: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error creating client/provider")
|
||||
|
||||
def get_client_provider(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
|
||||
"""
|
||||
Obtiene un cliente/proveedor por ID
|
||||
|
||||
Args:
|
||||
client_id: ID del cliente/proveedor
|
||||
|
||||
Returns:
|
||||
ClientProviderResponseDTO o None si no existe
|
||||
"""
|
||||
return self._get_client_with_relations(client_id)
|
||||
|
||||
def _get_client_with_relations(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
|
||||
"""Método privado para obtener cliente con relaciones"""
|
||||
client = self.db.query(GClientProvider).options(
|
||||
joinedload(GClientProvider.address),
|
||||
joinedload(GClientProvider.programs)
|
||||
).filter(GClientProvider.client_id == client_id).first()
|
||||
|
||||
if not client:
|
||||
return None
|
||||
return ClientProviderResponseDTO.model_validate(client)
|
||||
|
||||
def list_clients_providers(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None,
|
||||
client_or_provider: Optional[str] = None,
|
||||
enabled_only: bool = False
|
||||
) -> ClientProviderListDTO:
|
||||
"""
|
||||
Lista clientes/proveedores con filtros
|
||||
|
||||
Args:
|
||||
skip: Número de registros a omitir
|
||||
limit: Número máximo de registros a retornar
|
||||
search: Texto de búsqueda (nombre, RFC, ID)
|
||||
client_or_provider: Filtrar por tipo (C=Cliente, P=Proveedor)
|
||||
enabled_only: Si True, solo retorna activos
|
||||
|
||||
Returns:
|
||||
ClientProviderListDTO con la lista paginada
|
||||
"""
|
||||
query = self.db.query(GClientProvider)
|
||||
|
||||
# Aplicar filtros
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
GClientProvider.name.ilike(search_pattern),
|
||||
GClientProvider.short_name.ilike(search_pattern),
|
||||
GClientProvider.rfc.ilike(search_pattern),
|
||||
GClientProvider.client_id.ilike(search_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
if client_or_provider:
|
||||
query = query.filter(GClientProvider.client_or_provider == client_or_provider)
|
||||
|
||||
if enabled_only:
|
||||
query = query.filter(GClientProvider.enabled_disabled == 1)
|
||||
|
||||
# Contar total
|
||||
total = query.count()
|
||||
|
||||
# Aplicar paginación
|
||||
clients = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Convertir a DTOs básicos
|
||||
client_dtos = [ClientProviderBasicDTO.model_validate(client) for client in clients]
|
||||
|
||||
return ClientProviderListDTO(
|
||||
clients=client_dtos,
|
||||
total=total,
|
||||
page=(skip // limit) + 1 if limit > 0 else 1,
|
||||
size=len(client_dtos)
|
||||
)
|
||||
|
||||
def update_client_provider(self, client_id: str, client_data: ClientProviderUpdateDTO) -> Optional[ClientProviderResponseDTO]:
|
||||
"""
|
||||
Actualiza un cliente/proveedor
|
||||
|
||||
Args:
|
||||
client_id: ID del cliente/proveedor a actualizar
|
||||
client_data: Datos a actualizar
|
||||
|
||||
Returns:
|
||||
ClientProviderResponseDTO actualizado o None si no existe
|
||||
"""
|
||||
client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first()
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Actualizar campos del cliente principal
|
||||
update_data = client_data.model_dump(exclude_unset=True, exclude={'address', 'programs'})
|
||||
for field, value in update_data.items():
|
||||
setattr(client, field, value)
|
||||
|
||||
# Actualizar dirección
|
||||
if client_data.address:
|
||||
address = self.db.query(GClientProviderAddress).filter(GClientProviderAddress.client_id == client_id).first()
|
||||
if address:
|
||||
# Actualizar dirección existente
|
||||
address_data = client_data.address.model_dump(exclude_unset=True)
|
||||
for field, value in address_data.items():
|
||||
setattr(address, field, value)
|
||||
else:
|
||||
# Crear nueva dirección
|
||||
address = GClientProviderAddress(
|
||||
client_id=client_id,
|
||||
**client_data.address.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(address)
|
||||
|
||||
# Actualizar programas
|
||||
if client_data.programs:
|
||||
programs = self.db.query(GClientProviderPrograms).filter(GClientProviderPrograms.client_id == client_id).first()
|
||||
if programs:
|
||||
# Actualizar programas existentes
|
||||
programs_data = client_data.programs.model_dump(exclude_unset=True)
|
||||
for field, value in programs_data.items():
|
||||
setattr(programs, field, value)
|
||||
else:
|
||||
# Crear nuevos programas
|
||||
programs = GClientProviderPrograms(
|
||||
client_id=client_id,
|
||||
**client_data.programs.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(programs)
|
||||
|
||||
self.db.commit()
|
||||
logger.info(f"Client/Provider updated: {client_id}")
|
||||
|
||||
return self._get_client_with_relations(client_id)
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating client/provider")
|
||||
|
||||
def delete_client_provider(self, client_id: str) -> bool:
|
||||
"""
|
||||
Elimina un cliente/proveedor
|
||||
|
||||
Args:
|
||||
client_id: ID del cliente/proveedor a eliminar
|
||||
|
||||
Returns:
|
||||
True si se eliminó, False si no existe
|
||||
"""
|
||||
client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first()
|
||||
if not client:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.db.delete(client) # Las relaciones se eliminan en cascada
|
||||
self.db.commit()
|
||||
logger.info(f"Client/Provider deleted: {client_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting client/provider")
|
||||
|
||||
def get_clients_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]:
|
||||
"""Obtiene solo clientes (C)"""
|
||||
query = self.db.query(GClientProvider).filter(GClientProvider.client_or_provider == 'C')
|
||||
clients = query.offset(skip).limit(limit).all()
|
||||
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
|
||||
|
||||
def get_providers_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]:
|
||||
"""Obtiene solo proveedores (P)"""
|
||||
query = self.db.query(GClientProvider).filter(GClientProvider.client_or_provider == 'P')
|
||||
providers = query.offset(skip).limit(limit).all()
|
||||
return [ClientProviderBasicDTO.model_validate(provider) for provider in providers]
|
||||
|
||||
def search_by_rfc(self, rfc: str) -> List[ClientProviderBasicDTO]:
|
||||
"""Busca clientes/proveedores por RFC"""
|
||||
clients = self.db.query(GClientProvider).filter(GClientProvider.rfc.ilike(f"%{rfc}%")).all()
|
||||
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
|
||||
|
||||
def toggle_status(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
|
||||
"""Cambia el estado habilitado/deshabilitado"""
|
||||
client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first()
|
||||
if not client:
|
||||
return None
|
||||
|
||||
# Toggle status (1 = habilitado, 0 = deshabilitado)
|
||||
client.enabled_disabled = 1 if client.enabled_disabled == 0 else 0
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
logger.info(f"Client/Provider status toggled: {client_id} -> {client.enabled_disabled}")
|
||||
return self._get_client_with_relations(client_id)
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error toggling status for {client_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating status")
|
||||
|
||||
|
||||
6
backend/api/v1/modules/a76/company/__init__.py
Normal file
6
backend/api/v1/modules/a76/company/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Tenants
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
157
backend/api/v1/modules/a76/company/dto.py
Normal file
157
backend/api/v1/modules/a76/company/dto.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de empresa
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CompanyCreateDTO(BaseModel):
|
||||
"""DTO para crear una empresa"""
|
||||
id: str = Field(default='EMP', max_length=3, description="Company ID")
|
||||
consecutive: bool = Field(default=True, description="Unique record control")
|
||||
name: Optional[str] = Field(None, max_length=255, description="Company name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
|
||||
main_activity: Optional[str] = Field(None, max_length=255, description="Main activity")
|
||||
|
||||
# Program information
|
||||
program: Optional[str] = Field(None, max_length=10, description="Program")
|
||||
program_number: Optional[str] = Field(None, max_length=40, description="Program number")
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(None, max_length=20, description="PROSEC authorization")
|
||||
|
||||
# Identifiers
|
||||
manufacturer_id: Optional[str] = Field(None, max_length=25, description="Manufacturer ID")
|
||||
broker_company: Optional[str] = Field(None, max_length=10, description="Broker company")
|
||||
|
||||
# Responsible person
|
||||
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
|
||||
responsible_name: Optional[str] = Field(None, max_length=20, description="Responsible first name")
|
||||
responsible_last_name: Optional[str] = Field(None, max_length=20, description="Responsible last name")
|
||||
responsible_mother_last_name: Optional[str] = Field(None, max_length=20, description="Responsible mother's last name")
|
||||
responsible_rfc: Optional[str] = Field(None, max_length=30, description="Responsible RFC")
|
||||
position: Optional[str] = Field(None, max_length=30, description="Responsible position")
|
||||
|
||||
# Configuration
|
||||
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
|
||||
has_express_line: Optional[bool] = Field(None, description="Has express line")
|
||||
order_format_type: Optional[str] = Field(None, max_length=19, description="Order format type")
|
||||
previous_code: Optional[int] = Field(None, description="Previous code")
|
||||
is_service_company: Optional[bool] = Field(None, description="Is service company")
|
||||
|
||||
# Client and subassembly
|
||||
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
|
||||
subassembly_mode: Optional[str] = Field(None, max_length=7, description="Subassembly mode")
|
||||
|
||||
# Additional information
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
inter_db_name: Optional[str] = Field(None, max_length=100, description="Inter DB name")
|
||||
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
|
||||
trusted_exporter_number: Optional[str] = Field(None, max_length=50, description="Trusted exporter number")
|
||||
prevalidator_key: Optional[str] = Field(None, max_length=20, description="Prevalidator key")
|
||||
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CompanyUpdateDTO(BaseModel):
|
||||
"""DTO para actualizar una empresa"""
|
||||
name: Optional[str] = Field(None, max_length=255, description="Company name")
|
||||
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
|
||||
main_activity: Optional[str] = Field(None, max_length=255, description="Main activity")
|
||||
|
||||
# Program information
|
||||
program: Optional[str] = Field(None, max_length=10, description="Program")
|
||||
program_number: Optional[str] = Field(None, max_length=40, description="Program number")
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(None, max_length=20, description="PROSEC authorization")
|
||||
|
||||
# Identifiers
|
||||
manufacturer_id: Optional[str] = Field(None, max_length=25, description="Manufacturer ID")
|
||||
broker_company: Optional[str] = Field(None, max_length=10, description="Broker company")
|
||||
|
||||
# Responsible person
|
||||
responsible: Optional[str] = Field(None, max_length=80, description="Responsible person")
|
||||
responsible_name: Optional[str] = Field(None, max_length=20, description="Responsible first name")
|
||||
responsible_last_name: Optional[str] = Field(None, max_length=20, description="Responsible last name")
|
||||
responsible_mother_last_name: Optional[str] = Field(None, max_length=20, description="Responsible mother's last name")
|
||||
responsible_rfc: Optional[str] = Field(None, max_length=30, description="Responsible RFC")
|
||||
position: Optional[str] = Field(None, max_length=30, description="Responsible position")
|
||||
|
||||
# Configuration
|
||||
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
|
||||
has_express_line: Optional[bool] = Field(None, description="Has express line")
|
||||
order_format_type: Optional[str] = Field(None, max_length=19, description="Order format type")
|
||||
previous_code: Optional[int] = Field(None, description="Previous code")
|
||||
is_service_company: Optional[bool] = Field(None, description="Is service company")
|
||||
|
||||
# Client and subassembly
|
||||
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
|
||||
subassembly_mode: Optional[str] = Field(None, max_length=7, description="Subassembly mode")
|
||||
|
||||
# Additional information
|
||||
curp: Optional[str] = Field(None, max_length=19, description="CURP")
|
||||
inter_db_name: Optional[str] = Field(None, max_length=100, description="Inter DB name")
|
||||
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
|
||||
trusted_exporter_number: Optional[str] = Field(None, max_length=50, description="Trusted exporter number")
|
||||
prevalidator_key: Optional[str] = Field(None, max_length=20, description="Prevalidator key")
|
||||
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CompanyResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de empresa"""
|
||||
id: str
|
||||
consecutive: bool
|
||||
name: Optional[str] = None
|
||||
rfc: Optional[str] = None
|
||||
main_activity: Optional[str] = None
|
||||
|
||||
# Program information
|
||||
program: Optional[str] = None
|
||||
program_number: Optional[str] = None
|
||||
prosec: Optional[int] = None
|
||||
prosec_authorization: Optional[str] = None
|
||||
|
||||
# Identifiers
|
||||
manufacturer_id: Optional[str] = None
|
||||
broker_company: Optional[str] = None
|
||||
|
||||
# Responsible person
|
||||
responsible: Optional[str] = None
|
||||
responsible_name: Optional[str] = None
|
||||
responsible_last_name: Optional[str] = None
|
||||
responsible_mother_last_name: Optional[str] = None
|
||||
responsible_rfc: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
|
||||
# Configuration
|
||||
logo: Optional[str] = None
|
||||
has_express_line: Optional[bool] = None
|
||||
order_format_type: Optional[str] = None
|
||||
previous_code: Optional[int] = None
|
||||
is_service_company: Optional[bool] = None
|
||||
|
||||
# Client and subassembly
|
||||
client_name: Optional[str] = None
|
||||
subassembly_mode: Optional[str] = None
|
||||
|
||||
# Additional information
|
||||
curp: Optional[str] = None
|
||||
inter_db_name: Optional[str] = None
|
||||
ctpat_svi: Optional[str] = None
|
||||
trusted_exporter_number: Optional[str] = None
|
||||
prevalidator_key: Optional[str] = None
|
||||
seventh_amendment: Optional[bool] = None
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
68
backend/api/v1/modules/a76/company/models.py
Normal file
68
backend/api/v1/modules/a76/company/models.py
Normal file
@@ -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)
|
||||
|
||||
|
||||
176
backend/api/v1/modules/a76/company/routes.py
Normal file
176
backend/api/v1/modules/a76/company/routes.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Endpoints API para gestión de empresa
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, has_role
|
||||
from .service import CompanyService
|
||||
from .dto import CompanyCreateDTO, CompanyUpdateDTO, CompanyResponseDTO
|
||||
|
||||
router = APIRouter(prefix="/company", tags=["Company"])
|
||||
|
||||
|
||||
@router.post("/", response_model=CompanyResponseDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def create_company(
|
||||
company_data: CompanyCreateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new company in the system
|
||||
|
||||
Only one company can exist per system due to the unique consecutive field.
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
return service.create_company(company_data)
|
||||
|
||||
|
||||
@router.get("/", response_model=Optional[CompanyResponseDTO])
|
||||
async def get_company(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get the registered company information
|
||||
|
||||
Returns the unique company in the system or None if it doesn't exist.
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
company = service.get_company()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="No company found")
|
||||
return company
|
||||
|
||||
|
||||
@router.get("/{company_id}", response_model=CompanyResponseDTO)
|
||||
async def get_company_by_id(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get company by specific ID
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
company = service.get_company_by_id(company_id)
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found")
|
||||
return company
|
||||
|
||||
|
||||
@router.put("/{company_id}", response_model=CompanyResponseDTO)
|
||||
async def update_company(
|
||||
company_id: str,
|
||||
company_data: CompanyUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update company information
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
company = service.update_company(company_id, company_data)
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found")
|
||||
return company
|
||||
|
||||
|
||||
@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_company(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete company from the system
|
||||
|
||||
Note: This will completely remove the company from the system.
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
if not service.delete_company(company_id):
|
||||
raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found")
|
||||
|
||||
|
||||
@router.get("/status/exists", response_model=dict)
|
||||
async def check_company_exists(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Check if a company is registered in the system
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
exists = service.exists_company()
|
||||
return {"exists": exists, "message": "Company found" if exists else "No company registered"}
|
||||
|
||||
|
||||
# Specific endpoints for important fields
|
||||
@router.get("/info/basic", response_model=dict)
|
||||
async def get_company_basic_info(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get basic company information (name, RFC, main activity)
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
company = service.get_company()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="No company found")
|
||||
|
||||
return {
|
||||
"name": company.name,
|
||||
"rfc": company.rfc,
|
||||
"main_activity": company.main_activity,
|
||||
"logo": company.logo
|
||||
}
|
||||
|
||||
|
||||
@router.get("/info/responsible", response_model=dict)
|
||||
async def get_company_responsible_info(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get company responsible person information
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
company = service.get_company()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="No company found")
|
||||
|
||||
return {
|
||||
"responsible": company.responsible,
|
||||
"responsible_name": company.responsible_name,
|
||||
"responsible_last_name": company.responsible_last_name,
|
||||
"responsible_mother_last_name": company.responsible_mother_last_name,
|
||||
"responsible_rfc": company.responsible_rfc,
|
||||
"position": company.position
|
||||
}
|
||||
|
||||
|
||||
@router.get("/info/program", response_model=dict)
|
||||
async def get_company_program_info(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get company program information
|
||||
"""
|
||||
service = CompanyService(db)
|
||||
company = service.get_company()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="No company found")
|
||||
|
||||
return {
|
||||
"program": company.program,
|
||||
"program_number": company.program_number,
|
||||
"prosec": company.prosec,
|
||||
"prosec_authorization": company.prosec_authorization,
|
||||
"manufacturer_id": company.manufacturer_id
|
||||
}
|
||||
|
||||
|
||||
184
backend/api/v1/modules/a76/company/service.py
Normal file
184
backend/api/v1/modules/a76/company/service.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de empresa
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
from .models import GCompany
|
||||
from .dto import CompanyCreateDTO, CompanyUpdateDTO, CompanyResponseDTO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompanyService:
|
||||
"""Servicio para gestión de empresa"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_company(self, company_data: CompanyCreateDTO) -> CompanyResponseDTO:
|
||||
"""
|
||||
Crea una nueva empresa en el sistema
|
||||
|
||||
Args:
|
||||
company_data: Datos de la empresa a crear
|
||||
|
||||
Returns:
|
||||
CompanyResponseDTO con información de la empresa creada
|
||||
|
||||
Raises:
|
||||
HTTPException: Si ya existe una empresa o error en la creación
|
||||
"""
|
||||
try:
|
||||
# Verificar que no exista ya una empresa (solo puede haber una por el consecutivo único)
|
||||
existing = self.db.query(GCompany).filter(GCompany.consecutive == True).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="A company is already registered in the system")
|
||||
|
||||
# Crear empresa
|
||||
db_company = GCompany(
|
||||
id=company_data.id,
|
||||
consecutive=company_data.consecutive,
|
||||
name=company_data.name,
|
||||
rfc=company_data.rfc,
|
||||
main_activity=company_data.main_activity,
|
||||
program=company_data.program,
|
||||
program_number=company_data.program_number,
|
||||
prosec=company_data.prosec,
|
||||
prosec_authorization=company_data.prosec_authorization,
|
||||
manufacturer_id=company_data.manufacturer_id,
|
||||
broker_company=company_data.broker_company,
|
||||
responsible=company_data.responsible,
|
||||
responsible_name=company_data.responsible_name,
|
||||
responsible_last_name=company_data.responsible_last_name,
|
||||
responsible_mother_last_name=company_data.responsible_mother_last_name,
|
||||
responsible_rfc=company_data.responsible_rfc,
|
||||
position=company_data.position,
|
||||
logo=company_data.logo,
|
||||
has_express_line=company_data.has_express_line,
|
||||
order_format_type=company_data.order_format_type,
|
||||
previous_code=company_data.previous_code,
|
||||
is_service_company=company_data.is_service_company,
|
||||
client_name=company_data.client_name,
|
||||
subassembly_mode=company_data.subassembly_mode,
|
||||
curp=company_data.curp,
|
||||
inter_db_name=company_data.inter_db_name,
|
||||
ctpat_svi=company_data.ctpat_svi,
|
||||
trusted_exporter_number=company_data.trusted_exporter_number,
|
||||
prevalidator_key=company_data.prevalidator_key,
|
||||
seventh_amendment=company_data.seventh_amendment
|
||||
)
|
||||
|
||||
self.db.add(db_company)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_company)
|
||||
|
||||
logger.info(f"Company created: {db_company.id} - {db_company.name}")
|
||||
|
||||
return CompanyResponseDTO.model_validate(db_company)
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"IntegrityError creating company: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail="Integrity error: A company already exists in the system")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating company: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error creating company")
|
||||
|
||||
def get_company(self) -> Optional[CompanyResponseDTO]:
|
||||
"""
|
||||
Obtiene la empresa (solo puede haber una)
|
||||
|
||||
Returns:
|
||||
CompanyResponseDTO o None si no existe
|
||||
"""
|
||||
company = self.db.query(GCompany).filter(GCompany.consecutive == True).first()
|
||||
if not company:
|
||||
return None
|
||||
return CompanyResponseDTO.model_validate(company)
|
||||
|
||||
def get_company_by_id(self, company_id: str) -> Optional[CompanyResponseDTO]:
|
||||
"""
|
||||
Obtiene una empresa por ID
|
||||
|
||||
Args:
|
||||
company_id: ID de la empresa
|
||||
|
||||
Returns:
|
||||
CompanyResponseDTO o None si no existe
|
||||
"""
|
||||
company = self.db.query(GCompany).filter(GCompany.id == company_id).first()
|
||||
if not company:
|
||||
return None
|
||||
return CompanyResponseDTO.model_validate(company)
|
||||
|
||||
def update_company(self, company_id: str, company_data: CompanyUpdateDTO) -> Optional[CompanyResponseDTO]:
|
||||
"""
|
||||
Actualiza una empresa
|
||||
|
||||
Args:
|
||||
company_id: ID de la empresa a actualizar
|
||||
company_data: Datos a actualizar
|
||||
|
||||
Returns:
|
||||
CompanyResponseDTO actualizada o None si no existe
|
||||
"""
|
||||
company = self.db.query(GCompany).filter(GCompany.id == company_id).first()
|
||||
if not company:
|
||||
return None
|
||||
|
||||
# Actualizar solo campos proporcionados
|
||||
update_data = company_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(company, field, value)
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
self.db.refresh(company)
|
||||
logger.info(f"Company updated: {company_id}")
|
||||
return CompanyResponseDTO.model_validate(company)
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error updating company {company_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating company")
|
||||
|
||||
def delete_company(self, company_id: str) -> bool:
|
||||
"""
|
||||
Elimina una empresa
|
||||
|
||||
Args:
|
||||
company_id: ID de la empresa a eliminar
|
||||
|
||||
Returns:
|
||||
True si se eliminó, False si no existe
|
||||
"""
|
||||
company = self.db.query(GCompany).filter(GCompany.id == company_id).first()
|
||||
if not company:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.db.delete(company)
|
||||
self.db.commit()
|
||||
logger.info(f"Company deleted: {company_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting company {company_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting company")
|
||||
|
||||
def exists_company(self) -> bool:
|
||||
"""
|
||||
Verifica si existe una empresa registrada
|
||||
|
||||
Returns:
|
||||
True si existe una empresa, False en caso contrario
|
||||
"""
|
||||
return self.db.query(GCompany).filter(GCompany.consecutive == True).first() is not None
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
107
docs/RELATIONSHIPS.md
Normal file
107
docs/RELATIONSHIPS.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user