From e0b08dfa8ac83eb93119d3c233fd6c6943f4cfbd Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 2 Jun 2026 11:12:38 -0600 Subject: [PATCH] feauture/catalogo-clases-inventario-UME --- ...b3_add_stock_unit_of_measure_to_classes.py | 43 ++++ backend/api/v1/modules/a76/classes/dto.py | 12 ++ backend/api/v1/modules/a76/classes/models.py | 15 +- backend/api/v1/modules/a76/classes/service.py | 79 +++++++- frontend/src/lib/api/dashboard/a76/classes.ts | 2 + .../dashboard/goods/classes/columns.ts | 27 ++- .../classes/forms/FixedAssetClassForm.svelte | 190 +++++++++++++++++- .../goods/fixed-asset-classes/+page.svelte | 17 +- 8 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 backend/alembic/versions/c8d9e0f1a2b3_add_stock_unit_of_measure_to_classes.py diff --git a/backend/alembic/versions/c8d9e0f1a2b3_add_stock_unit_of_measure_to_classes.py b/backend/alembic/versions/c8d9e0f1a2b3_add_stock_unit_of_measure_to_classes.py new file mode 100644 index 00000000..8971bbd2 --- /dev/null +++ b/backend/alembic/versions/c8d9e0f1a2b3_add_stock_unit_of_measure_to_classes.py @@ -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") diff --git a/backend/api/v1/modules/a76/classes/dto.py b/backend/api/v1/modules/a76/classes/dto.py index 91fd7c0d..39ffdd7d 100644 --- a/backend/api/v1/modules/a76/classes/dto.py +++ b/backend/api/v1/modules/a76/classes/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/classes/models.py b/backend/api/v1/modules/a76/classes/models.py index 3b9c5950..93694b9a 100644 --- a/backend/api/v1/modules/a76/classes/models.py +++ b/backend/api/v1/modules/a76/classes/models.py @@ -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( diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 3f106c6b..d03888ab 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -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, diff --git a/frontend/src/lib/api/dashboard/a76/classes.ts b/frontend/src/lib/api/dashboard/a76/classes.ts index d7ff36d9..16e9d68d 100644 --- a/frontend/src/lib/api/dashboard/a76/classes.ts +++ b/frontend/src/lib/api/dashboard/a76/classes.ts @@ -12,6 +12,7 @@ export interface A76Class { description_en: string | null; material_key: string | null; unit_of_measure: string; + stock_unit_of_measure?: string | null; fraction: string; us_fraction: string; sub_key: string; @@ -31,6 +32,7 @@ export interface A76ClassCreate { description_en?: string | null; material_key?: string | null; unit_of_measure?: string | null; + stock_unit_of_measure?: string | null; fraction?: string | null; us_fraction?: string | null; sub_key?: string | null; diff --git a/frontend/src/lib/components/dashboard/goods/classes/columns.ts b/frontend/src/lib/components/dashboard/goods/classes/columns.ts index 826009f6..287afc81 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/columns.ts +++ b/frontend/src/lib/components/dashboard/goods/classes/columns.ts @@ -8,8 +8,8 @@ import { normalizeMexTariffDigitsStored } from "$lib/utils/mexican-tariff-fraction"; -export function createColumns(): ColumnDef[] { - return [ +export function createColumns(options?: { includeStockUom?: boolean }): ColumnDef[] { + const baseColumns: ColumnDef[] = [ { id: "select", header: ({ table }) => { @@ -129,6 +129,24 @@ export function createColumns(): ColumnDef[] { return renderSnippet(umSnippet, { um: row.original.unit_of_measure }); } }, + ]; + + const stockUomColumn: ColumnDef = { + accessorKey: "stock_unit_of_measure", + header: "U.M. Existencia", + enableSorting: true, + cell: ({ row }) => { + const umSnippet = createRawSnippet<[{ um: string | null }]>((getProps) => { + const { um } = getProps(); + return { + render: () => `
${um || '—'}
` + }; + }); + return renderSnippet(umSnippet, { um: row.original.stock_unit_of_measure ?? null }); + } + }; + + const tailColumns: ColumnDef[] = [ { accessorKey: "fraction", header: m['common.col_fraction'](), @@ -162,6 +180,11 @@ export function createColumns(): ColumnDef[] { } } ]; + + if (options?.includeStockUom) { + return [...baseColumns, stockUomColumn, ...tailColumns]; + } + return [...baseColumns, ...tailColumns]; } export const columns = createColumns(); diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index fad6c2fa..59e4d54f 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -10,6 +10,7 @@ type MaterialType } from '$lib/api/dashboard/reference_data/material_types'; import { unitsOfMeasureApi } from '$lib/api/dashboard/a76/units_of_measure'; + import { unitConversionsApi } from '$lib/api/dashboard/a76/general_catalogs/unit-conversions'; import { getTariffFractions, type TariffFraction @@ -70,6 +71,7 @@ const isInventoryMode = $derived(mode === 'inventory'); description_en: initialData?.description_en || '', material_key: initialData?.material_key || '', unit_of_measure: initialData?.unit_of_measure || '', + stock_unit_of_measure: initialData?.stock_unit_of_measure || '', fraction: initialData?.fraction || '', us_fraction: initialData?.us_fraction || '', annual_depreciation_rate: initialData?.annual_depreciation_rate || '', @@ -86,6 +88,7 @@ const isInventoryMode = $derived(mode === 'inventory'); // Campos calculados/display material_description: '', unit_of_measure_description: '', + stock_unit_of_measure_description: '', unit_measure_key: '', fraction_umt: '', fraction_uma_key: '', @@ -115,6 +118,7 @@ const isInventoryMode = $derived(mode === 'inventory'); formData.description_en = snap.description_en ?? ''; formData.material_key = snap.material_key ?? ''; formData.unit_of_measure = snap.unit_of_measure ?? ''; + formData.stock_unit_of_measure = snap.stock_unit_of_measure ?? ''; const mxDigits = normalizeMexTariffDigitsStored( snap.import_tariff_code || snap.fraction || '' ); @@ -155,6 +159,7 @@ const isInventoryMode = $derived(mode === 'inventory'); formData.description_en = ''; formData.material_key = ''; formData.unit_of_measure = ''; + formData.stock_unit_of_measure = ''; formData.fraction = ''; formData.us_fraction = ''; formData.annual_depreciation_rate = ''; @@ -192,6 +197,9 @@ const isInventoryMode = $derived(mode === 'inventory'); let showUnitDialog = $state(false); let searchUnit = $state(''); + let unitPickerTarget = $state<'commercial' | 'stock'>('commercial'); + let conversionExists = $state(null); + let conversionCheckLoading = $state(false); import TariffFractionSelector from '$lib/components/dashboard/goods/modales/TariffFractionSelector.svelte'; @@ -265,7 +273,76 @@ const isInventoryMode = $derived(mode === 'inventory'); : unitsOfMeasureData ); + const stockUmcDiffer = $derived.by(() => { + if (!isInventoryMode) return false; + const stock = formData.stock_unit_of_measure?.trim().toUpperCase(); + const commercial = formData.unit_of_measure?.trim().toUpperCase(); + return Boolean(stock && commercial && stock !== commercial); + }); + // Funciones + async function checkUnitConversionPair(): Promise { + if (!stockUmcDiffer) { + conversionExists = null; + return; + } + + const stockUm = formData.stock_unit_of_measure.trim().toUpperCase(); + const commercialUm = formData.unit_of_measure.trim().toUpperCase(); + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + conversionExists = false; + return; + } + + conversionCheckLoading = true; + try { + const [fwd, rev] = await Promise.all([ + unitConversionsApi.list(1, 10, companyId, { + from_unit_code: stockUm, + to_unit_code: commercialUm + }), + unitConversionsApi.list(1, 10, companyId, { + from_unit_code: commercialUm, + to_unit_code: stockUm + }) + ]); + const fwdMatch = fwd.items.some( + (c) => + c.from_unit_code.toUpperCase() === stockUm && + c.to_unit_code.toUpperCase() === commercialUm + ); + const revMatch = rev.items.some( + (c) => + c.from_unit_code.toUpperCase() === commercialUm && + c.to_unit_code.toUpperCase() === stockUm + ); + conversionExists = fwdMatch || revMatch; + } catch { + conversionExists = false; + } finally { + conversionCheckLoading = false; + } + } + + $effect(() => { + if (!isInventoryMode) { + conversionExists = null; + return; + } + const stock = formData.stock_unit_of_measure; + const commercial = formData.unit_of_measure; + if (!stock?.trim() || !commercial?.trim()) { + conversionExists = null; + return; + } + if (stock.trim().toUpperCase() === commercial.trim().toUpperCase()) { + conversionExists = null; + return; + } + void checkUnitConversionPair(); + }); + async function loadUnitsOfMeasure() { if (unitsOfMeasureData.length > 0) return; try { @@ -346,15 +423,31 @@ const isInventoryMode = $derived(mode === 'inventory'); } async function openUnitOfMeasureSearch() { + unitPickerTarget = 'commercial'; + showUnitDialog = true; + searchUnit = ''; + await loadUnitsOfMeasure(); + } + + async function openStockUomSearch() { + unitPickerTarget = 'stock'; showUnitDialog = true; searchUnit = ''; await loadUnitsOfMeasure(); } function selectUnit(unit: UnitOfMeasure) { - formData.unit_of_measure = unit.code; - formData.unit_of_measure_description = unit.description; - formData.unit_measure_key = unit.claveMexicana; + if (unitPickerTarget === 'stock') { + formData.stock_unit_of_measure = unit.code; + formData.stock_unit_of_measure_description = unit.description; + if (showErrors) validateField('stock_unit_of_measure'); + } else { + formData.unit_of_measure = unit.code; + formData.unit_of_measure_description = unit.description; + formData.unit_measure_key = unit.claveMexicana; + if (showErrors && isInventoryMode) validateField('stock_unit_of_measure'); + validateField('unit_of_measure'); + } showUnitDialog = false; searchUnit = ''; } @@ -379,6 +472,24 @@ const isInventoryMode = $derived(mode === 'inventory'); } } + async function handleStockUomBlur() { + if (!isInventoryMode) return; + validateField('stock_unit_of_measure'); + const code = formData.stock_unit_of_measure?.trim().toUpperCase(); + if (!code) { + formData.stock_unit_of_measure_description = ''; + return; + } + await loadUnitsOfMeasure(); + const found = unitsOfMeasureData.find((u) => u.code.toUpperCase() === code); + if (found) { + formData.stock_unit_of_measure = found.code; + formData.stock_unit_of_measure_description = found.description; + } else { + formData.stock_unit_of_measure_description = '(Código no encontrado)'; + } + } + // Fix 2: Máscara para Fracción Americana (formato 0000.00.00.00 = 13 chars) function formatUSFraction(event: Event) { const input = event.target as HTMLInputElement; @@ -544,6 +655,18 @@ const isInventoryMode = $derived(mode === 'inventory'); searchCartaPorte = ''; } + function validateStockUnitOfMeasureField(errors: Record) { + if (!formData.stock_unit_of_measure?.trim()) { + errors.stock_unit_of_measure = 'La unidad de medida de existencia es obligatoria'; + return; + } + delete errors.stock_unit_of_measure; + if (stockUmcDiffer && conversionExists === false) { + errors.stock_unit_of_measure = + 'No existe factor de conversión para este par. Configúrelo en el catálogo de Conversiones de UM.'; + } + } + // Función de validación function validateForm(): boolean { const errors: Record = {}; @@ -566,6 +689,13 @@ const isInventoryMode = $derived(mode === 'inventory'); errors.unit_of_measure = 'La unidad de medida comercial es obligatoria'; } + if (isInventoryMode && !formData.stock_unit_of_measure?.trim()) { + errors.stock_unit_of_measure = 'La unidad de medida de existencia es obligatoria'; + } else if (isInventoryMode && stockUmcDiffer && conversionExists === false) { + errors.stock_unit_of_measure = + 'No existe factor de conversión para este par. Configúrelo en el catálogo de Conversiones de UM.'; + } + if (!formData.fraction?.trim()) { errors.fraction = 'La fracción arancelaria es obligatoria'; } @@ -611,6 +741,14 @@ const isInventoryMode = $derived(mode === 'inventory'); } else { delete errors.unit_of_measure; } + if (isInventoryMode) { + validateStockUnitOfMeasureField(errors); + } + break; + case 'stock_unit_of_measure': + if (isInventoryMode) { + validateStockUnitOfMeasureField(errors); + } break; case 'fraction': if (!formData.fraction?.trim()) { @@ -625,6 +763,10 @@ const isInventoryMode = $derived(mode === 'inventory'); } async function handleSave() { + if (isInventoryMode && stockUmcDiffer) { + await checkUnitConversionPair(); + } + // Validar formulario if (!validateForm()) { showErrors = true; @@ -789,6 +931,48 @@ const isInventoryMode = $derived(mode === 'inventory'); {/if} + {#if isInventoryMode} +
+ +
+ + + + {formData.stock_unit_of_measure_description || ''} + +
+ {#if validationErrors.stock_unit_of_measure} +

{validationErrors.stock_unit_of_measure}

+ {/if} + + {#if stockUmcDiffer} + {#if conversionCheckLoading} +

Verificando factor de conversión…

+ {:else if conversionExists === false} +

+ UM Existencia ≠ UM Comercial y no existe factor de conversión para este par. + Configúrelo primero en el catálogo de Conversiones de UM. +

+ {:else if conversionExists === true} +

Factor de conversión encontrado para este par.

+ {/if} + {/if} +
+ {/if} +