From 8b9537b5a92486c980720fd76a8fbfe48da27a9d Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 18 Mar 2026 09:40:52 -0600 Subject: [PATCH 1/2] feature/catalogo-equivalencias --- .../a76/general_catalogs/equivalencies/dto.py | 67 ++--- .../general_catalogs/equivalencies/models.py | 7 +- .../general_catalogs/equivalencies/routes.py | 1 + .../general_catalogs/equivalencies/service.py | 252 +++++++++--------- .../a76/general_catalogs/equivalencies.ts | 68 ++--- .../general_catalogs/equivalencies/columns.ts | 22 +- .../equivalencies/create-edit-dialog.svelte | 208 ++++++++++----- .../equivalencies/data-table-actions.svelte | 10 +- .../equivalencies/+page.server.ts | 10 +- .../equivalencies/+page.svelte | 20 +- 10 files changed, 379 insertions(+), 286 deletions(-) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py index f2017259..a4088acd 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py @@ -1,39 +1,15 @@ -from typing import Optional, List +from typing import Optional +from decimal import Decimal + from pydantic import BaseModel, Field, ConfigDict -# Equivalency Item DTOs - - -class EquivalencyItemBase(BaseModel): - original_field: str = Field(..., max_length=100, - description="Original Field (Unit of Measure)") - external_field: str = Field(..., max_length=100, - description="External Field") - - -class EquivalencyItemCreate(EquivalencyItemBase): - pass - - -class EquivalencyItemUpdate(BaseModel): - original_field: Optional[str] = Field(None, max_length=100) - external_field: Optional[str] = Field(None, max_length=100) - - -class EquivalencyItemResponse(EquivalencyItemBase): - id: int - equivalency_id: int - - model_config = ConfigDict(from_attributes=True) - -# Equivalency DTOs +# Equivalency (parent) DTOs class EquivalencyBase(BaseModel): - fraccion_mex: str = Field(..., max_length=10, description="Fraccion MX (Identifier)") - fraccion_us: str = Field(..., max_length=100, description="Fraccion US (External Field)") - description: Optional[str] = Field( - None, max_length=200, description="Description") + # 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): @@ -41,8 +17,7 @@ class EquivalencyCreate(EquivalencyBase): class EquivalencyUpdate(BaseModel): - fraccion_mex: Optional[str] = Field(None, max_length=10) - fraccion_us: Optional[str] = Field(None, max_length=100) + identifier: Optional[str] = Field(None, max_length=10) description: Optional[str] = Field(None, max_length=200) @@ -53,4 +28,30 @@ class EquivalencyResponse(EquivalencyBase): model_config = ConfigDict(from_attributes=True) + +# Equivalency Item DTOs (catálogo por fila) + + +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") + + +class EquivalencyItemCreate(EquivalencyItemBase): + conversion_factor: Decimal = Field(..., description="Conversion factor") + + +class EquivalencyItemUpdate(BaseModel): + original_field: Optional[str] = Field(None, max_length=100) + external_field: Optional[str] = Field(None, max_length=100) + conversion_factor: Optional[Decimal] = None + + +class EquivalencyItemResponse(EquivalencyItemBase): + id: int + equivalency_id: int + tenant_id: int + company_id: int + model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py index 5211bc10..87de137c 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py @@ -1,5 +1,6 @@ from typing import Optional, List -from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, ForeignKeyConstraint +from decimal import Decimal +from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, ForeignKeyConstraint, Numeric from sqlalchemy.orm import Mapped, mapped_column, relationship from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base @@ -50,6 +51,10 @@ class EquivalencyItem(Base, TenantScopedMixin, TimestampMixin): 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() diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py index d7c1c42f..d5f00b96 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py @@ -38,6 +38,7 @@ item_crud = TenantCRUDRoutes( tags=["Equivalency Items"], resource_name="EquivalencyItem", enable_list=True, + enable_filters=True, ) # Custom endpoint for creating items nested under equivalency diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py index 3580ef22..f55b2bf8 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py @@ -1,6 +1,5 @@ from typing import List, Optional, Tuple, Dict, Any -from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select +from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from fastapi import HTTPException @@ -20,27 +19,26 @@ class EquivalencyService: ) -> Tuple[List[Equivalency], int]: query = db.query(Equivalency).filter( Equivalency.tenant_id == tenant_id, - Equivalency.company_id == company_id - ).options(joinedload(Equivalency.items)) + Equivalency.company_id == company_id, + ) if filters: - if 'fraccion_mex' in filters: - query = query.filter(Equivalency.identifier.ilike(f"%{filters['fraccion_mex']}%")) - if 'description' in filters: - query = query.filter(Equivalency.description.ilike(f"%{filters['description']}%")) + # 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() - - # Map internal fields to DTO fields - for item in items: - item.fraccion_mex = item.identifier - # Try to find the first item to get fraccion_us - if item.items: - item.fraccion_us = item.items[0].external_field - else: - item.fraccion_us = "" - return items, total @staticmethod @@ -54,15 +52,8 @@ class EquivalencyService: Equivalency.id == id, Equivalency.tenant_id == tenant_id, Equivalency.company_id == company_id - ).options(joinedload(Equivalency.items)).first() - - if item: - item.fraccion_mex = item.identifier - if item.items: - item.fraccion_us = item.items[0].external_field - else: - item.fraccion_us = "" - + ).first() + return item @staticmethod @@ -73,53 +64,27 @@ class EquivalencyService: company_id: int ) -> Equivalency: try: - # Create Parent db_obj = Equivalency( - identifier=data.fraccion_mex, + identifier=data.identifier, description=data.description, tenant_id=tenant_id, - company_id=company_id + company_id=company_id, ) db.add(db_obj) - db.flush() # Flush to get ID - - # Create Child Item (mapping fraccion_mex -> original_field, fraccion_us -> external_field) - item = EquivalencyItem( - equivalency_id=db_obj.id, - original_field=data.fraccion_mex, # Must exist in units_of_measure - external_field=data.fraccion_us, - tenant_id=tenant_id, - company_id=company_id - ) - db.add(item) - db.commit() db.refresh(db_obj) - - # Map for response - db_obj.fraccion_mex = db_obj.identifier - db_obj.fraccion_us = item.external_field - return db_obj except IntegrityError as e: db.rollback() - error_msg = str(e.orig) if hasattr(e, 'orig') else str(e) - print(f"IntegrityError in create: {error_msg}") - - if "units_of_measure" in error_msg: - raise HTTPException( - status_code=400, - detail=f"La Fracción MX '{data.fraccion_mex}' no es válida. Debe existir en el catálogo de Unidades de Medida." - ) + 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 una equivalencia para la Fracción MX '{data.fraccion_mex}'." - ) - raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}") + 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() - print(f"Error in create: {str(e)}") raise e @staticmethod @@ -135,58 +100,19 @@ class EquivalencyService: return None try: - if data.fraccion_mex: - db_obj.identifier = data.fraccion_mex - if data.description: - db_obj.description = data.description - - # Update Item - item = None - if db_obj.items: - item = db_obj.items[0] - - if item: - if data.fraccion_us: - item.external_field = data.fraccion_us - if data.fraccion_mex: - item.original_field = data.fraccion_mex - else: - # Create if missing - if data.fraccion_us or data.fraccion_mex: - item = EquivalencyItem( - equivalency_id=db_obj.id, - original_field=data.fraccion_mex or db_obj.identifier, - external_field=data.fraccion_us or "", - tenant_id=tenant_id, - company_id=company_id - ) - db.add(item) + 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) - - # Map for response - db_obj.fraccion_mex = db_obj.identifier - if db_obj.items: - db_obj.fraccion_us = db_obj.items[0].external_field - else: - db_obj.fraccion_us = "" - return db_obj except IntegrityError as e: db.rollback() - error_msg = str(e.orig) if hasattr(e, 'orig') else str(e) - print(f"IntegrityError in update: {error_msg}") - - if "units_of_measure" in error_msg: - raise HTTPException( - status_code=400, - detail=f"La Fracción MX '{data.fraccion_mex or db_obj.identifier}' no es válida. Debe existir en el catálogo de Unidades de Medida." - ) - raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}") + 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() - print(f"Error in update: {str(e)}") raise e @staticmethod @@ -216,8 +142,21 @@ class EquivalencyItemService: ) -> Tuple[List[EquivalencyItem], int]: query = db.query(EquivalencyItem).filter( EquivalencyItem.tenant_id == tenant_id, - EquivalencyItem.company_id == company_id + EquivalencyItem.company_id == company_id, ) + + if filters: + if "from_unit_code" in filters: + query = query.filter( + EquivalencyItem.original_field.ilike( + f"%{filters['from_unit_code']}%" + ) + ) + if "to_unit_code" in filters: + query = query.filter( + EquivalencyItem.external_field.ilike(f"%{filters['to_unit_code']}%") + ) + total = query.count() items = query.offset(skip).limit(limit).all() return items, total @@ -242,15 +181,51 @@ class EquivalencyItemService: tenant_id: int, company_id: int ) -> EquivalencyItem: - db_obj = EquivalencyItem( - **data.model_dump(), - tenant_id=tenant_id, - company_id=company_id - ) - db.add(db_obj) - db.commit() - db.refresh(db_obj) - return db_obj + 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, + 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 "units_of_measure" in error_msg: + raise HTTPException( + status_code=400, + detail=( + "Verifica que las unidades existan en el catálogo de U.M." + ), + ) from e + raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}") from e @staticmethod def create_nested( @@ -264,6 +239,7 @@ class EquivalencyItemService: 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 ) @@ -283,13 +259,47 @@ class EquivalencyItemService: db_obj = EquivalencyItemService.get_by_id(db, id, tenant_id, company_id) if not db_obj: return None - - for key, value in data.model_dump(exclude_unset=True).items(): - setattr(db_obj, key, value) - - db.commit() - db.refresh(db_obj) - return db_obj + + 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 + 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 + 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( diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts index 0b6c70c3..74788989 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts @@ -1,43 +1,44 @@ import { api } from '$lib/api'; import type { ApiResponse } from '$lib/api'; -export interface Equivalency { +export interface EquivalencyItem { id: number; - fraccion_mex: string; - fraccion_us: string; - description?: string; + original_field: string; // from_unit_code + external_field: string; // to_unit_code + conversion_factor: number | null; + equivalency_id: number; tenant_id: number; company_id: number; created_at: string; updated_at?: string; } -export interface EquivalencyCreate { - fraccion_mex: string; - fraccion_us: string; - description?: string; +export interface EquivalencyItemCreate { + original_field: string; + external_field: string; + conversion_factor: number; } -export interface EquivalencyUpdate { - fraccion_mex?: string; - fraccion_us?: string; - description?: string; +export interface EquivalencyItemUpdate { + original_field?: string; + external_field?: string; + conversion_factor?: number | null; } -export interface EquivalencyListResponse { - items: Equivalency[]; +export interface EquivalencyItemListResponse { + items: EquivalencyItem[]; total: number; page: number; page_size: number; pages: number; } -export async function getEquivalencies( +export async function getEquivalencyItems( page: number = 1, pageSize: number = 50, companyId: number, filters: Record = {} -): Promise> { +) : Promise> { const params = new URLSearchParams({ page: page.toString(), page_size: pageSize.toString(), @@ -45,28 +46,31 @@ export async function getEquivalencies( ...filters }); - return await api.get(`/v1/a76/equivalencies/?${params.toString()}`); + return await api.get(`/v1/a76/equivalencies/items/?${params.toString()}`); } -export async function getEquivalency(id: number, companyId: number): Promise> { - return await api.get(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`); -} - -export async function createEquivalency( - data: EquivalencyCreate, +export async function getEquivalencyItem( + id: number, companyId: number -): Promise> { - return await api.post(`/v1/a76/equivalencies/?company_id=${companyId}`, data); +): Promise> { + return await api.get(`/v1/a76/equivalencies/items/${id}/?company_id=${companyId}`); } -export async function updateEquivalency( - id: number, - data: EquivalencyUpdate, +export async function createEquivalencyItem( + data: EquivalencyItemCreate, companyId: number -): Promise> { - return await api.put(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`, data); +): Promise> { + return await api.post(`/v1/a76/equivalencies/items/?company_id=${companyId}`, data); } -export async function deleteEquivalency(id: number, companyId: number): Promise> { - return await api.delete(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`); +export async function updateEquivalencyItem( + id: number, + data: EquivalencyItemUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/equivalencies/items/${id}/?company_id=${companyId}`, data); +} + +export async function deleteEquivalencyItem(id: number, companyId: number): Promise> { + return await api.delete(`/v1/a76/equivalencies/items/${id}?company_id=${companyId}`); } diff --git a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/columns.ts b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/columns.ts index f4a92c75..61d243e7 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/columns.ts +++ b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/columns.ts @@ -1,25 +1,25 @@ import type { ColumnDef } from '@tanstack/table-core'; -import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; +import type { EquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; import { renderComponent } from '$lib/components/ui/data-table'; import DataTableActions from './data-table-actions.svelte'; import { Header } from '$lib/components/ui/alert-dialog'; -export function createColumns(onSuccess?: () => void): ColumnDef[] { +export function createColumns(onSuccess?: () => void): ColumnDef[] { return [ { - accessorKey: 'fraccion_mex', - header: 'Fracción MX', - cell: ({ row }) => row.original.fraccion_mex || 'N/A' + accessorKey: 'original_field', + header: 'Desde código', + cell: ({ row }) => row.original.original_field || 'N/A' }, { - accessorKey: 'fraccion_us', - header: 'Fracción US', - cell: ({ row }) => row.original.fraccion_us || 'N/A' + accessorKey: 'external_field', + header: 'Hacia código', + cell: ({ row }) => row.original.external_field || 'N/A' }, { - accessorKey: 'description', - header: 'Descripción', - cell: ({ row }) => row.original.description || 'N/A' + accessorKey: 'conversion_factor', + header: 'Factor de conversión', + cell: ({ row }) => (row.original.conversion_factor ?? 0).toString() }, { id: 'actions', diff --git a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte index ae9a0540..f445acab 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte @@ -4,12 +4,13 @@ import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import { companyStore } from '$lib/stores/company.svelte'; + import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte'; + import { FolderSearch, Scale } from 'lucide-svelte'; import { - createEquivalency, - updateEquivalency, - type Equivalency + createEquivalencyItem, + updateEquivalencyItem, + type EquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; - import { obtenerAtajosFormularioEquivalencias } from '$lib/config/shortcuts/dashboard/general_catalogs/equivalencies/edit'; let { open = $bindable(false), @@ -17,46 +18,50 @@ onSuccess }: { open: boolean; - item?: Equivalency | null; + item?: EquivalencyItem | null; onSuccess?: () => void; } = $props(); - // Atajos - const isEdit = $derived(!!item); const title = $derived( - isEdit ? `Editar Equivalencia ${item?.fraccion_mex || ''}` : 'Nueva Equivalencia' + isEdit + ? `Editar Equivalencia ${item?.original_field || ''} → ${item?.external_field || ''}` + : 'Nueva Equivalencia' ); - // Estado del formulario let formData = $state({ - fraccion_mex: '', - fraccion_us: '', - description: '' + original_field: '', + external_field: '', + conversion_factor: '' }); let loading = $state(false); let error = $state(null); - // Cargar datos + let showFromUomModal = $state(false); + let showToUomModal = $state(false); + $effect(() => { - if (open) { - if (item) { - formData = { - fraccion_mex: item.fraccion_mex || '', - fraccion_us: item.fraccion_us || '', - description: item.description || '' - }; - } else { - // Reset - formData = { - fraccion_mex: '', - fraccion_us: '', - description: '' - }; - } - error = null; + if (!open) return; + + if (item) { + formData = { + original_field: item.original_field || '', + external_field: item.external_field || '', + conversion_factor: + item.conversion_factor !== null && item.conversion_factor !== undefined + ? item.conversion_factor.toString() + : '' + }; + } else { + formData = { + original_field: '', + external_field: '', + conversion_factor: '' + }; } + + error = null; }); async function handleSubmit() { @@ -70,15 +75,24 @@ error = null; try { - let response; - if (isEdit && item) { - response = await updateEquivalency(item.id, formData, companyId); - } else { - response = await createEquivalency(formData, companyId); - } + if (!formData.original_field.trim()) throw new Error('Código origen requerido'); + if (!formData.external_field.trim()) throw new Error('Código destino requerido'); + if (formData.conversion_factor === '' || formData.conversion_factor === null) + throw new Error('Factor de conversión requerido'); - if (response.error) { - throw new Error(response.error); + const factor = parseFloat(formData.conversion_factor); + if (isNaN(factor)) throw new Error('El factor de conversión debe ser un número válido'); + + const dataToSend = { + original_field: formData.original_field.trim(), + external_field: formData.external_field.trim(), + conversion_factor: factor + }; + + if (isEdit && item) { + await updateEquivalencyItem(item.id, dataToSend, companyId); + } else { + await createEquivalencyItem(dataToSend, companyId); } open = false; @@ -111,46 +125,104 @@ {/if} -
- - -
+
+
+ +
+
+ + (showFromUomModal = true)} + class="cursor-pointer pl-9 font-mono" + placeholder="Seleccione..." + disabled={loading} + required + /> +
+ +
+
-
- - -
+
+ +
+
+ + (showToUomModal = true)} + class="cursor-pointer pl-9 font-mono" + placeholder="Seleccione..." + disabled={loading} + required + /> +
+ +
+
-
- - +
+ + +
+ + { + formData.original_field = u.code; + }} +/> + + { + formData.external_field = u.code; + }} +/> diff --git a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/data-table-actions.svelte b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/data-table-actions.svelte index 8128f9ee..d2d7e9d9 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/data-table-actions.svelte @@ -2,8 +2,8 @@ import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte'; - import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; - import { deleteEquivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; + import type { EquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; + import { deleteEquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; import { companyStore } from '$lib/stores/company.svelte'; import CreateEditDialog from './create-edit-dialog.svelte'; @@ -11,14 +11,14 @@ item, onSuccess }: { - item: Equivalency; + item: EquivalencyItem; onSuccess?: () => void; } = $props(); let loading = $state(false); let error = $state(null); let dialogOpen = $state(false); - let selectedItem = $state(null); + let selectedItem = $state(null); async function handleDelete() { if (!confirm('¿Está seguro de eliminar esta equivalencia?')) { @@ -33,7 +33,7 @@ loading = true; try { - const response = await deleteEquivalency(item.id, companyId); + const response = await deleteEquivalencyItem(item.id, companyId); if (response.error) { throw new Error(response.error); diff --git a/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.server.ts b/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.server.ts index b1b1a5c7..66175ed1 100644 --- a/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.server.ts +++ b/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.server.ts @@ -24,11 +24,11 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { } const filters: Record = {}; - const identifier = url.searchParams.get('identifier'); - const description = url.searchParams.get('description'); + const fromUnitCode = url.searchParams.get('from_unit_code'); + const toUnitCode = url.searchParams.get('to_unit_code'); - if (identifier) filters.identifier = identifier; - if (description) filters.description = description; + if (fromUnitCode) filters.from_unit_code = fromUnitCode; + if (toUnitCode) filters.to_unit_code = toUnitCode; const queryParams = new URLSearchParams({ page: page.toString(), @@ -37,7 +37,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { ...filters }); - const response = await authenticatedFetch(`v1/a76/equivalencies/?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch); + const response = await authenticatedFetch(`v1/a76/equivalencies/items/?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch); if (!response.ok) { return { error: 'Failed to load', equivalencies: { items: [], total: 0, page, page_size: pageSize, pages: 0 } }; diff --git a/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.svelte index af77bff5..72cae2e0 100644 --- a/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/equivalencies/+page.svelte @@ -26,8 +26,8 @@ ); // Filtros - let searchFraccion = $state($page.url.searchParams.get('fraccion_mex') || ''); - let searchDesc = $state($page.url.searchParams.get('description') || ''); + let searchFrom = $state($page.url.searchParams.get('from_unit_code') || ''); + let searchTo = $state($page.url.searchParams.get('to_unit_code') || ''); let timeout: ReturnType; function handleSearch() { @@ -35,11 +35,11 @@ clearTimeout(timeout); timeout = setTimeout(() => { const url = new URL($page.url); - if (searchFraccion) url.searchParams.set('fraccion_mex', searchFraccion); - else url.searchParams.delete('fraccion_mex'); + if (searchFrom) url.searchParams.set('from_unit_code', searchFrom); + else url.searchParams.delete('from_unit_code'); - if (searchDesc) url.searchParams.set('description', searchDesc); - else url.searchParams.delete('description'); + if (searchTo) url.searchParams.set('to_unit_code', searchTo); + else url.searchParams.delete('to_unit_code'); url.searchParams.set('page', '1'); goto(url, { keepFocus: true, noScroll: true }); @@ -67,15 +67,15 @@
From 0a68454593f47279dfb7b492712764edc2bc69ad Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 18 Mar 2026 13:22:45 -0600 Subject: [PATCH 2/2] 6feature/fix-en-base-a-scaii --- .../a76/general_catalogs/equivalencies/dto.py | 65 +-- .../general_catalogs/equivalencies/models.py | 71 ++-- .../general_catalogs/equivalencies/routes.py | 89 +--- .../general_catalogs/equivalencies/service.py | 331 ++++++--------- backend/api/v1/modules/a76/items/models.py | 4 +- .../a76/general_catalogs/equivalencies.ts | 99 ++++- .../equivalencies/catalog-columns.ts | 40 ++ .../catalog-create-edit-dialog.svelte | 146 +++++++ .../catalog-data-table-actions.svelte | 71 ++++ .../general_catalogs/equivalencies/columns.ts | 2 - .../equivalencies/create-edit-dialog.svelte | 128 ++---- .../data-equivalencies-dialog.svelte | 383 ++++++++++++++++++ .../equivalencies/+page.server.ts | 11 +- .../equivalencies/+page.svelte | 71 ++-- 14 files changed, 1022 insertions(+), 489 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-columns.ts create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-create-edit-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-data-table-actions.svelte create mode 100644 frontend/src/lib/components/dashboard/general_catalogs/equivalencies/data-equivalencies-dialog.svelte diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py index a4088acd..4494351f 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py index 87de137c..211badd7 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/models.py @@ -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]) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py index d5f00b96..6e076725 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py b/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py index f55b2bf8..2732877c 100644 --- a/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/equivalencies/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py index e9bcb55a..d1a569df 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -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", ) diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts index 74788989..b54abcfd 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/equivalencies.ts @@ -1,22 +1,23 @@ import { api } from '$lib/api'; import type { ApiResponse } from '$lib/api'; +// ======================== +// ITEMS (pool global) +// ======================== + export interface EquivalencyItem { id: number; - original_field: string; // from_unit_code - external_field: string; // to_unit_code + original_field: string; + external_field: string; conversion_factor: number | null; - equivalency_id: number; tenant_id: number; company_id: number; - created_at: string; - updated_at?: string; } export interface EquivalencyItemCreate { original_field: string; external_field: string; - conversion_factor: number; + conversion_factor?: number | null; } export interface EquivalencyItemUpdate { @@ -38,14 +39,13 @@ export async function getEquivalencyItems( pageSize: number = 50, companyId: number, filters: Record = {} -) : Promise> { +): Promise> { const params = new URLSearchParams({ page: page.toString(), page_size: pageSize.toString(), company_id: companyId.toString(), ...filters }); - return await api.get(`/v1/a76/equivalencies/items/?${params.toString()}`); } @@ -71,6 +71,87 @@ export async function updateEquivalencyItem( return await api.put(`/v1/a76/equivalencies/items/${id}/?company_id=${companyId}`, data); } -export async function deleteEquivalencyItem(id: number, companyId: number): Promise> { +export async function deleteEquivalencyItem( + id: number, + companyId: number +): Promise> { return await api.delete(`/v1/a76/equivalencies/items/${id}?company_id=${companyId}`); } + +// ======================== +// CATÁLOGO (referencia item_id) +// ======================== + +export interface Equivalency { + id: number; + identifier: string; + description: string | null; + item_id: number | null; + item: EquivalencyItem | null; + tenant_id: number; + company_id: number; +} + +export interface EquivalencyCreate { + identifier: string; + description?: string | null; + item_id?: number | null; +} + +export interface EquivalencyUpdate { + identifier?: string; + description?: string | null; + item_id?: number | null; +} + +export interface EquivalencyListResponse { + items: Equivalency[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getEquivalencies( + page: number = 1, + pageSize: number = 50, + companyId: number, + filters: Record = {} +): Promise> { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + return await api.get(`/v1/a76/equivalencies/?${params.toString()}`); +} + +export async function getEquivalency( + id: number, + companyId: number +): Promise> { + return await api.get(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`); +} + +export async function createEquivalency( + data: EquivalencyCreate, + companyId: number +): Promise> { + return await api.post(`/v1/a76/equivalencies/?company_id=${companyId}`, data); +} + +export async function updateEquivalency( + id: number, + data: EquivalencyUpdate, + companyId: number +): Promise> { + return await api.put(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`, data); +} + +export async function deleteEquivalency( + id: number, + companyId: number +): Promise> { + return await api.delete(`/v1/a76/equivalencies/${id}?company_id=${companyId}`); +} diff --git a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-columns.ts b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-columns.ts new file mode 100644 index 00000000..32ee7c5f --- /dev/null +++ b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-columns.ts @@ -0,0 +1,40 @@ +import type { ColumnDef } from '@tanstack/table-core'; +import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies'; +import { renderComponent } from '$lib/components/ui/data-table'; +import CatalogDataTableActions from './catalog-data-table-actions.svelte'; + +export function createCatalogColumns({ + onInsertItems, + onEdit, + onSuccess +}: { + onInsertItems: (equivalency: Equivalency) => void; + onEdit: (equivalency: Equivalency) => void; + onSuccess?: () => void; +}): ColumnDef[] { + return [ + { + accessorKey: 'identifier', + header: 'Identificador', + cell: ({ row }) => row.original.identifier || 'N/A' + }, + { + accessorKey: 'description', + header: 'Descripción', + cell: ({ row }) => row.original.description || '' + }, + { + id: 'actions', + Header: 'Acciones', + cell: ({ row }) => { + return renderComponent(CatalogDataTableActions, { + item: row.original, + onInsertItems, + onEdit, + onSuccess + }); + } + } + ]; +} + diff --git a/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-create-edit-dialog.svelte new file mode 100644 index 00000000..077f837a --- /dev/null +++ b/frontend/src/lib/components/dashboard/general_catalogs/equivalencies/catalog-create-edit-dialog.svelte @@ -0,0 +1,146 @@ + + + + + + {title} + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="grid gap-4 py-4" + > + {#if error} +
{error}
+ {/if} + +
+
+ + +
+ +
+ +