feat: add Fixed Asset Classes management page with CRUD functionality
This commit is contained in:
@@ -13,7 +13,6 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
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_es: str = Field(
|
||||
..., max_length=500, description="Description in Spanish (required)"
|
||||
@@ -127,7 +126,6 @@ class ClassResponseDTO(BaseModel):
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
client_id: int
|
||||
class_code: str
|
||||
description_es: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
@@ -164,7 +162,6 @@ class ClassResponseDTOFA(ClassResponseDTO):
|
||||
class ClassBasicDTO(BaseModel):
|
||||
"""DTO para información básica de clase"""
|
||||
|
||||
client_id: int
|
||||
class_code: str
|
||||
description_es: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
@@ -190,7 +187,6 @@ 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")
|
||||
|
||||
@@ -31,9 +31,6 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "classes"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="classes_pkey"),
|
||||
ForeignKeyConstraint(
|
||||
["client_id"], ["a76.clients_and_providers.id"], name="fk_classes_client"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["material_key"],
|
||||
["public.material_types.key"],
|
||||
@@ -47,15 +44,13 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"company_id",
|
||||
"client_id",
|
||||
"class_code",
|
||||
name="ufa_classes_client_id_class_code",
|
||||
name="uq_classes_tenant_company_code",
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
client_id: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
# Unique constraint compuesta
|
||||
class_code: Mapped[str] = mapped_column(String(8)) # CLASE
|
||||
@@ -98,11 +93,11 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
# 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]",
|
||||
primaryjoin="and_(Class.class_code == Part.part_class)",
|
||||
foreign_keys="[Part.part_class]",
|
||||
viewonly=True,
|
||||
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}')>"
|
||||
return f"<Class(class_code='{self.class_code}', description='{self.description_es}')>"
|
||||
|
||||
@@ -31,29 +31,6 @@ crud_routes = TenantCRUDRoutes(
|
||||
|
||||
router = crud_routes.router
|
||||
|
||||
|
||||
@router.post(
|
||||
"/seed",
|
||||
summary="Seed Fixed Asset Classes",
|
||||
description="Initialize fixed asset class catalog with default data",
|
||||
)
|
||||
async def seed_classes(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
client_id: int = Query(..., description="Client ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Seed initial data for fixed asset classes"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
count = ClassService.seed_initial_data(db, tenant_id, company_id, client_id)
|
||||
|
||||
return {
|
||||
"message": f"Successfully created {count} fixed asset classes",
|
||||
"count": count,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/fa",
|
||||
response_model=ClassResponseDTOFA,
|
||||
|
||||
@@ -46,8 +46,6 @@ class ClassService:
|
||||
)
|
||||
|
||||
if filters:
|
||||
if filters.get("client_id"):
|
||||
query = query.filter(Class.client_id == filters["client_id"])
|
||||
if filters.get("class_code"):
|
||||
query = query.filter(
|
||||
Class.class_code.ilike(f"%{filters['class_code']}%")
|
||||
@@ -106,7 +104,6 @@ class ClassService:
|
||||
existing = db.query(Class).filter(
|
||||
Class.tenant_id == tenant_id,
|
||||
Class.company_id == company_id,
|
||||
Class.client_id == data_dict["client_id"],
|
||||
Class.class_code == data_dict["class_code"]
|
||||
).first()
|
||||
|
||||
@@ -177,16 +174,15 @@ class ClassService:
|
||||
if "class_code" in update_data and update_data["class_code"]:
|
||||
new_code = update_data["class_code"]
|
||||
# Check if another class with this code exists (excluding current class)
|
||||
# The unique constraint is on (tenant_id, company_id, client_id, class_code)
|
||||
# The unique constraint is on (tenant_id, company_id, class_code)
|
||||
existing_class = db.query(Class).filter(
|
||||
Class.class_code == new_code,
|
||||
Class.tenant_id == tenant_id,
|
||||
Class.company_id == company_id,
|
||||
Class.client_id == class_obj.client_id, # Same client
|
||||
Class.id != class_id # Exclude current class
|
||||
).first()
|
||||
|
||||
logger.info(f"Checking for duplicate class_code '{new_code}' for client {class_obj.client_id}")
|
||||
logger.info(f"Checking for duplicate class_code '{new_code}'")
|
||||
if existing_class:
|
||||
logger.warning(f"Duplicate class_code found: {existing_class.id}")
|
||||
raise HTTPException(
|
||||
@@ -265,7 +261,7 @@ class ClassService:
|
||||
|
||||
# Extract base class fields
|
||||
base_fields = {
|
||||
"client_id", "class_code", "description_es", "description_en",
|
||||
"class_code", "description_es", "description_en",
|
||||
"material_key", "unit_of_measure", "fraction", "us_fraction",
|
||||
"sub_key", "physical_review", "iva_exempt_fraction"
|
||||
}
|
||||
@@ -300,7 +296,6 @@ class ClassService:
|
||||
"id": base_class.id,
|
||||
"tenant_id": base_class.tenant_id,
|
||||
"company_id": base_class.company_id,
|
||||
"client_id": base_class.client_id,
|
||||
"class_code": base_class.class_code,
|
||||
"description_es": base_class.description_es,
|
||||
"description_en": base_class.description_en,
|
||||
@@ -352,62 +347,6 @@ class ClassService:
|
||||
detail=f"Error al crear clase de activo fijo: {error_msg}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def seed_initial_data(
|
||||
db: Session, tenant_id: int, company_id: int, client_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Seed initial fixed asset class data
|
||||
Returns: number of records created
|
||||
"""
|
||||
from .seed import seed
|
||||
|
||||
created_count = 0
|
||||
for record in seed:
|
||||
(
|
||||
class_code,
|
||||
description_es,
|
||||
description_en,
|
||||
material_key,
|
||||
unit_of_measure,
|
||||
fraction,
|
||||
us_fraction,
|
||||
bom,
|
||||
) = record
|
||||
|
||||
# Check if already exists
|
||||
existing = (
|
||||
db.query(Class)
|
||||
.filter(
|
||||
Class.tenant_id == tenant_id,
|
||||
Class.company_id == company_id,
|
||||
Class.client_id == client_id,
|
||||
Class.class_code == class_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing:
|
||||
class_obj = Class(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
client_id=client_id,
|
||||
class_code=class_code,
|
||||
description_es=description_es,
|
||||
description_en=description_en,
|
||||
material_key=material_key if material_key else None,
|
||||
unit_of_measure=unit_of_measure if unit_of_measure else None,
|
||||
fraction=fraction if fraction else None,
|
||||
us_fraction=us_fraction if us_fraction else None,
|
||||
)
|
||||
db.add(class_obj)
|
||||
created_count += 1
|
||||
|
||||
if created_count > 0:
|
||||
db.commit()
|
||||
|
||||
return created_count
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@@ -430,7 +369,6 @@ class ClassService:
|
||||
self.db.query(Class)
|
||||
.filter(
|
||||
and_(
|
||||
Class.client_id == class_data.client_id,
|
||||
Class.class_code == class_data.class_code,
|
||||
)
|
||||
)
|
||||
@@ -440,12 +378,11 @@ class ClassService:
|
||||
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",
|
||||
detail=f"Class with class_code '{class_data.class_code}' already exists",
|
||||
)
|
||||
|
||||
# Crear clase
|
||||
db_class = Class(
|
||||
client_id=class_data.client_id,
|
||||
class_code=class_data.class_code,
|
||||
description_spanish=class_data.description_spanish,
|
||||
description_english=class_data.description_english,
|
||||
@@ -469,7 +406,7 @@ class ClassService:
|
||||
logger.error(f"IntegrityError creating class: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Class with this client_id and class_code already exists",
|
||||
detail="Class with this class_code already exists",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -478,12 +415,11 @@ class ClassService:
|
||||
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]:
|
||||
def get_class(self, class_code: str) -> Optional[ClassResponseDTO]:
|
||||
"""
|
||||
Obtiene una clase por clave compuesta
|
||||
|
||||
Args:
|
||||
client_id: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
Returns:
|
||||
@@ -491,7 +427,7 @@ class ClassService:
|
||||
"""
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
|
||||
.filter(and_(Class.class_code == class_code))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -520,9 +456,6 @@ class ClassService:
|
||||
|
||||
# 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}%")
|
||||
@@ -569,13 +502,12 @@ class ClassService:
|
||||
)
|
||||
|
||||
def update_class(
|
||||
self, client_id: int, class_code: str, class_data: ClassUpdateDTO
|
||||
self, 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
|
||||
|
||||
@@ -584,7 +516,7 @@ class ClassService:
|
||||
"""
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
|
||||
.filter(and_(Class.class_code == class_code))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -604,15 +536,14 @@ class ClassService:
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error updating class {client_id}-{class_code}: {str(e)}")
|
||||
logger.error(f"Error updating class {class_code}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error updating class")
|
||||
|
||||
def delete_class(self, client_id: int, class_code: str) -> bool:
|
||||
def delete_class(self, class_code: str) -> bool:
|
||||
"""
|
||||
Elimina una clase
|
||||
|
||||
Args:
|
||||
client_id: Clave del cliente
|
||||
class_code: Código de clase
|
||||
|
||||
Returns:
|
||||
@@ -620,7 +551,7 @@ class ClassService:
|
||||
"""
|
||||
class_obj = (
|
||||
self.db.query(Class)
|
||||
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
|
||||
.filter(and_(Class.class_code == class_code))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -633,7 +564,7 @@ class ClassService:
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting class {client_id}-{class_code}: {str(e)}")
|
||||
logger.error(f"Error deleting class {class_code}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting class")
|
||||
|
||||
def search_by_fraction(self, fraction: str) -> List[ClassBasicDTO]:
|
||||
@@ -643,19 +574,6 @@ class ClassService:
|
||||
)
|
||||
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]:
|
||||
"""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()
|
||||
)
|
||||
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 = (
|
||||
@@ -678,9 +596,6 @@ class ClassService:
|
||||
"""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
|
||||
@@ -695,7 +610,6 @@ class ClassService:
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user