6feature/fix-en-base-a-scaii

This commit is contained in:
2026-03-18 13:22:45 -06:00
parent 8b9537b5a9
commit 0a68454593
14 changed files with 1022 additions and 489 deletions

View File

@@ -3,43 +3,17 @@ from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
# Equivalency (parent) DTOs
class EquivalencyBase(BaseModel):
# Agrupa múltiples destinos para un mismo origen.
identifier: str = Field(..., max_length=10, description="From unit code")
description: Optional[str] = Field(None, max_length=200, description="Description (optional)")
class EquivalencyCreate(EquivalencyBase):
pass
class EquivalencyUpdate(BaseModel):
identifier: Optional[str] = Field(None, max_length=10)
description: Optional[str] = Field(None, max_length=200)
class EquivalencyResponse(EquivalencyBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
# Equivalency Item DTOs (catálogo por fila)
# EquivalencyItem DTOs (pool global independiente)
class EquivalencyItemBase(BaseModel):
original_field: str = Field(..., max_length=100, description="Original unit code (from)")
external_field: str = Field(..., max_length=100, description="External unit code (to)")
conversion_factor: Optional[Decimal] = Field(None, description="Conversion factor")
original_field: str = Field(..., max_length=100, description="Campo original (from)")
external_field: str = Field(..., max_length=100, description="Campo externo (to)")
conversion_factor: Optional[Decimal] = Field(None, description="Factor de conversión")
class EquivalencyItemCreate(EquivalencyItemBase):
conversion_factor: Decimal = Field(..., description="Conversion factor")
conversion_factor: Optional[Decimal] = Field(None, description="Factor de conversión")
class EquivalencyItemUpdate(BaseModel):
@@ -50,8 +24,35 @@ class EquivalencyItemUpdate(BaseModel):
class EquivalencyItemResponse(EquivalencyItemBase):
id: int
equivalency_id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
# Equivalency DTOs (catálogo — referencia un item del pool)
class EquivalencyBase(BaseModel):
identifier: str = Field(..., max_length=10, description="Identificador único")
description: Optional[str] = Field(None, max_length=200, description="Descripción (opcional)")
item_id: Optional[int] = Field(None, description="ID del EquivalencyItem seleccionado")
class EquivalencyCreate(EquivalencyBase):
pass
class EquivalencyUpdate(BaseModel):
identifier: Optional[str] = Field(None, max_length=10)
description: Optional[str] = Field(None, max_length=200)
item_id: Optional[int] = Field(None, description="Re-asociar a otro EquivalencyItem")
class EquivalencyResponse(EquivalencyBase):
id: int
tenant_id: int
company_id: int
item: Optional[EquivalencyItemResponse] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,13 +1,39 @@
from typing import Optional, List
from decimal import Decimal
from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, ForeignKeyConstraint, Numeric
from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
class EquivalencyItem(Base, TenantScopedMixin, TimestampMixin):
"""Pool global de mapeos campo_original → campo_externo.
Un mismo item puede ser referenciado por múltiples Equivalency."""
__tablename__ = "equivalency_items"
__table_args__ = (
UniqueConstraint(
"original_field", "external_field", "tenant_id", "company_id",
name="uq_equivalency_item_fields"
),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
original_field: Mapped[str] = mapped_column(String(100), nullable=False)
external_field: Mapped[str] = mapped_column(String(100), nullable=False)
conversion_factor: Mapped[Optional[Decimal]] = mapped_column(
Numeric(13, 6), nullable=True)
equivalencies: Mapped[List["Equivalency"]] = relationship(
back_populates="item", foreign_keys="[Equivalency.item_id]")
class Equivalency(Base, TenantScopedMixin, TimestampMixin):
"""Catálogo de equivalencias. Cada registro selecciona UN item del pool."""
__tablename__ = "equivalencies"
__table_args__ = (
UniqueConstraint("identifier", "tenant_id", "company_id",
@@ -19,42 +45,13 @@ class Equivalency(Base, TenantScopedMixin, TimestampMixin):
Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(String(10), nullable=False)
description: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True)
items: Mapped[List["EquivalencyItem"]] = relationship(
back_populates="equivalency", cascade="all, delete-orphan")
item_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("a76.equivalency_items.id", ondelete="SET NULL"),
nullable=True)
class EquivalencyItem(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "equivalency_items"
__table_args__ = (
UniqueConstraint("equivalency_id", "original_field",
"external_field", "tenant_id", "company_id", name="uq_equivalency_item_fields"),
ForeignKeyConstraint(
["original_field", "tenant_id", "company_id"],
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"]
),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
equivalency_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.equivalencies.id"), nullable=False)
original_field: Mapped[str] = mapped_column(
String(100), nullable=False) # Relation to Unit of Measure
external_field: Mapped[str] = mapped_column(String(100), nullable=False)
conversion_factor: Mapped[Optional[Decimal]] = mapped_column(
Numeric(13, 6), nullable=True
)
equivalency: Mapped["Equivalency"] = relationship(back_populates="items")
unit_of_measure: Mapped["UnitOfMeasure"] = relationship()
item: Mapped[Optional["EquivalencyItem"]] = relationship(
back_populates="equivalencies", foreign_keys=[item_id])

View File

@@ -1,12 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from . import service
from .models import Equivalency, EquivalencyItem
from .dto import (
EquivalencyCreate, EquivalencyResponse, EquivalencyUpdate,
EquivalencyItemCreate, EquivalencyItemResponse, EquivalencyItemUpdate
@@ -16,19 +9,7 @@ from .service import EquivalencyService, EquivalencyItemService
router = APIRouter(prefix="/equivalencies",
tags=["a76.general_catalogs.equivalencies"])
# Equivalency CRUD
equivalency_crud = TenantCRUDRoutes(
service=EquivalencyService,
create_schema=EquivalencyCreate,
update_schema=EquivalencyUpdate,
response_schema=EquivalencyResponse,
prefix="",
tags=["Equivalencies"],
resource_name="Equivalency",
enable_list=True,
)
# Equivalency Item CRUD
# Pool global de EquivalencyItems
item_crud = TenantCRUDRoutes(
service=EquivalencyItemService,
create_schema=EquivalencyItemCreate,
@@ -41,60 +22,18 @@ item_crud = TenantCRUDRoutes(
enable_filters=True,
)
# Custom endpoint for creating items nested under equivalency
@equivalency_crud.router.post(
"/{equivalency_id}/items",
response_model=EquivalencyItemResponse,
status_code=status.HTTP_201_CREATED,
summary="Create equivalency item",
# Catálogo de equivalencias (referencia item_id)
equivalency_crud = TenantCRUDRoutes(
service=EquivalencyService,
create_schema=EquivalencyCreate,
update_schema=EquivalencyUpdate,
response_schema=EquivalencyResponse,
prefix="",
tags=["Equivalencies"],
resource_name="Equivalency",
enable_list=True,
enable_filters=True,
)
async def create_equivalency_item(
equivalency_id: int,
data: EquivalencyItemCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify parent exists
parent = EquivalencyService.get_by_id(
db, equivalency_id, tenant_id, company_id)
if not parent:
raise HTTPException(status_code=404, detail="Equivalency not found")
# Create item
# We need to manually handle the creation because the DTO doesn't have equivalency_id
# and the service.create expects data to match the model or DTO.
# But service.create takes EquivalencyItemCreate which doesn't have equivalency_id.
# So we need to modify the data or handle it in service.
# Actually, I implemented EquivalencyItemService.create to take EquivalencyItemCreate.
# And it tries to create the model.
# But the model needs equivalency_id.
# So EquivalencyItemService.create will fail if I don't pass equivalency_id.
# I should update EquivalencyItemService.create to accept extra kwargs or handle this.
# Let's update the service call here to pass equivalency_id manually if I can't change the service signature easily.
# But wait, I can just instantiate the model here or update the service.
# I'll update the service to handle it.
# But for now, let's assume I can pass it in the data if I convert it to dict.
item_data = data.model_dump()
item_data['equivalency_id'] = equivalency_id
# I need to call a method that accepts this.
# EquivalencyItemService.create takes EquivalencyItemCreate.
# I should probably add a specific method for this or update create.
# Let's use a direct DB call here or add a method to service.
# Adding a method to service is cleaner.
return EquivalencyItemService.create_nested(db, equivalency_id, data, tenant_id, company_id)
router.include_router(equivalency_crud.router)
router.include_router(item_crud.router)
router.include_router(equivalency_crud.router)

View File

@@ -7,130 +7,9 @@ from .models import Equivalency, EquivalencyItem
from .dto import EquivalencyCreate, EquivalencyUpdate, EquivalencyItemCreate, EquivalencyItemUpdate
class EquivalencyService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[Equivalency], int]:
query = db.query(Equivalency).filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id,
)
if filters:
# Soporta filtros de UI por nombre de parámetro.
if "from_unit_code" in filters:
query = query.filter(
Equivalency.identifier.ilike(f"%{filters['from_unit_code']}%")
)
if "identifier" in filters:
query = query.filter(
Equivalency.identifier.ilike(f"%{filters['identifier']}%")
)
if "description" in filters:
query = query.filter(
Equivalency.description.ilike(f"%{filters['description']}%")
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[Equivalency]:
item = db.query(Equivalency).filter(
Equivalency.id == id,
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).first()
return item
@staticmethod
def create(
db: Session,
data: EquivalencyCreate,
tenant_id: int,
company_id: int
) -> Equivalency:
try:
db_obj = Equivalency(
identifier=data.identifier,
description=data.description,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
if "uq_equivalency_identifier" in error_msg:
raise HTTPException(
status_code=400,
detail=f"Ya existe un registro para el origen '{data.identifier}'.",
) from e
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}") from e
except Exception as e:
db.rollback()
raise e
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: EquivalencyUpdate,
company_id: int
) -> Optional[Equivalency]:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
try:
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}") from e
except Exception as e:
db.rollback()
raise e
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
class EquivalencyItemService:
"""CRUD simple sobre el pool global de items."""
@staticmethod
def get_all(
db: Session,
@@ -146,15 +25,13 @@ class EquivalencyItemService:
)
if filters:
if "from_unit_code" in filters:
if "original_field" in filters:
query = query.filter(
EquivalencyItem.original_field.ilike(
f"%{filters['from_unit_code']}%"
)
EquivalencyItem.original_field.ilike(f"%{filters['original_field']}%")
)
if "to_unit_code" in filters:
if "external_field" in filters:
query = query.filter(
EquivalencyItem.external_field.ilike(f"%{filters['to_unit_code']}%")
EquivalencyItem.external_field.ilike(f"%{filters['external_field']}%")
)
total = query.count()
@@ -182,29 +59,7 @@ class EquivalencyItemService:
company_id: int
) -> EquivalencyItem:
try:
# Equivalency padre agrupado por `from_unit_code` (original_field)
parent = (
db.query(Equivalency)
.filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id,
Equivalency.identifier == data.original_field,
)
.first()
)
if not parent:
parent = Equivalency(
identifier=data.original_field,
description=None,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(parent)
db.flush() # obtener id sin commit
db_obj = EquivalencyItem(
equivalency_id=parent.id,
original_field=data.original_field,
external_field=data.external_field,
conversion_factor=data.conversion_factor,
@@ -218,36 +73,13 @@ class EquivalencyItemService:
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
if "units_of_measure" in error_msg:
if "uq_equivalency_item_fields" in error_msg:
raise HTTPException(
status_code=400,
detail=(
"Verifica que las unidades existan en el catálogo de U.M."
),
detail=f"Ya existe un item con los mismos campos '{data.original_field}''{data.external_field}'."
) from e
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}") from e
@staticmethod
def create_nested(
db: Session,
equivalency_id: int,
data: EquivalencyItemCreate,
tenant_id: int,
company_id: int
) -> EquivalencyItem:
db_obj = EquivalencyItem(
equivalency_id=equivalency_id,
original_field=data.original_field,
external_field=data.external_field,
conversion_factor=data.conversion_factor,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session,
@@ -260,39 +92,10 @@ class EquivalencyItemService:
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
try:
new_original = update_dict.get("original_field")
if new_original and new_original != db_obj.original_field:
# Re-asignar al padre del nuevo `from_unit_code`
parent = (
db.query(Equivalency)
.filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id,
Equivalency.identifier == new_original,
)
.first()
)
if not parent:
parent = Equivalency(
identifier=new_original,
description=None,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(parent)
db.flush()
db_obj.equivalency_id = parent.id
db_obj.original_field = new_original
# Aplicar resto de cambios
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if key == "original_field":
continue # ya aplicado arriba (y con posible re-parenting)
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@@ -314,3 +117,119 @@ class EquivalencyItemService:
db.delete(db_obj)
db.commit()
return True
class EquivalencyService:
"""CRUD del catálogo de equivalencias. Cada registro referencia un EquivalencyItem."""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[Equivalency], int]:
query = db.query(Equivalency).filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id,
)
if filters:
if "from_unit_code" in filters:
query = query.filter(
Equivalency.identifier.ilike(f"%{filters['from_unit_code']}%")
)
if "identifier" in filters:
query = query.filter(
Equivalency.identifier.ilike(f"%{filters['identifier']}%")
)
if "description" in filters:
query = query.filter(
Equivalency.description.ilike(f"%{filters['description']}%")
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[Equivalency]:
return db.query(Equivalency).filter(
Equivalency.id == id,
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).first()
@staticmethod
def create(
db: Session,
data: EquivalencyCreate,
tenant_id: int,
company_id: int
) -> Equivalency:
try:
db_obj = Equivalency(
identifier=data.identifier,
description=data.description,
item_id=data.item_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
if "uq_equivalency_identifier" in error_msg:
raise HTTPException(
status_code=400,
detail=f"Ya existe un registro para el identificador '{data.identifier}'.",
) from e
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}") from e
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: EquivalencyUpdate,
company_id: int
) -> Optional[Equivalency]:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
try:
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}") from e
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True

View File

@@ -14,6 +14,7 @@ from core.database import Base
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
if TYPE_CHECKING:
from .line_financials.models import LineFinancial
@@ -24,7 +25,6 @@ if TYPE_CHECKING:
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
# ============================================================================
# CORE ENTITIES
@@ -221,7 +221,7 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
uselist=False,
)
identifiers: Mapped[List["IdentifierDetail"]] = relationship(
"IdentifierDetail",
IdentifierDetail,
back_populates="line",
cascade="all, delete-orphan",
)