feature/app-selector

This commit is contained in:
2026-05-26 16:59:32 -06:00
parent 904fa965c8
commit dbf68986da
34 changed files with 792 additions and 169 deletions

View File

@@ -46,6 +46,10 @@ class ClassCreateDTO(BaseModel):
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
system: str = Field(
default="fixed_asset", max_length=12,
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
)
class Config:
from_attributes = True
@@ -125,6 +129,10 @@ class ClassUpdateDTO(BaseModel):
is_active: Optional[bool] = Field(
True, description="Indicates if the class is active (default: true)"
)
system: Optional[str] = Field(
None, max_length=12,
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
)
model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields
@@ -146,6 +154,7 @@ class ClassResponseDTO(BaseModel):
physical_review: Optional[int] = None
iva_exempt_fraction: Optional[str] = None
is_active: Optional[bool] = None
system: str = "fixed_asset"
created_at: datetime
updated_at: datetime
@@ -226,9 +235,10 @@ class ClassWithFADataResponse(BaseModel):
sub_key: Optional[str] = None
physical_review: Optional[int] = None
iva_exempt_fraction: Optional[str] = None
system: str = "fixed_asset"
created_at: datetime
updated_at: datetime
# FA-specific fields (embedded from a24.fa_classes)
fa_class_id: Optional[int] = None
import_tariff_code: Optional[str] = None

View File

@@ -88,6 +88,9 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
default=True, server_default="true", nullable=False
) # Campo para habilitar/deshabilitar clases sin eliminarlas
# Sistema al que pertenece la clase: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset")
# Relationships
material_type: Mapped[Optional["MaterialType"]] = relationship(
foreign_keys=[material_key]

View File

@@ -3,11 +3,11 @@ Endpoints API para gestión de clases SCAII y SCAF
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from core.security import get_current_user, get_active_system
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from .dto import (
@@ -37,6 +37,7 @@ router.include_router(imports_router, prefix="/imports", tags=["a76 / classes /
tags=["a76 / classes"],
)
async def get_classes_with_fa_data(
request: Request,
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=1000, description="Page size"),
@@ -54,28 +55,31 @@ async def get_classes_with_fa_data(
This endpoint is optimized for the fixed-asset-classes view.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.view"])
skip = (page - 1) * page_size
filters: Optional[Dict[str, Any]] = None
if any([class_code, description, material_key, fraction]):
filters = {}
if class_code:
filters["class_code"] = class_code
if description:
filters["description"] = description
if material_key:
filters["material_key"] = material_key
if fraction:
filters["fraction"] = fraction
filters: Dict[str, Any] = {}
if class_code:
filters["class_code"] = class_code
if description:
filters["description"] = description
if material_key:
filters["material_key"] = material_key
if fraction:
filters["fraction"] = fraction
# Sistema activo desde header/cookie (TenantMiddleware no setea request.state en /api/)
active_system = get_active_system(request)
if active_system:
filters["system"] = active_system
classes_with_fa, total = ClassService.get_all_with_fa_data(
db=db,
tenant_id=tenant_id,
company_id=company_id,
skip=skip,
limit=page_size,
filters=filters,
filters=filters if filters else None,
sort_by=sort_by,
sort_order=sort_order,
)
@@ -96,17 +100,30 @@ async def get_classes_with_fa_data(
tags=["a76 / classes"],
)
async def create_fa_class(
request: Request,
class_data: ClassCreateDTOFA,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Create a fixed asset class (both base class and FA extension)"""
active_system = get_active_system(request)
if active_system == "inventory":
raise HTTPException(
status_code=403,
detail=(
"Solo se pueden crear clases de activo fijo con el módulo de Activo Fijo (SCAF) activo. "
"Cambie de aplicación e intente de nuevo."
),
)
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.create"])
# Siempre persistir como activo fijo; no depender del default del DTO ni del body del cliente
class_data = class_data.model_copy(update={"system": "fixed_asset"})
result = ClassService.create_fa_class(db, class_data, tenant_id, company_id)
return result
# Now include generic CRUD routes

View File

@@ -82,7 +82,9 @@ class ClassService:
query = query.filter(
Class.physical_review == filters["physical_review"]
)
if filters.get("system"):
query = query.filter(Class.system == filters["system"])
# Apply sorting
if sort_by:
column = getattr(Class, sort_by, None)
@@ -161,7 +163,9 @@ class ClassService:
)
if filters.get("fraction"):
query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%"))
if filters.get("system"):
query = query.filter(Class.system == filters["system"])
# Apply sorting
if sort_by:
# Check if sort_by belongs to Class or QClasses
@@ -203,6 +207,7 @@ class ClassService:
"sub_key": base_class.sub_key,
"physical_review": base_class.physical_review,
"iva_exempt_fraction": base_class.iva_exempt_fraction,
"system": base_class.system,
"created_at": base_class.created_at,
"updated_at": base_class.updated_at,
# FA extension fields (None if no FA record exists)
@@ -399,7 +404,7 @@ class ClassService:
base_fields = {
"class_code", "description_es", "description_en",
"material_key", "unit_of_measure", "fraction", "us_fraction",
"sub_key", "physical_review", "iva_exempt_fraction"
"sub_key", "physical_review", "iva_exempt_fraction", "system"
}
base_data = {k: v for k, v in class_data.model_dump().items() if k in base_fields}
@@ -442,6 +447,7 @@ class ClassService:
"sub_key": base_class.sub_key,
"physical_review": base_class.physical_review,
"iva_exempt_fraction": base_class.iva_exempt_fraction,
"system": base_class.system,
"created_at": base_class.created_at,
"updated_at": base_class.updated_at,
# FA extension fields

View File

@@ -4,8 +4,8 @@ from typing import Dict, Any, Literal, Optional
from core.config import settings
from core.database import get_core_db
from core.exceptions import BaseAPIException
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource, get_active_system
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Request
from sqlalchemy import func, or_, and_
from sqlalchemy.orm import Session
@@ -181,6 +181,7 @@ def get_remesa_suggestion(
@router.post("/invoices/", response_model=schemas.InvoiceHeaderResponse)
def create_invoice(
request: Request,
data: schemas.InvoiceHeaderCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
@@ -188,11 +189,16 @@ def create_invoice(
):
try:
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Sistema autoritativo desde el contexto activo (header/cookie)
active_system = get_active_system(request)
if active_system:
data = data.model_copy(update={"system": active_system})
# Validamos usando los datos que vienen en el body (payload)
perm_base = get_invoice_permission_base(data.operation_type, data.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.create"])
return services.InvoiceService.create(db, data, tenant_id, company_id)
except HTTPException:
raise
@@ -506,6 +512,7 @@ def interface_cp_genesis(
@router.get("/invoices/", response_model=schemas.InvoiceHeaderListResponse)
def list_invoices(
request: Request,
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
@@ -575,9 +582,10 @@ def list_invoices(
"pedimento": pedimento,
"project_number": project_number,
"year": year,
"allowed_types": allowed_filters
"allowed_types": allowed_filters,
"system": get_active_system(request),
}
filters = {k: v for k, v in filters.items() if v is not None}
items, total = services.InvoiceService.get_all(

View File

@@ -369,6 +369,10 @@ class InvoiceService:
if not filters.get("invoice_type") and ot_exp_val == "exp":
query = query.filter(models.InvoiceHeader.invoice_type != "REPAR")
# Filtro por sistema activo (SCAF / SCAII)
if filters.get("system"):
query = query.filter(models.InvoiceHeader.system == filters["system"])
# Filtro por permisos granulares (allowed_types)
if "allowed_types" in filters:
allowed = filters["allowed_types"]

View File

@@ -174,6 +174,9 @@ class PartBase(BaseModel):
is_active: bool = True
part_photo: Optional[str] = None
# Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
system: str = Field(default="fixed_asset", max_length=12)
# Anidados
fa_data: Optional[FaDataDTO] = None
inv_data: Optional[InvDataDTO] = None

View File

@@ -97,6 +97,9 @@ class Part(Base, TenantScopedMixin, TimestampMixin):
)
part_photo: Mapped[Optional[str]] = mapped_column(String(255))
# Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset")
creation_date: Mapped[Optional[int]] = mapped_column()
modification_date: Mapped[Optional[int]] = mapped_column()
modification_date_iso: Mapped[Optional[datetime]] = mapped_column(DateTime)

View File

@@ -96,6 +96,8 @@ class PartService:
Part.commercial_part_number.ilike(search)
)
)
if filters.get("system"):
query = query.filter(Part.system == filters["system"])
# Otros filtros...
# Apply sorting