feauture/catalogo-clases-inventario-UME

This commit is contained in:
2026-06-02 11:12:38 -06:00
parent e612037668
commit e0b08dfa8a
8 changed files with 374 additions and 11 deletions

View File

@@ -0,0 +1,43 @@
"""add stock_unit_of_measure to classes
Revision ID: c8d9e0f1a2b3
Revises: b3c4d5e6f7a8
Create Date: 2026-06-02 12:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c8d9e0f1a2b3"
down_revision: Union[str, None] = "b3c4d5e6f7a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"classes",
sa.Column("stock_unit_of_measure", sa.String(length=5), nullable=True),
schema="a76",
)
op.create_foreign_key(
"fk_classes_stock_uom",
"classes",
"units_of_measure",
["stock_unit_of_measure", "tenant_id", "company_id"],
["code", "tenant_id", "company_id"],
source_schema="a76",
referent_schema="a76",
)
def downgrade() -> None:
op.drop_constraint(
"fk_classes_stock_uom",
"classes",
schema="a76",
type_="foreignkey",
)
op.drop_column("classes", "stock_unit_of_measure", schema="a76")

View File

@@ -28,6 +28,11 @@ class ClassCreateDTO(BaseModel):
unit_of_measure: str = Field(
..., max_length=5, description="Unit of measure - U.M. comercial (required)"
)
stock_unit_of_measure: Optional[str] = Field(
None,
max_length=5,
description="UM Existencia - solo SCAII/inventory (UMEXISTENCIA)",
)
fraction: str = Field(
..., max_length=20, description="Mexican tariff fraction (required)"
)
@@ -111,6 +116,11 @@ class ClassUpdateDTO(BaseModel):
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
)
stock_unit_of_measure: Optional[str] = Field(
None,
max_length=5,
description="UM Existencia - solo SCAII/inventory (UMEXISTENCIA)",
)
fraction: Optional[str] = Field(
None, max_length=20, description="Mexican tariff fraction"
)
@@ -148,6 +158,7 @@ class ClassResponseDTO(BaseModel):
description_en: Optional[str] = None
material_key: Optional[str] = None
unit_of_measure: Optional[str] = None
stock_unit_of_measure: Optional[str] = None
fraction: Optional[str] = None
us_fraction: Optional[str] = None
sub_key: Optional[str] = None
@@ -230,6 +241,7 @@ class ClassWithFADataResponse(BaseModel):
description_en: Optional[str] = None
material_key: Optional[str] = None
unit_of_measure: Optional[str] = None
stock_unit_of_measure: Optional[str] = None
fraction: Optional[str] = None
us_fraction: Optional[str] = None
sub_key: Optional[str] = None

View File

@@ -42,6 +42,12 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"],
),
ForeignKeyConstraint(
["stock_unit_of_measure", "tenant_id", "company_id"],
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"],
name="fk_classes_stock_uom",
),
UniqueConstraint(
"tenant_id",
"company_id",
@@ -69,6 +75,9 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
unit_of_measure: Mapped[Optional[str]] = mapped_column(
String(5)
) # UNIMED - homologated from UNIMEDIDA
stock_unit_of_measure: Mapped[Optional[str]] = mapped_column(
String(5)
) # UMEXISTENCIA - UM Existencia (SCAII/inventory)
# Tariff fractions
fraction: Mapped[Optional[str]] = mapped_column(String(20)) # FRACCION
@@ -97,7 +106,11 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[unit_of_measure]
)
)
stock_unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[stock_unit_of_measure],
viewonly=True,
)
# Inverse relationship with GParts that have this class
parts: Mapped[list["Part"]] = relationship(

View File

@@ -25,6 +25,51 @@ from .models import Class
logger = logging.getLogger(__name__)
def _validate_stock_uom_conversion(
db: Session,
tenant_id: int,
company_id: int,
system: str,
stock_unit_of_measure: Optional[str],
unit_of_measure: Optional[str],
) -> None:
"""SCAII: si UM Existencia ≠ UM Comercial, debe existir conversión en catálogo."""
if system != "inventory":
return
stock_um = (stock_unit_of_measure or "").strip().upper()
commercial_um = (unit_of_measure or "").strip().upper()
if not stock_um or not commercial_um or stock_um == commercial_um:
return
from api.v1.modules.a76.general_catalogs.unit_conversions.models import (
UnitConversion,
)
conversion_exists = (
db.query(UnitConversion)
.filter(
UnitConversion.tenant_id == tenant_id,
UnitConversion.company_id == company_id,
or_(
(UnitConversion.from_unit_code == stock_um)
& (UnitConversion.to_unit_code == commercial_um),
(UnitConversion.from_unit_code == commercial_um)
& (UnitConversion.to_unit_code == stock_um),
),
)
.first()
)
if not conversion_exists:
raise HTTPException(
status_code=422,
detail=(
f"No existe un factor de conversión para '{stock_um}''{commercial_um}'. "
"Configúrelo primero en el catálogo de Conversiones de UM."
),
)
class ClassService:
"""Servicio para gestión de clases SCAII y SCAF"""
@@ -202,6 +247,7 @@ class ClassService:
"description_en": base_class.description_en,
"material_key": base_class.material_key,
"unit_of_measure": base_class.unit_of_measure,
"stock_unit_of_measure": base_class.stock_unit_of_measure,
"fraction": base_class.fraction,
"us_fraction": base_class.us_fraction,
"sub_key": base_class.sub_key,
@@ -273,7 +319,16 @@ class ClassService:
status_code=400,
detail=f"Material type '{data_dict['material_key']}' does not exist"
)
_validate_stock_uom_conversion(
db,
tenant_id,
company_id,
data_dict.get("system", "fixed_asset"),
data_dict.get("stock_unit_of_measure"),
data_dict.get("unit_of_measure"),
)
class_obj = Class(**data_dict)
class_obj.tenant_id = tenant_id
class_obj.company_id = company_id
@@ -306,7 +361,23 @@ class ClassService:
return None
update_data = class_data.model_dump(exclude_unset=True)
effective_system = update_data.get("system", class_obj.system)
effective_stock_um = update_data.get(
"stock_unit_of_measure", class_obj.stock_unit_of_measure
)
effective_commercial_um = update_data.get(
"unit_of_measure", class_obj.unit_of_measure
)
_validate_stock_uom_conversion(
db,
tenant_id,
company_id,
effective_system,
effective_stock_um,
effective_commercial_um,
)
# Validate material_key exists if provided
if "material_key" in update_data and update_data["material_key"]:
from api.v1.modules.public.reference_data.material_types.models import MaterialType
@@ -438,7 +509,8 @@ class ClassService:
# Extract base class fields
base_fields = {
"class_code", "description_es", "description_en",
"material_key", "unit_of_measure", "fraction", "us_fraction",
"material_key", "unit_of_measure", "stock_unit_of_measure",
"fraction", "us_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}
@@ -477,6 +549,7 @@ class ClassService:
"description_en": base_class.description_en,
"material_key": base_class.material_key,
"unit_of_measure": base_class.unit_of_measure,
"stock_unit_of_measure": base_class.stock_unit_of_measure,
"fraction": base_class.fraction,
"us_fraction": base_class.us_fraction,
"sub_key": base_class.sub_key,