feat: Implement multi-tenancy support in middleware and security layers
- Enhanced TenantMiddleware to validate tenant information from JWT tokens. - Added LicenseValidationMiddleware to check tenant licenses before processing requests. - Updated security utilities to extract tenant information from tokens and validate company access. - Introduced CompanyStore to manage active company state and handle company switching in the frontend. - Modified API routes to include company_id in requests for better resource management. - Improved logging and error handling throughout the middleware and API layers. - Updated frontend components to reflect changes in company management and selection. - Added new API route for fetching user's companies with proper authentication handling.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Módulo de Class
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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
|
||||
@@ -9,17 +10,38 @@ from datetime import datetime
|
||||
|
||||
class ClassCreateDTO(BaseModel):
|
||||
"""DTO para crear una clase"""
|
||||
|
||||
client_id: 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")
|
||||
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
|
||||
@@ -27,15 +49,36 @@ class ClassCreateDTO(BaseModel):
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
@@ -43,6 +86,7 @@ class ClassUpdateDTO(BaseModel):
|
||||
|
||||
class ClassResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de clase"""
|
||||
|
||||
client_id: int
|
||||
class_code: str
|
||||
description_spanish: Optional[str] = None
|
||||
@@ -61,6 +105,7 @@ class ClassResponseDTO(BaseModel):
|
||||
|
||||
class ClassBasicDTO(BaseModel):
|
||||
"""DTO para información básica de clase"""
|
||||
|
||||
client_id: int
|
||||
class_code: str
|
||||
description_spanish: Optional[str] = None
|
||||
@@ -74,6 +119,7 @@ class ClassBasicDTO(BaseModel):
|
||||
|
||||
class ClassListDTO(BaseModel):
|
||||
"""DTO para lista de clases"""
|
||||
|
||||
classes: list[ClassBasicDTO]
|
||||
total: int
|
||||
page: int
|
||||
@@ -85,13 +131,15 @@ class ClassListDTO(BaseModel):
|
||||
|
||||
class ClassSearchDTO(BaseModel):
|
||||
"""DTO para búsqueda de clases"""
|
||||
|
||||
client_id: 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")
|
||||
physical_review: Optional[int] = Field(
|
||||
None, description="Filter by physical review indicator"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
"""
|
||||
Modelos ORM para gestión de clases SCAII y SCAF
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import Integer, String, SmallInteger, ForeignKey, PrimaryKeyConstraint, ForeignKeyConstraint, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
Integer,
|
||||
String,
|
||||
SmallInteger,
|
||||
ForeignKey,
|
||||
PrimaryKeyConstraint,
|
||||
ForeignKeyConstraint,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from core.database import Base
|
||||
|
||||
@@ -15,54 +24,78 @@ class Class(Base):
|
||||
"""
|
||||
Modelo para la tabla GClases - Información de clases en sistemas SCAII y SCAF
|
||||
"""
|
||||
|
||||
__tablename__ = "classes"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint('id', name='classes_pkey'),
|
||||
ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], name='fk_classes_tenant'),
|
||||
ForeignKeyConstraint(['company_id'], ['a76.company.id'], name='fk_classes_company'),
|
||||
ForeignKeyConstraint(['client_id'], ['a76.client_provider.id'], name='fk_classes_client'),
|
||||
ForeignKeyConstraint(['material_key'], ['public.material_types.key'], name='fk_classes_material_type'),
|
||||
UniqueConstraint('tenant_id', 'company_id', 'class_code', name='uq_classes_client_id_class_code'),
|
||||
{"schema": "a76"}
|
||||
)
|
||||
|
||||
PrimaryKeyConstraint("id", name="classes_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id"], ["a76.tenants.id"], name="fk_classes_tenant"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["company_id"], ["a76.company.id"], name="fk_classes_company"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["client_id"], ["a76.client_provider.id"], name="fk_classes_client"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["material_key"],
|
||||
["public.material_types.key"],
|
||||
name="fk_classes_material_type",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"company_id",
|
||||
"class_code",
|
||||
name="uq_classes_client_id_class_code",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
# Unique constraint compuesta
|
||||
class_code: Mapped[str] = mapped_column(String(8)) #CLASE
|
||||
|
||||
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
# Unique constraint compuesta
|
||||
class_code: Mapped[str] = mapped_column(String(8)) # CLASE
|
||||
|
||||
# Basic information
|
||||
description_es: Mapped[Optional[str]] = mapped_column(String(500)) #DESCRIPCIONE
|
||||
description_en: Mapped[Optional[str]] = mapped_column(String(500)) #DESCRIPCIONI
|
||||
|
||||
description_es: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONE
|
||||
description_en: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONI
|
||||
|
||||
# Material and measurement
|
||||
material_key: Mapped[Optional[str]] = mapped_column(String(10), ForeignKey('public.material_types.key')) # CLAVEMAT - homologated from TIPOMAT/TIPOMATEQUIPO
|
||||
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMED - homologated from UNIMEDIDA
|
||||
|
||||
material_key: Mapped[Optional[str]] = mapped_column(
|
||||
String(10), ForeignKey("public.material_types.key")
|
||||
) # CLAVEMAT - homologated from TIPOMAT/TIPOMATEQUIPO
|
||||
unit_of_measure: Mapped[Optional[str]] = mapped_column(
|
||||
String(5)
|
||||
) # UNIMED - homologated from UNIMEDIDA
|
||||
|
||||
# Tariff fractions
|
||||
fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION
|
||||
us_fraction: Mapped[Optional[str]] = mapped_column(String(16)) # FRACCIONAME - US tariff fraction
|
||||
|
||||
us_fraction: Mapped[Optional[str]] = mapped_column(
|
||||
String(16)
|
||||
) # FRACCIONAME - US tariff fraction
|
||||
|
||||
# Additional classification
|
||||
sub_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVESUB
|
||||
physical_review: Mapped[Optional[int]] = mapped_column(SmallInteger) # REVFISICA
|
||||
iva_exempt_fraction: Mapped[Optional[str]] = mapped_column(String(4)) # FRACCIONEXENTAIVA
|
||||
|
||||
iva_exempt_fraction: Mapped[Optional[str]] = mapped_column(
|
||||
String(4)
|
||||
) # FRACCIONEXENTAIVA
|
||||
|
||||
# Relationships
|
||||
material_type: Mapped[Optional["MaterialType"]] = relationship(foreign_keys=[material_key])
|
||||
|
||||
material_type: Mapped[Optional["MaterialType"]] = relationship(
|
||||
foreign_keys=[material_key]
|
||||
)
|
||||
|
||||
# Inverse relationship with GParts that have this class
|
||||
parts: Mapped[list["Part"]] = relationship(
|
||||
primaryjoin="and_(Class.client_id == Part.client_id, Class.class_code == Part.part_class)",
|
||||
foreign_keys="[Part.client_id, Part.part_class]",
|
||||
viewonly=True,
|
||||
back_populates="part_class_info"
|
||||
back_populates="part_class_info",
|
||||
)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Class(client_id={self.client_id}, class_code='{self.class_code}', description='{self.description_es}')>"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
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
|
||||
@@ -9,28 +10,33 @@ 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,
|
||||
ClassCreateDTO,
|
||||
ClassUpdateDTO,
|
||||
ClassResponseDTO,
|
||||
ClassBasicDTO,
|
||||
ClassListDTO,
|
||||
ClassSearchDTO
|
||||
ClassSearchDTO,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/classes", tags=["Classes"])
|
||||
|
||||
|
||||
@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"),
|
||||
limit: int = Query(
|
||||
100, ge=1, le=1000, description="Maximum number of records to return"
|
||||
),
|
||||
client_id: 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"),
|
||||
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)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List classes with optional filters and pagination
|
||||
@@ -49,7 +55,7 @@ async def list_classes(
|
||||
description=description,
|
||||
material_key=material_key,
|
||||
fraction=fraction,
|
||||
physical_review=physical_review
|
||||
physical_review=physical_review,
|
||||
)
|
||||
return service.list_classes(skip, limit, search_params)
|
||||
|
||||
@@ -60,7 +66,7 @@ async def get_classes_by_client(
|
||||
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)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all classes for a specific client
|
||||
@@ -80,7 +86,7 @@ async def get_classes_by_client(
|
||||
async def search_by_fraction(
|
||||
fraction: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Search classes by tariff fraction
|
||||
@@ -93,7 +99,7 @@ async def search_by_fraction(
|
||||
async def search_by_material(
|
||||
material_key: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Search classes by material key
|
||||
@@ -102,11 +108,13 @@ async def search_by_material(
|
||||
return service.search_by_material(material_key)
|
||||
|
||||
|
||||
@router.get("/search/unit-measure/{unit_of_measure}", response_model=List[ClassBasicDTO])
|
||||
@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)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get classes by unit of measure
|
||||
@@ -115,11 +123,13 @@ async def get_classes_by_unit_measure(
|
||||
return service.get_classes_by_unit_measure(unit_of_measure)
|
||||
|
||||
|
||||
@router.get("/search/physical-review/{physical_review}", response_model=List[ClassBasicDTO])
|
||||
@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)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get classes by physical review indicator
|
||||
@@ -130,8 +140,7 @@ async def get_classes_by_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)
|
||||
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get basic classes statistics
|
||||
@@ -145,7 +154,7 @@ async def get_class(
|
||||
client_id: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get class by composite key (client_id + class_code)
|
||||
@@ -154,16 +163,17 @@ async def get_class(
|
||||
class_obj = service.get_class(client_id, class_code)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found"
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
|
||||
)
|
||||
return class_obj
|
||||
|
||||
|
||||
@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)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new class in the system
|
||||
@@ -171,13 +181,14 @@ async def create_class(
|
||||
service = ClassService(db)
|
||||
return service.create_class(class_data)
|
||||
|
||||
|
||||
@router.put("/{client_id}/{class_code}", response_model=ClassResponseDTO)
|
||||
async def update_class(
|
||||
client_id: int,
|
||||
class_code: str,
|
||||
class_data: ClassUpdateDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update class information
|
||||
@@ -186,8 +197,8 @@ async def update_class(
|
||||
class_obj = service.update_class(client_id, class_code, class_data)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found"
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
|
||||
)
|
||||
return class_obj
|
||||
|
||||
@@ -197,18 +208,18 @@ async def delete_class(
|
||||
client_id: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
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_id, class_code):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found"
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
|
||||
)
|
||||
|
||||
|
||||
@@ -218,7 +229,7 @@ async def get_class_basic_info(
|
||||
client_id: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get basic information for a class
|
||||
@@ -227,17 +238,17 @@ async def get_class_basic_info(
|
||||
class_obj = service.get_class(client_id, class_code)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found"
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
|
||||
)
|
||||
|
||||
|
||||
return ClassBasicDTO(
|
||||
client_id=class_obj.client_id,
|
||||
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
|
||||
fraction=class_obj.fraction,
|
||||
)
|
||||
|
||||
|
||||
@@ -246,7 +257,7 @@ async def get_class_tariff_info(
|
||||
client_id: int,
|
||||
class_code: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get tariff information for a class (fractions, IVA exempt, etc.)
|
||||
@@ -255,10 +266,10 @@ async def get_class_tariff_info(
|
||||
class_obj = service.get_class(client_id, class_code)
|
||||
if not class_obj:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found"
|
||||
status_code=404,
|
||||
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"client_id": class_obj.client_id,
|
||||
"class_code": class_obj.class_code,
|
||||
@@ -266,7 +277,5 @@ async def get_class_tariff_info(
|
||||
"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
|
||||
"physical_review": class_obj.physical_review,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
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
|
||||
@@ -10,12 +11,12 @@ import logging
|
||||
|
||||
from .models import Class
|
||||
from .dto import (
|
||||
ClassCreateDTO,
|
||||
ClassUpdateDTO,
|
||||
ClassCreateDTO,
|
||||
ClassUpdateDTO,
|
||||
ClassResponseDTO,
|
||||
ClassBasicDTO,
|
||||
ClassListDTO,
|
||||
ClassSearchDTO
|
||||
ClassSearchDTO,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,38 +24,42 @@ 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(Class).filter(
|
||||
and_(
|
||||
Class.client_id == class_data.client_id,
|
||||
Class.class_code == class_data.class_code
|
||||
existing = (
|
||||
self.db.query(Class)
|
||||
.filter(
|
||||
and_(
|
||||
Class.client_id == class_data.client_id,
|
||||
Class.class_code == class_data.class_code,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Class with client_id '{class_data.client_id}' and class_code '{class_data.class_code}' already exists"
|
||||
status_code=400,
|
||||
detail=f"Class with client_id '{class_data.client_id}' and class_code '{class_data.class_code}' already exists",
|
||||
)
|
||||
|
||||
|
||||
# Crear clase
|
||||
db_class = Class(
|
||||
client_id=class_data.client_id,
|
||||
@@ -67,171 +72,181 @@ class ClassService:
|
||||
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
|
||||
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_id}-{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_id and class_code already exists")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Class with this client_id 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_id: int, class_code: str) -> Optional[ClassResponseDTO]:
|
||||
"""
|
||||
Obtiene una clase por clave compuesta
|
||||
|
||||
|
||||
Args:
|
||||
client_id: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
|
||||
Returns:
|
||||
ClassResponseDTO o None si no existe
|
||||
"""
|
||||
class_obj = self.db.query(Class).filter(
|
||||
and_(
|
||||
Class.client_id == client_id,
|
||||
Class.class_code == class_code
|
||||
)
|
||||
).first()
|
||||
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.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
|
||||
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(Class)
|
||||
|
||||
|
||||
# Aplicar filtros si se proporcionan
|
||||
if search_params:
|
||||
if search_params.client_id:
|
||||
query = query.filter(Class.client_id == search_params.client_id)
|
||||
|
||||
|
||||
if search_params.class_code:
|
||||
query = query.filter(Class.class_code.ilike(f"%{search_params.class_code}%"))
|
||||
|
||||
query = query.filter(
|
||||
Class.class_code.ilike(f"%{search_params.class_code}%")
|
||||
)
|
||||
|
||||
if search_params.description:
|
||||
description_pattern = f"%{search_params.description}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Class.description_spanish.ilike(description_pattern),
|
||||
Class.description_english.ilike(description_pattern)
|
||||
Class.description_english.ilike(description_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if search_params.material_key:
|
||||
query = query.filter(Class.material_key.ilike(f"%{search_params.material_key}%"))
|
||||
|
||||
query = query.filter(
|
||||
Class.material_key.ilike(f"%{search_params.material_key}%")
|
||||
)
|
||||
|
||||
if search_params.fraction:
|
||||
query = query.filter(Class.fraction.ilike(f"%{search_params.fraction}%"))
|
||||
|
||||
query = query.filter(
|
||||
Class.fraction.ilike(f"%{search_params.fraction}%")
|
||||
)
|
||||
|
||||
if search_params.physical_review is not None:
|
||||
query = query.filter(Class.physical_review == search_params.physical_review)
|
||||
|
||||
query = query.filter(
|
||||
Class.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)
|
||||
size=len(class_dtos),
|
||||
)
|
||||
|
||||
def update_class(self, client_id: int, class_code: str, class_data: ClassUpdateDTO) -> Optional[ClassResponseDTO]:
|
||||
|
||||
def update_class(
|
||||
self, client_id: int, class_code: str, class_data: ClassUpdateDTO
|
||||
) -> Optional[ClassResponseDTO]:
|
||||
"""
|
||||
Actualiza una clase
|
||||
|
||||
|
||||
Args:
|
||||
client_id: 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(Class).filter(
|
||||
and_(
|
||||
Class.client_id == client_id,
|
||||
Class.class_code == class_code
|
||||
)
|
||||
).first()
|
||||
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.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_id}-{class_code}")
|
||||
|
||||
|
||||
return ClassResponseDTO.model_validate(class_obj)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error updating class {client_id}-{class_code}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating class")
|
||||
|
||||
|
||||
def delete_class(self, client_id: int, class_code: str) -> bool:
|
||||
"""
|
||||
Elimina una clase
|
||||
|
||||
|
||||
Args:
|
||||
client_id: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
|
||||
Returns:
|
||||
True si se eliminó, False si no existe
|
||||
"""
|
||||
class_obj = self.db.query(Class).filter(
|
||||
and_(
|
||||
Class.client_id == client_id,
|
||||
Class.class_code == class_code
|
||||
)
|
||||
).first()
|
||||
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not class_obj:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
self.db.delete(class_obj)
|
||||
self.db.commit()
|
||||
@@ -241,54 +256,75 @@ class ClassService:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting class {client_id}-{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(Class).filter(Class.fraction.ilike(f"%{fraction}%")).all()
|
||||
classes = (
|
||||
self.db.query(Class).filter(Class.fraction.ilike(f"%{fraction}%")).all()
|
||||
)
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
def search_by_client(self, client_id: int, skip: int = 0, limit: int = 100) -> List[ClassBasicDTO]:
|
||||
|
||||
def search_by_client(
|
||||
self, client_id: int, skip: int = 0, limit: int = 100
|
||||
) -> List[ClassBasicDTO]:
|
||||
"""Obtiene todas las clases de un cliente específico"""
|
||||
classes = self.db.query(Class).filter(Class.client_id == client_id).offset(skip).limit(limit).all()
|
||||
classes = (
|
||||
self.db.query(Class)
|
||||
.filter(Class.client_id == client_id)
|
||||
.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(Class).filter(Class.material_key.ilike(f"%{material_key}%")).all()
|
||||
classes = (
|
||||
self.db.query(Class)
|
||||
.filter(Class.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]:
|
||||
|
||||
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(Class).filter(Class.physical_review == physical_review).all()
|
||||
classes = (
|
||||
self.db.query(Class).filter(Class.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(Class).count()
|
||||
|
||||
|
||||
# Contar por clientes
|
||||
clients_count = self.db.query(Class.client_id).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(Class).filter(Class.physical_review == i).count()
|
||||
physical_review_stats[f"physical_review_{i}"] = count
|
||||
|
||||
|
||||
# Contar clases con fracciones
|
||||
with_fraction = self.db.query(Class).filter(Class.fraction.isnot(None)).count()
|
||||
with_us_fraction = self.db.query(Class).filter(Class.us_fraction.isnot(None)).count()
|
||||
|
||||
with_us_fraction = (
|
||||
self.db.query(Class).filter(Class.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
|
||||
**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(Class).filter(Class.unit_of_measure == unit_of_measure).all()
|
||||
classes = (
|
||||
self.db.query(Class).filter(Class.unit_of_measure == unit_of_measure).all()
|
||||
)
|
||||
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user