From c7412abaa98e3d5096c2de724fe6b3fdd64776ab Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 28 Apr 2026 10:36:16 -0600 Subject: [PATCH 01/20] feature/visible-clicks-csv --- frontend/src/routes/dashboard/csv-upload/+page.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index c6986366..69383fdf 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -1139,6 +1139,9 @@

Importación Masiva de Datos (CSV)

+

+ Click izquierdo: cargar archivo CSV. Click derecho: descargar estructura (plantilla). +

From d67d1c5538ed2c272045847250452d0c4eea8d8e Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 28 Apr 2026 10:50:58 -0600 Subject: [PATCH 02/20] feature/sitar-fractions-upgrade --- .../fractions/tariff_fractions/service.py | 101 +++-- .../fractions/us_tariff_fractions/dto.py | 19 +- .../fractions/us_tariff_fractions/routes.py | 1 + .../fractions/us_tariff_fractions/service.py | 2 +- .../fractions/test_tariff_fraction_mapper.py | 57 +++ frontend/src/lib/api/dashboard/a76/sitar.ts | 77 +++- .../classes/forms/FixedAssetClassForm.svelte | 6 +- .../goods/fractions/SitarFractionTabs.svelte | 408 ++++++++++++++++++ .../goods/fractions/TariffFractionList.svelte | 38 +- .../modales/TariffFractionSelector.svelte | 3 +- .../us-fraction-selector-dialog.svelte | 3 +- .../lib/utils/tariff-fraction-display.test.ts | 43 ++ .../src/lib/utils/tariff-fraction-display.ts | 9 + .../tariff-fractions/sitar/+page.svelte | 3 +- 14 files changed, 708 insertions(+), 62 deletions(-) create mode 100644 backend/tests/unit/general_catalogs/fractions/test_tariff_fraction_mapper.py create mode 100644 frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte create mode 100644 frontend/src/lib/utils/tariff-fraction-display.test.ts create mode 100644 frontend/src/lib/utils/tariff-fraction-display.ts diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py index a54f8cf7..a98f78eb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py @@ -9,6 +9,7 @@ from sqlalchemy.exc import IntegrityError from fastapi import HTTPException import zlib import logging +import re from .models import TariffFraction from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO @@ -22,7 +23,42 @@ logger = logging.getLogger(__name__) class TariffFractionMapper: """Helper to map Sitar responses to Local domain objects""" - + + @staticmethod + def _digits_only(value: Optional[str]) -> str: + return re.sub(r"\D", "", (value or "").strip()) + + @staticmethod + def _format_mx_fraction(code: str) -> str: + if code.isdigit() and len(code) == 8: + return f"{code[:2]}.{code[2:4]}.{code[4:6]}.{code[6:]}" + if code.isdigit() and len(code) == 6: + return f"{code[:2]}.{code[2:4]}.{code[4:]}" + return code + + @staticmethod + def _format_usa_fraction(code: str) -> str: + if code.isdigit() and len(code) == 10: + return f"{code[:4]}.{code[4:6]}.{code[6:8]}.{code[8:]}" + if code.isdigit() and len(code) == 8: + return f"{code[:4]}.{code[4:6]}.{code[6:]}" + return code + + @staticmethod + def _normalized_pair(raw_code: Optional[str], raw_fraction: Optional[str], formatter) -> Tuple[str, str]: + """Return (code_without_separators, formatted_fraction).""" + code = TariffFractionMapper._digits_only(raw_code) + fraction = (raw_fraction or "").strip() + if not code: + code = TariffFractionMapper._digits_only(fraction) + if not fraction: + fraction = formatter(code) + elif "." not in fraction and "-" not in fraction: + fraction = formatter(TariffFractionMapper._digits_only(fraction)) + if not fraction: + fraction = formatter(code) + return code, fraction + @staticmethod def to_domain(fraccion: FraccionesResponse) -> TariffFraction: # Generate ID: Use SYSID if available, else composite hash of code + nico @@ -33,22 +69,9 @@ class TariffFractionMapper: unique_str = f"{fraccion.FRACCION}-{fraccion.NICO}" fake_id = zlib.crc32(unique_str.encode('utf-8')) - # UX Enhauncement: Sitar API returns empty strings for some fields. - # We fill them with fallbacks so the frontend table isn't 90% empty. - code_val = fraccion.FRACCION - - # Formatting Logic: if FRACCIONPUNTO is empty, try to format code_val - formatted_fraction = code_val - if fraccion.FRACCIONPUNTO: - formatted_fraction = fraccion.FRACCIONPUNTO - elif code_val and code_val.isdigit() and len(code_val) == 8: - # Standard 8 digit format: XX.XX.XX.XX - formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:6]}.{code_val[6:]}" - elif code_val and code_val.isdigit() and len(code_val) == 6: - # 6 digit (subheading): XX.XX.XX - formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:]}" - - fraction_val = formatted_fraction + code_val, fraction_val = TariffFractionMapper._normalized_pair( + fraccion.FRACCION, fraccion.FRACCIONPUNTO, TariffFractionMapper._format_mx_fraction + ) description_val = fraccion.DESCRIPCION if fraccion.DESCRIPCION else "(Sin descripción)" tf = TariffFraction( @@ -73,10 +96,15 @@ class TariffFractionMapper: @staticmethod def to_domain_usa(item: FraccionesUSAResponse) -> TariffFraction: """Map US Fraction to Domain""" + code_val, fraction_val = TariffFractionMapper._normalized_pair( + item.FRACCION_SIN_PUNTO, + item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR, + TariffFractionMapper._format_usa_fraction, + ) return TariffFraction( id=item.CONSECUTIVO, - code=item.FRACCION_SIN_PUNTO or "", - fraction=item.FRACCION_CON_PUNTO or "", + code=code_val, + fraction=fraction_val, description=item.DESCRIPCION or "(Sin descripción)", nico=None, # Not applicable umt=item.UNIDADCANTIDAD, @@ -183,28 +211,27 @@ class TariffFractionService: # Map filters sitar_fraccion = None sitar_nico = None + sitar_description = None - # Default level logic - level_filter = 5 # Default legacy + # Legacy parity: base query is always Nivel = 5 unless caller explicitly requests another level. + level_filter = 5 if filters and filters.get("level") is not None: level_filter = filters["level"] - - # Allow disabling level filter explicitly + # UI compatibility: level -1 means "sin filtro de nivel". if level_filter == -1: level_filter = None if filters: if filters.get("search"): - term = filters["search"] - # Heuristic: if search starts with digit (after removing dots), treat as code/fraccion/nico - # This covers "0101", "01.01", "020691A" + term = str(filters["search"]).strip() + # Legacy-like behavior: + # - Numeric search targets fracción first. + # - Text search targets descripción. clean_term = term.replace(".", "") - if clean_term and clean_term[0].isdigit(): + if clean_term.isdigit(): sitar_fraccion = clean_term else: - # Attempt description search via API first - logger.info(f"Search term '{term}' identified as text. Attempting API description search.") - pass + sitar_description = term if filters.get("code"): sitar_fraccion = filters["code"] @@ -212,14 +239,8 @@ class TariffFractionService: sitar_fraccion = filters["fraction"] if filters.get("nico"): sitar_nico = filters["nico"] - - # Determine description filter - sitar_description = None - # Only use description if we didn't use it as code above - if filters and filters.get("search"): - clean_term = filters["search"].replace(".", "") - if not (clean_term and clean_term[0].isdigit()): - sitar_description = filters["search"] + if sitar_fraccion is not None: + sitar_fraccion = str(sitar_fraccion).replace(".", "").strip() # Note: Sitar search might not return total count. # We fetch page items. Pagination might be tricky if Sitar doesn't return total. @@ -239,6 +260,8 @@ class TariffFractionService: # Map items items = [TariffFractionMapper.to_domain(item) for item in sitar_items] + # Legacy browse behavior: keep table in ascending fracción order. + items = sorted(items, key=lambda row: ((row.code or ""), (row.nico or ""))) # Estimate total (Sitar service doesn't return total currently) # If we got full limit, assume there are more. @@ -289,8 +312,6 @@ class TariffFractionService: query = query.filter(TariffFraction.umt.ilike(f"%{filters['umt']}%")) total = query.count() - # Add deterministic sort order - query = query.order_by(TariffFraction.fraction) items = query.offset(skip).limit(limit).all() return items, total diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py index c316dc08..b30d2d59 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Optional, Any from pydantic import BaseModel, Field, ConfigDict, model_validator +import re class USTariffFractionCreateDTO(BaseModel): @@ -62,10 +63,20 @@ class USTariffFractionResponseDTO(BaseModel): if raw_code: code_str = str(raw_code) - # fraction keeps the original formatted string - fraction = code_str - # code strips dots and hyphens - code = code_str.replace(".", "").replace("-", "") + fraction_raw = "" + if isinstance(data, dict): + fraction_raw = str(data.get("fraction") or "") + else: + fraction_raw = str(getattr(data, "fraction", "") or "") + + code = re.sub(r"[.\s-]", "", code_str) + fraction = fraction_raw.strip() or code_str + if "." not in fraction and "-" not in fraction: + only_digits = re.sub(r"[.\s-]", "", fraction) + if len(only_digits) == 10: + fraction = f"{only_digits[:4]}.{only_digits[4:6]}.{only_digits[6:8]}.{only_digits[8:]}" + elif len(only_digits) == 8: + fraction = f"{only_digits[:4]}.{only_digits[4:6]}.{only_digits[6:]}" if isinstance(data, dict): data["code"] = code diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py index e07bf0fd..a1cee9f5 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py @@ -28,6 +28,7 @@ def _sitar_row_to_us_response_payload(item: FraccionesUSAResponse) -> dict: return { "id": item.CONSECUTIVO, "code": canon, + "fraction": item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR or canon, "prefix": item.FRACCION_SIN_PUNTO, "type_code": str(item.NIVEL) if item.NIVEL is not None else None, "ad_valorem": american_fraction_ad_valorem_from_row(item), diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py index 661bf01b..e5f8cfbf 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py @@ -128,7 +128,7 @@ class USTariffFractionService: ) total = query.count() - items = query.order_by(USTariffFraction.code).offset(skip).limit(limit).all() + items = query.offset(skip).limit(limit).all() return items, total diff --git a/backend/tests/unit/general_catalogs/fractions/test_tariff_fraction_mapper.py b/backend/tests/unit/general_catalogs/fractions/test_tariff_fraction_mapper.py new file mode 100644 index 00000000..06e61c8c --- /dev/null +++ b/backend/tests/unit/general_catalogs/fractions/test_tariff_fraction_mapper.py @@ -0,0 +1,57 @@ +from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.service import ( + TariffFractionMapper, +) +from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import ( + USTariffFractionResponseDTO, +) +from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse + + +def test_to_domain_usa_keeps_separate_code_and_fraction(): + row = FraccionesUSAResponse( + CONSECUTIVO=10, + FRACCION_SIN_PUNTO="1234567890", + FRACCION_CON_PUNTO="1234.56.78.90", + DESCRIPCION="Test", + UNIDADCANTIDAD="KG", + TARIFA1="5%", + TARIFA2="0%", + ) + + mapped = TariffFractionMapper.to_domain_usa(row) + + assert mapped.code == "1234567890" + assert mapped.fraction == "1234.56.78.90" + + +def test_to_domain_usa_formats_fraction_when_only_code_available(): + row = FraccionesUSAResponse( + CONSECUTIVO=11, + FRACCION_SIN_PUNTO="9876543210", + FRACCION_CON_PUNTO=None, + FRACCION_MOSTRAR=None, + DESCRIPCION="Fallback", + UNIDADCANTIDAD="PZA", + TARIFA1="7.5%", + TARIFA2="0%", + ) + + mapped = TariffFractionMapper.to_domain_usa(row) + + assert mapped.code == "9876543210" + assert mapped.fraction == "9876.54.32.10" + + +def test_us_response_dto_preserves_fraction_when_provided(): + dto = USTariffFractionResponseDTO.model_validate( + { + "id": 1, + "code": "1111.22.33.44", + "fraction": "1111.22.33.44", + "description": "DTO test", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + ) + assert dto.code == "1111223344" + assert dto.fraction == "1111.22.33.44" diff --git a/frontend/src/lib/api/dashboard/a76/sitar.ts b/frontend/src/lib/api/dashboard/a76/sitar.ts index 829b1b42..9692ab5a 100644 --- a/frontend/src/lib/api/dashboard/a76/sitar.ts +++ b/frontend/src/lib/api/dashboard/a76/sitar.ts @@ -39,16 +39,81 @@ export interface SitarALADI { } export async function getSitarTLCS(filters: { fraccion: string; nico?: string }): Promise> { - const queryParams = new URLSearchParams(filters); - return await api.get(`/v1/sitar/tlcs/?${queryParams.toString()}`); + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/tlcs/?${queryParams.toString()}`); } export async function getSitarPROSEC(filters: { fraccion: string; nico?: string }): Promise> { - const queryParams = new URLSearchParams(filters); - return await api.get(`/v1/sitar/prosec/?${queryParams.toString()}`); + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/prosec/?${queryParams.toString()}`); } export async function getSitarALADI(filters: { fraccion: string; nico?: string }): Promise> { - const queryParams = new URLSearchParams(filters); - return await api.get(`/v1/sitar/aladi2/?${queryParams.toString()}`); + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/aladi2/?${queryParams.toString()}`); +} + +export type SitarGenericRecord = Record; + +export type SitarDatasetEndpoint = + | 'reit' + | 'requisito-previo' + | 'informacion-general' + | 'regulaciones' + | 'fundamentos-tlc' + | 'cuotas2' + | 'cupos' + | 'noms' + | 'precios-estimados' + | 'ieps' + | 'rcg2' + | 'vehiculos-marcas' + | 'vehiculos-modelos'; + +export async function getSitarDataset( + endpoint: SitarDatasetEndpoint, + filters: { fraccion: string; nico?: string; [key: string]: string | undefined } +): Promise> { + const queryParams = new URLSearchParams( + Object.entries(filters).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + acc[key] = String(value); + } + return acc; + }, + {} as Record + ) + ); + return await api.get(`/v1/sitar/${endpoint}/?${queryParams.toString()}`); } 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 dc78cf10..49305d14 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -1041,8 +1041,8 @@ - - + + @@ -1054,8 +1054,8 @@ class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700" onclick={() => selectUSFraction(fraction)} > - + diff --git a/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte b/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte new file mode 100644 index 00000000..af6fc266 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte @@ -0,0 +1,408 @@ + + +
+
+
+

+ Información Arancelaria (Solo Consulta) +

+
+
+
CódigoPrefijoClaveFracción Ad valorem Costo Fijo Descripción{fraction.fraction || fraction.code} {fraction.code || '—'}{fraction.fraction || '—'} {fraction.adv_impo ?? '—'} {fraction.adv_expo ?? '—'} {fraction.description || ''}
+ + + + + + + + + + + + + + + + + + +
FracciónUMTUMAdvalorem ImpoAdvalorem Expo
+ {headerFraction || '-'} + {selectedFraction?.umt || '-'}{selectedFraction?.um_code || '-'}{selectedFraction?.adv_impo || '-'}{selectedFraction?.adv_expo || '-'}
+ + + + {#if !hasSelection} +
+ Selecciona una fracción de la tabla para ver su detalle SITAR (Descripción, TLCS, PROSEC, ALADI). +
+ {:else} +
+ (activeTab = v as TabKey)} class="h-full w-full flex flex-col"> + + Descripción + TLCS + PROSEC + ALADI + IMMEX + ACUERDOS + + + {#if activeError} +
{activeError}
+ {/if} + + +
+

+ Descripción de la Fracción +

+
+ {selectedFraction?.description || 'No hay descripción disponible para esta fracción.'} +
+
+
+ + +
+
+

+ Información TLCS +

+
+
+ + + + + + + + + + + {#each sitarTLCSData as item} + + + + + + + {:else} + + {/each} + +
PaísTasaD.O.FNotas
{item.PAIS}{item.TASATXT}{item.DOF || '-'}{item.NOTA || '-'}
No hay información de TLCS disponible para esta fracción.
+
+
+
+ + +
+
+

+ Programa PROSEC +

+
+
+ + + + + + + + + + + {#each sitarPROSECData as item} + + + + + + + {:else} + + {/each} + +
ArtículoSectorTasa TxtD.O.F
{item.PRODUCTO}{item.SECTOR}{item.TASA}{item.DOF || '-'}
No hay información de PROSEC disponible para esta fracción.
+
+
+
+ + +
+
+

+ Acuerdo ALADI +

+
+
+ + + + + + + + + + + {#each sitarALADIData as item} + + + + + + + {:else} + + {/each} + +
AcuerdoPaísTasaD.O.F
{item.ACUERDO}{item.PAIS}{item.TASATXT}{item.DOF || '-'}
No hay información de ALADI disponible para esta fracción.
+
+
+
+ + +
+
+

+ IMMEX / REIT +

+
+
+ + + + + + + + + + + + {#each sitarIMMEXData as item} + + + + + + + + {:else} + + + + {/each} + +
ArtículoFundamentoAcuerdoPermisoD.O.F
{item.ARTICULO || ''}{item.FUNDAMENTO || ''}{item.ACUERDO || ''}{item.PERMISO || '-'}{item.DOF || '-'}
+ No hay información de IMMEX disponible para esta fracción. +
+
+
+
+ + +
+
+

+ Acuerdos / Requisitos previos +

+
+
+ + + + + + + + + + + {#each sitarAcuerdosData as item} + + + + + + + {:else} + + + + {/each} + +
DescripciónPermisoD.O.FVigencia
{item.DESCRIPCION || ''}{item.PERMISO || '-'}{item.DOF || '-'}{item.VIGENCIA || '-'}
+ No hay información de ACUERDOS disponible para esta fracción. +
+
+
+
+
+
+ {/if} + diff --git a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte index d4577e32..478f431d 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte @@ -20,22 +20,29 @@ import { untrack } from 'svelte'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import TariffFractionFormDialog from './TariffFractionFormDialog.svelte'; + import SitarFractionTabs from './SitarFractionTabs.svelte'; import { toast } from 'svelte-sonner'; import { currentUser, userHasPermission } from '$lib/auth'; import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; + import { + getTariffFractionDisplayFraction, + getTariffFractionDisplayKey + } from '$lib/utils/tariff-fraction-display'; let { title = 'Fracciones Arancelarias', catalog = 'mex', // 'mex' or 'usa' levelFilter = null, // null or number readOnly = false, - basePerm: customBasePerm = null + basePerm: customBasePerm = null, + showSitarTabsOnSelect = false }: { title?: string; catalog?: string; levelFilter?: number | null; readOnly?: boolean; basePerm?: string | null; + showSitarTabsOnSelect?: boolean; } = $props(); let fractions = $state([]); @@ -74,6 +81,7 @@ let isFormDialogOpen = $state(false); let selectedFraction = $state(null); + let selectedDetailFraction = $state(null); let isManageMode = $state(false); // If true, opens form in edit mode // Delete confirmation @@ -110,6 +118,11 @@ } else { fractions = [...fractions, ...newItems]; } + if (selectedDetailFraction) { + selectedDetailFraction = + [...fractions, ...newItems].find((item) => item.id === selectedDetailFraction?.id) || + selectedDetailFraction; + } totalFractions = payload.total || 0; // Safer end-of-data detection @@ -171,6 +184,11 @@ isFormDialogOpen = true; } + function selectFractionDetail(fraction: TariffFraction) { + if (!showSitarTabsOnSelect || catalog !== 'mex') return; + selectedDetailFraction = fraction; + } + function confirmDelete(fraction: TariffFraction) { fractionToDelete = fraction; showDeleteConfirm = true; @@ -244,7 +262,8 @@ {/if} - +
+
@@ -289,9 +308,12 @@ {:else} {#each fractions as fraction (fraction.id)} - - {fraction.um_code || fraction.code} - {fraction.fraction} + selectFractionDetail(fraction)} + > + {getTariffFractionDisplayKey(fraction)} + {getTariffFractionDisplayFraction(fraction)} {fraction.description} @@ -346,6 +368,12 @@
+ {#if showSitarTabsOnSelect && catalog === 'mex'} +
+ +
+ {/if} +
Mostrando {fractions.length} de {totalFractions} registros
{/if} diff --git a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte index b0461aa5..e23f5e9a 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte @@ -13,6 +13,7 @@ buildMexTariffDigitsFromCatalogRow, formatMexTariffDigitsForDisplay } from '$lib/utils/mexican-tariff-fraction'; + import { getTariffFractionDisplayKey } from '$lib/utils/tariff-fraction-display'; import { m } from '$lib/i18n/messages'; let { @@ -139,7 +140,7 @@ open = false; }} > - {fraction.um_code} + {getTariffFractionDisplayKey(fraction)} {formatMexTariffDigitsForDisplay( buildMexTariffDigitsFromCatalogRow(fraction) diff --git a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte index 1cf8e8f8..206ab402 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte @@ -9,6 +9,7 @@ getTariffFractions, type TariffFraction } from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions"; + import { getTariffFractionDisplayKey } from '$lib/utils/tariff-fraction-display'; import { companyStore } from "$lib/stores/company.svelte"; import { m } from '$lib/i18n/messages'; @@ -155,7 +156,7 @@
- {item.fraction || item.code} + {getTariffFractionDisplayKey(item)}
diff --git a/frontend/src/lib/utils/tariff-fraction-display.test.ts b/frontend/src/lib/utils/tariff-fraction-display.test.ts new file mode 100644 index 00000000..26f0d7bc --- /dev/null +++ b/frontend/src/lib/utils/tariff-fraction-display.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + getTariffFractionDisplayFraction, + getTariffFractionDisplayKey +} from './tariff-fraction-display'; +import type { TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions'; + +function buildFraction(partial: Partial): TariffFraction { + return { + id: 1, + code: '', + fraction: '', + description: null, + nico: null, + umt: null, + adv_impo: null, + adv_expo: null, + updated_at: null, + dof: null, + aplica_ieps: null, + um_code: null, + ...partial + }; +} + +describe('tariff-fraction-display', () => { + it('uses technical code for key column', () => { + const row = buildFraction({ + code: '01012101', + fraction: '0101.21.01', + um_code: '06' + }); + expect(getTariffFractionDisplayKey(row)).toBe('01012101'); + }); + + it('uses formatted fraction for fraction column', () => { + const row = buildFraction({ + code: '1234567890', + fraction: '1234.56.78.90' + }); + expect(getTariffFractionDisplayFraction(row)).toBe('1234.56.78.90'); + }); +}); diff --git a/frontend/src/lib/utils/tariff-fraction-display.ts b/frontend/src/lib/utils/tariff-fraction-display.ts new file mode 100644 index 00000000..bd2f73c5 --- /dev/null +++ b/frontend/src/lib/utils/tariff-fraction-display.ts @@ -0,0 +1,9 @@ +import type { TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions'; + +export function getTariffFractionDisplayKey(fraction: TariffFraction): string { + return fraction.code || '-'; +} + +export function getTariffFractionDisplayFraction(fraction: TariffFraction): string { + return fraction.fraction || '-'; +} diff --git a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/sitar/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/sitar/+page.svelte index 52e9771c..ce52605a 100644 --- a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/sitar/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/sitar/+page.svelte @@ -6,6 +6,7 @@ From 2715d9d22fe8738243f8fdb6fb4a8639dac62f7b Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 28 Apr 2026 11:01:18 -0600 Subject: [PATCH 03/20] feature/us-y-7-mejora-tabla-lateral --- .../goods/fractions/SitarFractionTabs.svelte | 48 ++++++++++++++----- .../goods/fractions/TariffFractionList.svelte | 6 +-- .../seventh-amendment/+page.svelte | 1 + .../tariff-fractions/us/+page.svelte | 2 +- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte b/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte index af6fc266..ef9b8327 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/SitarFractionTabs.svelte @@ -21,7 +21,10 @@ type TabKey = 'descripcion' | 'tlcs' | 'prosec' | 'aladi' | 'immex' | 'acuerdos'; type NonDescTabKey = Exclude; - let { selectedFraction }: { selectedFraction: TariffFraction | null } = $props(); + let { selectedFraction, catalog = 'mex' }: { + selectedFraction: TariffFraction | null; + catalog?: string; + } = $props(); let activeTab = $state('descripcion'); let isResolvingTabs = $state(false); @@ -53,14 +56,18 @@ let resolveToken = 0; const fractionDigits = $derived( - selectedFraction ? splitMexTariffDigitsForSitar(buildMexTariffDigitsFromCatalogRow(selectedFraction)) : null + catalog === 'mex' && selectedFraction + ? splitMexTariffDigitsForSitar(buildMexTariffDigitsFromCatalogRow(selectedFraction)) + : null ); const hasSelection = $derived(!!selectedFraction); const headerFraction = $derived( - selectedFraction && fractionDigits - ? formatMexTariffDigitsForDisplay(buildMexTariffDigitsFromCatalogRow(selectedFraction)) + selectedFraction + ? catalog === 'mex' && fractionDigits + ? formatMexTariffDigitsForDisplay(buildMexTariffDigitsFromCatalogRow(selectedFraction)) + : selectedFraction.fraction || selectedFraction.code || '' : '' ); @@ -140,6 +147,12 @@ sitarAcuerdosData = []; activeError = ''; + if (catalog !== 'mex') { + isResolvingTabs = false; + noDataTabs = { descripcion: false, tlcs: true, prosec: true, aladi: true, immex: true, acuerdos: true }; + return; + } + if (!frac || !fracDigits) { isResolvingTabs = false; noDataTabs = { descripcion: false, tlcs: true, prosec: true, aladi: true, immex: true, acuerdos: true }; @@ -192,13 +205,26 @@
- {#if !hasSelection} -
- Selecciona una fracción de la tabla para ver su detalle SITAR (Descripción, TLCS, PROSEC, ALADI). -
- {:else} -
- (activeTab = v as TabKey)} class="h-full w-full flex flex-col"> + {#if !hasSelection} +
+ {catalog === 'mex' + ? 'Selecciona una fracción de la tabla para ver su detalle SITAR (Descripción, TLCS, PROSEC, ALADI).' + : 'Selecciona una fracción de la tabla para ver su descripción.'} +
+ {:else if catalog !== 'mex'} +
+
+

+ Descripción de la Fracción +

+
+
+ {selectedFraction?.description || 'No hay descripción disponible para esta fracción.'} +
+
+ {:else} +
+ (activeTab = v as TabKey)} class="h-full w-full flex flex-col"> Descripción TLCS diff --git a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte index 478f431d..cfb49d74 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte @@ -185,7 +185,7 @@ } function selectFractionDetail(fraction: TariffFraction) { - if (!showSitarTabsOnSelect || catalog !== 'mex') return; + if (!showSitarTabsOnSelect) return; selectedDetailFraction = fraction; } @@ -368,9 +368,9 @@
- {#if showSitarTabsOnSelect && catalog === 'mex'} + {#if showSitarTabsOnSelect}
- +
{/if}
diff --git a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte index 2dabe899..4c6f61af 100644 --- a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/seventh-amendment/+page.svelte @@ -9,4 +9,5 @@ levelFilter={5} readOnly={true} basePerm="frac_sitar_7" + showSitarTabsOnSelect={true} /> diff --git a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/us/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/us/+page.svelte index 32ffe06e..c8eead28 100644 --- a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/us/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/us/+page.svelte @@ -3,4 +3,4 @@ import { m } from '$lib/i18n/messages'; - + From 463b1a1925e677452de2db1c581a6a77af20121e Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 28 Apr 2026 11:23:04 -0600 Subject: [PATCH 04/20] feature/scaf-partes-default --- .../dashboard/goods/parts/partForm.svelte | 33 ---------------- .../routes/dashboard/goods/parts/+page.svelte | 39 +------------------ 2 files changed, 2 insertions(+), 70 deletions(-) diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index ec05e492..3c55e2c0 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -1155,39 +1155,6 @@ {isEdit ? 'Editar' : 'Nueva'} -
- - - -
- diff --git a/frontend/src/routes/dashboard/goods/parts/+page.svelte b/frontend/src/routes/dashboard/goods/parts/+page.svelte index 79d10322..075d76f1 100644 --- a/frontend/src/routes/dashboard/goods/parts/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/+page.svelte @@ -25,7 +25,6 @@ let searchDescription = $state(''); let searchClient = $state(''); let searchClass = $state(''); - let systemFilter = $state<'ALL' | 'SCAI' | 'SCAF'>('ALL'); let sorting = $state([ { id: 'updated_at', desc: true } ]); @@ -60,12 +59,7 @@ clientName.toLowerCase().includes(searchClient.toLowerCase()); const matchesClass = !searchClass || (p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false); - let matchesSystem = true; - if (systemFilter === 'SCAI') matchesSystem = !!p.inv_data; - else if (systemFilter === 'SCAF') matchesSystem = !!p.fa_data; - return ( - matchesPartNumber && matchesDescription && matchesClient && matchesClass && matchesSystem - ); + return matchesPartNumber && matchesDescription && matchesClient && matchesClass; }) ); @@ -239,7 +233,7 @@ Filtra las partes por diferentes criterios (los filtros se aplican automáticamente) -
+
Clase
-
- -
- - - -
-
From 52ad58acebdd30634555c8e0c9c31f1d8310eeeb Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 28 Apr 2026 12:01:50 -0600 Subject: [PATCH 05/20] feature/cliente-asignado-partes --- .../b2c3d4e5f6a7_drop_client_id_from_parts.py | 28 +++++ .../v1/modules/a76/layouts_csv/parts/tasks.py | 1 - backend/api/v1/modules/a76/parts/dto.py | 2 - backend/api/v1/modules/a76/parts/models.py | 1 - frontend/src/lib/api/dashboard/a76/parts.ts | 1 - .../dashboard/goods/parts/partForm.svelte | 108 +----------------- .../routes/dashboard/goods/parts/+page.svelte | 50 +------- 7 files changed, 34 insertions(+), 157 deletions(-) create mode 100644 backend/alembic/versions/b2c3d4e5f6a7_drop_client_id_from_parts.py diff --git a/backend/alembic/versions/b2c3d4e5f6a7_drop_client_id_from_parts.py b/backend/alembic/versions/b2c3d4e5f6a7_drop_client_id_from_parts.py new file mode 100644 index 00000000..172e2e19 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_drop_client_id_from_parts.py @@ -0,0 +1,28 @@ +"""drop client_id column from parts + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-28 11:50:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("parts", "client_id", schema="a76") + + +def downgrade() -> None: + op.add_column( + "parts", + sa.Column("client_id", sa.Integer(), nullable=True), + schema="a76", + ) diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py index 6c4cf830..1589e61c 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py @@ -325,7 +325,6 @@ def insert_valid_rows(self, job_id: str): new_part = Part( tenant_id=tenant_id, company_id=company_id, - client_id=company_id, **data, ) session.add(new_part) diff --git a/backend/api/v1/modules/a76/parts/dto.py b/backend/api/v1/modules/a76/parts/dto.py index a6815a01..9320e585 100644 --- a/backend/api/v1/modules/a76/parts/dto.py +++ b/backend/api/v1/modules/a76/parts/dto.py @@ -145,7 +145,6 @@ class InvDataDTO(BaseModel): class PartBase(BaseModel): - client_id: Optional[int] = None part_number: str = Field(..., max_length=70) commercial_part_number: Optional[str] = None @@ -186,7 +185,6 @@ class PartCreateDTO(PartBase): # --- ACTUALIZACIÓN --- class PartUpdateDTO(PartBase): - client_id: Optional[int] = None part_number: Optional[str] = None # Todo opcional para PATCH pass diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py index 27ba357f..0f1e8c03 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -68,7 +68,6 @@ class Part(Base, TenantScopedMixin, TimestampMixin): ) id: Mapped[int] = mapped_column(Integer, primary_key=True) - client_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) part_number: Mapped[str] = mapped_column(String(70)) commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70)) diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index a0ef2c4f..5c4ab8a6 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -92,7 +92,6 @@ export interface Part { id: number; tenant_id: number; company_id: number; - client_id: number; // Identificación part_number: string; diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index 3c55e2c0..5cb464ad 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -26,7 +26,6 @@ Settings, Image as ImageIcon, FolderSearch, - UserCheck, CheckCircle2, XCircle, Tag, @@ -53,7 +52,6 @@ // Stores & APIs import { companyStore } from '$lib/stores/company.svelte'; import { partsApi } from '$lib/api/dashboard/a76/parts'; - import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { classesApi } from '$lib/api/dashboard/a76/classes'; import { materialTypesApi } from '$lib/api/dashboard/a76/material-types'; import { countriesApi } from '$lib/api/dashboard/reference_data/countries'; @@ -187,8 +185,6 @@ let showNonDischargeDialog = $state(false); // Descripciones Visuales - let selectedClientName = $state(''); - let selectedClientStatus = $state(true); let selectedClassDesc = $state(''); let selectedCurrencyName = $state(''); let selectedCountryName = $state(''); @@ -406,7 +402,6 @@ // Estado Formulario let formData = $state({ id: 0, - client_id: 0, part_number: '', assigned_client: '', description_spanish: '', @@ -525,7 +520,6 @@ const d = response.data; formData = { id: d.id, - client_id: d.client_id, part_number: d.part_number, description_spanish: d.description_spanish || '', description_english: d.description_english || '', @@ -648,7 +642,6 @@ if (d.currency_key === 'MXN') formData.currency_type = 'NA'; else formData.currency_type = 'EX'; - if (d.client_id) await fetchClientName(d.client_id, companyId); if (d.part_class) await fetchClassDesc(d.part_class, companyId); if (d.inv_data?.material_type) await fetchMaterialName(d.inv_data.material_type); await fetchSectorDesc(formData.sector); @@ -674,19 +667,6 @@ }); // --- HELPERS VISUALES --- - async function fetchClientName(clientId: number, companyId: number) { - try { - const res = await clientsProvidersApi.get(clientId, companyId); - const clientData = (res as any).data || res; - if (clientData) { - selectedClientName = clientData.name; - selectedClientStatus = clientData.is_active ?? true; - } - } catch (e) { - console.error('Error visual cliente', e); - } - } - async function fetchClassDesc(code: string, companyId: number) { try { const res = await classesApi.list({ company_id: companyId, class_code: code }); @@ -745,20 +725,6 @@ if (modalContext === 'non_discharge') { tempNonDischargeItem.client_id = client.id; tempNonDischargeItem.name = client.name; - } else { - formData.client_id = client.id; - selectedClientName = client.name; - selectedClientStatus = client.is_active ?? true; - - // Cambio reactivo: Si estamos en SCAI y seleccionamos un cliente, - // asumimos que el usuario quiere convertirlo a SCAF (Activo Fijo). - if (formType === 'inv') { - formType = 'fa'; - toast.info('Cambio de sistema detectado', { - description: - 'Se ha cambiado automáticamente a SCAF (Activo Fijo) al seleccionar un cliente.' - }); - } } modalContext = 'main'; } @@ -937,10 +903,6 @@ error = 'No hay una compañía activa seleccionada'; return; } - if (!formData.client_id && formType !== 'inv') { - error = 'Debe seleccionar un Cliente'; - return; - } if (!formData.unit_of_measure) { error = 'Debe seleccionar una Unidad de Medida'; return; @@ -973,9 +935,6 @@ commonData.scrap_export_fraction = normalizeMexTariffDigitsStored( commonData.scrap_export_fraction ); - if (commonData.client_id === 0) { - commonData.client_id = null; - } if (!commonData.currency_key || commonData.currency_key === 'USD') { commonData.currency_key = null; } @@ -1186,7 +1145,7 @@ class="animate-in fade-in space-y-8 pt-6 duration-300" >
-
+
@@ -1202,36 +1161,6 @@ />
-
- -
-
- - (showClientModal = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showClientModal = true))} - tabindex="0" - class="cursor-pointer pl-9 transition-colors hover:bg-muted/50" - placeholder="Seleccione un cliente..." - /> -
- -
-
@@ -1666,7 +1595,7 @@ >
-
+
-
- -
-
- - (showClientModal = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showClientModal = true))} - tabindex="0" - class="cursor-pointer pl-9 transition-colors hover:bg-muted/50" - placeholder="Asignar cliente..." - /> -
- -
-

- * Al asignar un cliente, se cambiará automáticamente a modo SCAF. -

-
diff --git a/frontend/src/routes/dashboard/goods/parts/+page.svelte b/frontend/src/routes/dashboard/goods/parts/+page.svelte index 075d76f1..01066b15 100644 --- a/frontend/src/routes/dashboard/goods/parts/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/+page.svelte @@ -5,7 +5,6 @@ import { Plus, RefreshCw, Package } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { partsApi, type Part } from '$lib/api/dashboard/a76/parts'; - import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { goto } from '$app/navigation'; @@ -18,12 +17,10 @@ // Estado de la lista de partes let parts = $state([]); - let clientsMap = $state>({}); // Mapa ID -> Nombre let selectedPart = $state(null); let isLoading = $state(false); let searchPartNumber = $state(''); let searchDescription = $state(''); - let searchClient = $state(''); let searchClass = $state(''); let sorting = $state([ { id: 'updated_at', desc: true } @@ -39,7 +36,6 @@ const canCreate = $derived(userHasPermission($currentUser, 'goods_parts.create')); const canEdit = $derived(userHasPermission($currentUser, 'goods_parts.edit')); const canDelete = $derived(userHasPermission($currentUser, 'goods_parts.delete')); - const canViewClients = $derived(userHasPermission($currentUser, 'clients_providers.view')); const isError = $derived(!canView || status >= 400 || error); @@ -52,14 +48,9 @@ !searchDescription || (p.description_spanish?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) || (p.description_english?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false); - const clientName = clientsMap[p.client_id] || ''; - const matchesClient = - !searchClient || - (p.client_id?.toString().includes(searchClient) ?? false) || - clientName.toLowerCase().includes(searchClient.toLowerCase()); const matchesClass = !searchClass || (p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false); - return matchesPartNumber && matchesDescription && matchesClient && matchesClass; + return matchesPartNumber && matchesDescription && matchesClass; }) ); @@ -94,27 +85,7 @@ async function loadData() { if (!canView) return; - await Promise.all([loadParts(), loadClients()]); - } - - async function loadClients() { - if (!canViewClients) return; - const companyId = companyStore.activeCompany?.id; - if (!companyId) return; - - try { - const response = await clientsProvidersApi.list(companyId, 1, 1000); - const data = (response as any).data || response; - const items = data.items || []; - - const map: Record = {}; - items.forEach((c: any) => { - map[c.id] = c.name; - }); - clientsMap = map; - } catch (e) { - console.error('Error cargando clientes:', e); - } + await loadParts(); } async function loadParts() { @@ -233,7 +204,7 @@ Filtra las partes por diferentes criterios (los filtros se aplican automáticamente)
-
+
-
- - -
@@ -343,16 +310,7 @@
-
-
- -
- - {clientsMap[selectedPart.client_id] || 'Sin cliente'} -
-
+
{selectedPart.part_class || '-'} From daf7758b9fe62bbf6a3e3eb60e1d68316f862566 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 28 Apr 2026 12:15:26 -0600 Subject: [PATCH 06/20] feature/quitar-label-tc-pedimentos --- .../pedimentos/edit/general-tab-form.svelte | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte index 49061420..a176f970 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte @@ -621,23 +621,25 @@
- -

- Tipo de fecha para TC: FECHA {getEffectiveDateLabel().toUpperCase()} - {#if formData.pedimento_type === 'consolidated' && getEffectiveExchangeDate() > getCurrentLocalDate()} - - (Opcional por fecha futura en consolidado) - - {/if} -

+
+ +

+ FECHA {getEffectiveDateLabel().toUpperCase()} +

+
+ {#if formData.pedimento_type === 'consolidated' && getEffectiveExchangeDate() > getCurrentLocalDate()} +

+ (Opcional por fecha futura en consolidado) +

+ {/if}
From 07ba33a4d9f05fd8b182923dcacccc13f665e71f Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 28 Apr 2026 14:53:08 -0600 Subject: [PATCH 07/20] feature/doda-endpoints-faltantes --- ...3d4e5f6a7b8_add_action_to_doda_alta_log.py | 29 ++ .../a76/general_catalogs/doda/alta_log_dto.py | 2 + .../general_catalogs/doda/alta_log_models.py | 1 + .../general_catalogs/doda/alta_log_service.py | 2 + .../a76/general_catalogs/doda/alta_service.py | 92 ++++++ .../general_catalogs/doda/external_service.py | 44 +++ .../a76/general_catalogs/doda/routes.py | 305 ++++++++++++++++++ .../a76/general_catalogs/doda/service.py | 30 +- .../doda/test_doda_alta_payloads.py | 69 ++++ .../doda/test_doda_external_service.py | 65 ++++ .../dashboard/a76/general_catalogs/doda.ts | 50 +++ .../despacho/doda/doda-progress-dialog.svelte | 13 +- .../dashboard/despacho/doda/+page.svelte | 138 +++++++- 13 files changed, 828 insertions(+), 12 deletions(-) create mode 100644 backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py create mode 100644 backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py create mode 100644 backend/tests/unit/general_catalogs/doda/test_doda_external_service.py diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py b/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py new file mode 100644 index 00000000..e28c1542 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py @@ -0,0 +1,29 @@ +"""add action column to doda_alta_log + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-04-28 13:20:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c3d4e5f6a7b8" +down_revision = "b2c3d4e5f6a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "doda_alta_log", + sa.Column("action", sa.String(length=20), nullable=True), + schema="a76", + ) + + +def downgrade() -> None: + op.drop_column("doda_alta_log", "action", schema="a76") diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py index 72ef6aab..3ad314d6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field class DodaAltaLogCreateDTO(BaseModel): doda_id: Optional[int] = None variant: Optional[str] = Field(None, max_length=10) + action: Optional[str] = Field(None, max_length=20) responsible: Optional[str] = Field(None, max_length=20) patent: Optional[str] = Field(None, max_length=10) dispatch_customs: Optional[str] = Field(None, max_length=10) @@ -29,6 +30,7 @@ class DodaAltaLogResponseDTO(BaseModel): id: int doda_id: Optional[int] = None variant: Optional[str] = None + action: Optional[str] = None responsible: Optional[str] = None patent: Optional[str] = None dispatch_customs: Optional[str] = None diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py index 581c5f55..085213ed 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py @@ -23,6 +23,7 @@ class DodaAltaLog(Base, TenantScopedMixin, TimestampMixin): # Tipo de alta (doda / pita) variant: Mapped[str | None] = mapped_column(String(10), nullable=True) + action: Mapped[str | None] = mapped_column(String(20), nullable=True) # Datos copiados del DODA al momento del envío (para historial) responsible: Mapped[str | None] = mapped_column(String(20), nullable=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py index f4b83615..ef325ab8 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py @@ -115,6 +115,7 @@ class DodaAltaLogService: tenant_id: int, variant: str, ext_result: dict, + action: str = "alta", ) -> DodaAltaLog: """ Crea un registro de log a partir de la respuesta del servicio externo de alta. @@ -127,6 +128,7 @@ class DodaAltaLogService: dto = DodaAltaLogCreateDTO( doda_id=doda.id, variant=variant, + action=action, responsible=doda.responsible, patent=doda.patent, dispatch_customs=doda.dispatch_customs, diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py index ffca5a57..2a539bdb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import json import logging from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -16,6 +17,7 @@ from core.storage_s3 import get_object_bytes, object_exists from api.v1.modules.a76.customs_brokers import models as cb_models from .models import Doda, DodaContainer, DodaAmericanPedimento, DodaPedimento +from .alta_log_models import DodaAltaLog from .payload_normalizer import ( normalize_aduana_despacho, normalize_aduana_seccion, @@ -487,6 +489,40 @@ class DodaAltaService: # Build full payload # ------------------------------------------------------------------ + def _latest_alta_log( + self, + doda_id: int, + tenant_id: int, + company_id: int, + variant: str, + ) -> Optional[DodaAltaLog]: + return ( + self.db.query(DodaAltaLog) + .filter( + DodaAltaLog.doda_id == doda_id, + DodaAltaLog.tenant_id == tenant_id, + DodaAltaLog.company_id == company_id, + DodaAltaLog.variant == variant, + DodaAltaLog.deleted_at.is_(None), + ) + .order_by(DodaAltaLog.id.desc()) + .first() + ) + + @staticmethod + def _extract_numero_transaccion(log_record: DodaAltaLog) -> str: + raw_json = (log_record.result_json or "").strip() + if raw_json: + try: + parsed = json.loads(raw_json) + for key in ("numero_transaccion", "transaction_number"): + value = parsed.get(key) + if value: + return str(value).strip() + except Exception: + logger.warning("No se pudo parsear result_json de DodaAltaLog id=%s", log_record.id) + return "" + def build_alta_payload( self, doda_id: int, @@ -549,3 +585,59 @@ class DodaAltaService: } return payload + + def build_consulta_payload( + self, + doda_id: int, + tenant_id: int, + company_id: int, + variant: str = "doda", + user_email: str = "", + ) -> Dict[str, Any]: + payload = self.build_alta_payload( + doda_id=doda_id, + tenant_id=tenant_id, + company_id=company_id, + variant=variant, + user_email=user_email, + ) + latest_log = self._latest_alta_log(doda_id, tenant_id, company_id, variant) + if not latest_log: + raise ValueError( + "No existe un alta DODA previa para construir la consulta (falta task/log)." + ) + numero_transaccion = self._extract_numero_transaccion(latest_log) + if not numero_transaccion: + raise ValueError( + "No se encontro numero_transaccion en el ultimo resultado de alta DODA." + ) + payload["numero_transaccion"] = numero_transaccion + return payload + + def build_eliminar_payload( + self, + doda_id: int, + tenant_id: int, + company_id: int, + variant: str = "doda", + user_email: str = "", + ) -> Dict[str, Any]: + payload = self.build_alta_payload( + doda_id=doda_id, + tenant_id=tenant_id, + company_id=company_id, + variant=variant, + user_email=user_email, + ) + latest_log = self._latest_alta_log(doda_id, tenant_id, company_id, variant) + if not latest_log: + raise ValueError( + "No existe un alta DODA previa para construir la eliminacion." + ) + numero_integracion = (latest_log.integration_number or "").strip() + if not numero_integracion: + raise ValueError( + "No se encontro numero_integracion en el historial de alta DODA." + ) + payload["numero_integracion"] = numero_integracion + return payload diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py index a972bb66..85970890 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py @@ -17,6 +17,10 @@ class DodaExternalService: Endpoints: POST {base_url}/api/v1/doda/alta GET {base_url}/api/v1/doda/alta-status/{task_id} + POST {base_url}/api/v1/doda/consulta + GET {base_url}/api/v1/doda/consulta-status/{task_id} + POST {base_url}/api/v1/doda/eliminar + GET {base_url}/api/v1/doda/eliminar-status/{task_id} Usa COVE_API_URL como URL base (la misma variable que COVE y Expediente). """ @@ -61,3 +65,43 @@ class DodaExternalService: response = client.get(url) response.raise_for_status() return response.json() + + def post_consulta(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Envia consulta DODA y retorna {task_id, status, message}.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/consulta" + with httpx.Client( + timeout=httpx.Timeout(60.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.post(url, json=payload) + response.raise_for_status() + return response.json() + + def get_consulta_status(self, task_id: str) -> Dict[str, Any]: + """Consulta el estado de una tarea de consulta DODA.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/consulta-status/{task_id}" + with httpx.Client( + timeout=httpx.Timeout(30.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.get(url) + response.raise_for_status() + return response.json() + + def post_eliminar(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Envia eliminacion DODA y retorna {task_id, status, message}.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/eliminar" + with httpx.Client( + timeout=httpx.Timeout(60.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.post(url, json=payload) + response.raise_for_status() + return response.json() + + def get_eliminar_status(self, task_id: str) -> Dict[str, Any]: + """Consulta el estado de una tarea de eliminacion DODA.""" + url = f"{self.base_url.rstrip('/')}/api/v1/doda/eliminar-status/{task_id}" + with httpx.Client( + timeout=httpx.Timeout(30.0, connect=10.0), verify=self.verify_ssl + ) as client: + response = client.get(url) + response.raise_for_status() + return response.json() diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py index 7ed58c89..3537c2ba 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py @@ -60,6 +60,47 @@ from core.security import get_current_user, validate_access_to_resource logger = logging.getLogger(__name__) + +def _coalesce_external_result_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + result = payload.get("result") + if isinstance(result, dict): + return result + return payload + + +def _apply_consulta_success_to_doda( + doda: Doda, + payload: Dict[str, Any], +) -> None: + source = _coalesce_external_result_payload(payload) + mapping = { + "integration_number": "integration_number", + "numero_integracion": "integration_number", + "transaction_number": "transaction_number", + "numero_transaccion": "transaction_number", + "sat_digital_seal": "sat_digital_seal", + "sello_digital_sat": "sat_digital_seal", + "sat_certificate": "sat_certificate", + "certificado_sat": "sat_certificate", + "serial_number": "serial_number", + "numero_serie": "serial_number", + "electronic_signature": "electronic_signature", + "firma_electronica": "electronic_signature", + "original_chain": "original_chain", + "cadena_original": "original_chain", + "sat_original_chain": "sat_original_chain", + "cadena_original_sat": "sat_original_chain", + "linq_sat_qr": "linq_sat_qr", + "link_sat_qr": "linq_sat_qr", + "xml_doda_sent_path": "xml_doda_sent_path", + "xml_doda_response_path": "xml_doda_response_path", + "status": "status", + } + for src_key, dst_attr in mapping.items(): + value = source.get(src_key) + if value is not None and value != "": + setattr(doda, dst_attr, str(value)) + # Router independiente para rutas literales (deben registrarse antes que /{id}) router = APIRouter(prefix="/doda", tags=["doda"]) @@ -633,6 +674,7 @@ async def post_doda_alta( tenant_id=int(tenant_id), variant=variant, ext_result=result, + action="alta", ) except Exception: logger.exception("Error persistiendo DodaAltaLog para doda_id=%s", doda_id) @@ -668,6 +710,269 @@ async def get_doda_alta_status( ) from exc +@router.post( + "/{doda_id}/consulta", + summary="Enviar Consulta DODA al servicio externo (asíncrono)", + tags=["doda-alta"], +) +async def post_doda_consulta( + doda_id: int, + company_id: int = Query(..., description="Company ID"), + variant: str = Query("doda", description="Tipo de alta: 'doda' o 'pita'"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +) -> Dict[str, Any]: + tenant_id = validate_access_to_resource(db, company_id, current_user) + user_email = ( + current_user.get("email") + or current_user.get("preferred_username") + or "" + ) + service = DodaAltaService(db) + try: + payload = service.build_consulta_payload( + doda_id=doda_id, + tenant_id=int(tenant_id), + company_id=company_id, + variant=variant, + user_email=user_email, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + ext_result: Dict[str, Any] + try: + ext = DodaExternalService() + ext_result = ext.post_consulta(payload) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error al enviar consulta DODA al servicio externo: doda_id=%s", doda_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al contactar el servicio DODA externo (consulta): {exc}", + ) from exc + + doda_record = DodaService.get_by_id(db, doda_id, int(tenant_id), company_id) + if doda_record: + try: + DodaAltaLogService.create_from_alta_result( + db=db, + doda=doda_record, + company_id=company_id, + tenant_id=int(tenant_id), + variant=variant, + ext_result=ext_result, + action="consulta", + ) + except Exception: + logger.exception("Error persistiendo DodaAltaLog(consulta) para doda_id=%s", doda_id) + return ext_result + + +@router.get( + "/consulta-status/{task_id}", + summary="Consultar estado de tarea de Consulta DODA", + tags=["doda-alta"], +) +async def get_doda_consulta_status( + task_id: str, + current_user: dict = Depends(get_current_user), +) -> Any: + try: + ext = DodaExternalService() + return ext.get_consulta_status(task_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error consultando consulta-status DODA task_id=%s", task_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al consultar el estado de la consulta DODA: {exc}", + ) from exc + + +@router.post( + "/{doda_id}/consulta-apply/{task_id}", + summary="Aplicar resultado exitoso de consulta DODA al registro local", + tags=["doda-alta"], +) +async def post_doda_consulta_apply( + doda_id: int, + task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +) -> Dict[str, Any]: + tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + doda_record = DodaService.get_by_id(db, doda_id, tenant_id, company_id) + if not doda_record: + raise HTTPException(status_code=404, detail="DODA no encontrado.") + + try: + ext = DodaExternalService() + status_payload = ext.get_consulta_status(task_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error al consultar consulta-status para aplicar: task_id=%s", task_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al consultar el estado de la consulta DODA: {exc}", + ) from exc + + task_state = str(status_payload.get("state") or status_payload.get("status") or "").upper() + if task_state != "SUCCESS": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="La tarea de consulta aún no está en estado SUCCESS.", + ) + + try: + _apply_consulta_success_to_doda(doda_record, status_payload) + if not (doda_record.status or "").strip(): + doda_record.status = "VALIDADO" + db.add(doda_record) + db.commit() + db.refresh(doda_record) + except Exception as exc: + db.rollback() + logger.exception("Error aplicando consulta-status al DODA id=%s", doda_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"No se pudo aplicar el resultado de consulta al DODA: {exc}", + ) from exc + + return { + "message": "Resultado de consulta aplicado correctamente.", + "doda_id": doda_id, + "task_id": task_id, + "state": task_state, + } + + +@router.post( + "/{doda_id}/eliminar", + summary="Enviar Eliminación DODA al servicio externo (asíncrono)", + tags=["doda-alta"], +) +async def post_doda_eliminar( + doda_id: int, + company_id: int = Query(..., description="Company ID"), + variant: str = Query("doda", description="Tipo de alta: 'doda' o 'pita'"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +) -> Dict[str, Any]: + tenant_id = validate_access_to_resource(db, company_id, current_user) + user_email = ( + current_user.get("email") + or current_user.get("preferred_username") + or "" + ) + service = DodaAltaService(db) + try: + payload = service.build_eliminar_payload( + doda_id=doda_id, + tenant_id=int(tenant_id), + company_id=company_id, + variant=variant, + user_email=user_email, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + ext_result: Dict[str, Any] + try: + ext = DodaExternalService() + ext_result = ext.post_eliminar(payload) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error al enviar eliminación DODA al servicio externo: doda_id=%s", doda_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al contactar el servicio DODA externo (eliminación): {exc}", + ) from exc + + doda_record = DodaService.get_by_id(db, doda_id, int(tenant_id), company_id) + if doda_record: + try: + DodaAltaLogService.create_from_alta_result( + db=db, + doda=doda_record, + company_id=company_id, + tenant_id=int(tenant_id), + variant=variant, + ext_result=ext_result, + action="eliminar", + ) + except Exception: + logger.exception("Error persistiendo DodaAltaLog(eliminar) para doda_id=%s", doda_id) + + try: + doda_record.integration_number = None + doda_record.transaction_number = None + doda_record.status = "PENDIENTE" + doda_record.sat_digital_seal = None + doda_record.sat_certificate = None + doda_record.serial_number = None + doda_record.electronic_signature = None + doda_record.original_chain = None + doda_record.sat_original_chain = None + doda_record.linq_sat_qr = None + doda_record.xml_doda_sent_path = None + doda_record.xml_doda_response_path = None + db.add(doda_record) + db.commit() + except Exception: + db.rollback() + logger.exception("Error desprocesando DODA local tras eliminación id=%s", doda_id) + return ext_result + + +@router.get( + "/eliminar-status/{task_id}", + summary="Consultar estado de tarea de Eliminación DODA", + tags=["doda-alta"], +) +async def get_doda_eliminar_status( + task_id: str, + current_user: dict = Depends(get_current_user), +) -> Any: + try: + ext = DodaExternalService() + return ext.get_eliminar_status(task_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + logger.exception("Error consultando eliminar-status DODA task_id=%s", task_id) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Error al consultar el estado de la eliminación DODA: {exc}", + ) from exc + + # ============ DODA ALTA LOG CRUD ============ diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/service.py b/backend/api/v1/modules/a76/general_catalogs/doda/service.py index ee60e5e5..33172fd7 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/service.py @@ -37,6 +37,19 @@ logger = logging.getLogger(__name__) class DodaService: """Servicio para gestión de DODA""" + @staticmethod + def _ensure_editable_doda(doda: Optional[Doda]) -> None: + if not doda: + return + if (doda.integration_number or "").strip(): + raise HTTPException( + status_code=422, + detail=( + "El DODA ya fue generado (tiene número de integración). " + "Elimínelo primero para poder editarlo." + ), + ) + @staticmethod def _invalidate_report_after_mutation( db: Session, @@ -132,6 +145,7 @@ class DodaService: db_doda = DodaService.get_by_id(db, id, tenant_id, company_id) if not db_doda: return None + DodaService._ensure_editable_doda(db_doda) for key, value in doda_data.model_dump(exclude_unset=True).items(): setattr(db_doda, key, value) @@ -195,6 +209,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: return None + DodaService._ensure_editable_doda(doda) cv = (container_data.container_value or "").strip() if not cv: @@ -259,6 +274,8 @@ class DodaService: ) if not db_container: return None + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) for key, value in container_data.model_dump(exclude_unset=True).items(): setattr(db_container, key, value) @@ -308,6 +325,8 @@ class DodaService: ) if not db_container: raise HTTPException(status_code=404, detail="Contenedor no encontrado.") + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) has_seals = bool(db_container.seals_detail) if not has_seals and db_container.seals: @@ -376,6 +395,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: raise HTTPException(status_code=404, detail="DODA no encontrado.") + DodaService._ensure_editable_doda(doda) container = ( db.query(DodaContainer) @@ -387,6 +407,8 @@ class DodaService: ) if not container: raise HTTPException(status_code=404, detail="Contenedor no encontrado.") + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) raw_value = (seal_data.seal_value or "").strip() if not raw_value: @@ -504,6 +526,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: return None + DodaService._ensure_editable_doda(doda) tipo = (pedimento_data.american_pedimento_type or "").strip() valor = (pedimento_data.american_pedimento_value or "").strip() @@ -595,8 +618,9 @@ class DodaService: raise HTTPException( status_code=404, detail="Pedimento americano no encontrado." ) + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) try: - doda = db.get(Doda, doda_id) db.delete(db_pedimento) db.commit() if doda: @@ -625,6 +649,7 @@ class DodaService: doda = db.query(Doda).filter(Doda.id == doda_id).first() if not doda: return None + DodaService._ensure_editable_doda(doda) max_line = ( db.query(DodaPedimento) @@ -681,8 +706,9 @@ class DodaService: ) if not db_pedimento: raise HTTPException(status_code=404, detail="Pedimento no encontrado.") + doda = db.get(Doda, doda_id) + DodaService._ensure_editable_doda(doda) try: - doda = db.get(Doda, doda_id) db.delete(db_pedimento) db.commit() if doda: diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py b/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py new file mode 100644 index 00000000..4d18d0e0 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from api.v1.modules.a76.general_catalogs.doda.alta_service import DodaAltaService + + +def test_build_consulta_payload_appends_numero_transaccion(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr( + service, + "build_alta_payload", + lambda **kwargs: {"base": "payload"}, + ) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=9, + result_json=json.dumps({"numero_transaccion": "TX-001"}), + integration_number="INT-001", + ), + ) + + payload = service.build_consulta_payload( + doda_id=1, tenant_id=1, company_id=1, variant="doda", user_email="u@test.com" + ) + assert payload["base"] == "payload" + assert payload["numero_transaccion"] == "TX-001" + + +def test_build_consulta_payload_fails_when_numero_transaccion_missing(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr(service, "build_alta_payload", lambda **kwargs: {}) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=10, result_json=json.dumps({"other": "value"}), integration_number="INT-001" + ), + ) + + with pytest.raises(ValueError, match="numero_transaccion"): + service.build_consulta_payload( + doda_id=1, tenant_id=1, company_id=1, variant="doda", user_email="" + ) + + +def test_build_eliminar_payload_appends_numero_integracion(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr(service, "build_alta_payload", lambda **kwargs: {"base": "payload"}) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=11, + result_json="{}", + integration_number="INT-900", + ), + ) + + payload = service.build_eliminar_payload( + doda_id=2, tenant_id=1, company_id=1, variant="doda", user_email="user@test.com" + ) + assert payload["base"] == "payload" + assert payload["numero_integracion"] == "INT-900" diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py b/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py new file mode 100644 index 00000000..8b5367f3 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any, Dict + +from api.v1.modules.a76.general_catalogs.doda.external_service import DodaExternalService + + +class _FakeResponse: + def __init__(self, payload: Dict[str, Any]): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> Dict[str, Any]: + return self._payload + + +class _FakeClient: + calls = [] + + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url: str, json: Dict[str, Any]): + _FakeClient.calls.append(("POST", url, json)) + return _FakeResponse({"task_id": "t-1", "status": "queued", "message": "ok"}) + + def get(self, url: str): + _FakeClient.calls.append(("GET", url, None)) + return _FakeResponse({"task_id": "t-1", "status": "done", "message": "ok"}) + + +def test_external_service_supports_consulta_and_eliminar(monkeypatch): + from api.v1.modules.a76.general_catalogs.doda import external_service as module + + _FakeClient.calls = [] + monkeypatch.setattr(module, "httpx", module.httpx) + monkeypatch.setattr(module.httpx, "Client", _FakeClient) + + service = DodaExternalService() + service.base_url = "http://example.test" + + payload = {"foo": "bar"} + consulta = service.post_consulta(payload) + consulta_status = service.get_consulta_status("abc123") + eliminar = service.post_eliminar(payload) + eliminar_status = service.get_eliminar_status("abc123") + + assert consulta["task_id"] == "t-1" + assert consulta_status["status"] == "done" + assert eliminar["status"] == "queued" + assert eliminar_status["task_id"] == "t-1" + assert _FakeClient.calls == [ + ("POST", "http://example.test/api/v1/doda/consulta", payload), + ("GET", "http://example.test/api/v1/doda/consulta-status/abc123", None), + ("POST", "http://example.test/api/v1/doda/eliminar", payload), + ("GET", "http://example.test/api/v1/doda/eliminar-status/abc123", None), + ] diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts index 7e381174..770510dc 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts @@ -478,6 +478,56 @@ export async function getDodaAltaStatus( return api.get(`/v1/a76/doda/alta-status/${taskId}`); } +export async function postDodaConsulta( + dodaId: number, + companyId: number, + variant: 'doda' | 'pita' = 'doda' +): Promise> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.post(`/v1/a76/doda/${dodaId}/consulta?${params}`, {}); +} + +export async function getDodaConsultaStatus( + taskId: string +): Promise> { + return api.get(`/v1/a76/doda/consulta-status/${taskId}`); +} + +export async function postDodaConsultaApply( + dodaId: number, + taskId: string, + companyId: number +): Promise>> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + }); + return api.post>( + `/v1/a76/doda/${dodaId}/consulta-apply/${taskId}?${params}`, + {} + ); +} + +export async function postDodaEliminar( + dodaId: number, + companyId: number, + variant: 'doda' | 'pita' = 'doda' +): Promise> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.post(`/v1/a76/doda/${dodaId}/eliminar?${params}`, {}); +} + +export async function getDodaEliminarStatus( + taskId: string +): Promise> { + return api.get(`/v1/a76/doda/eliminar-status/${taskId}`); +} + export async function getDodaElegibilidad( dodaId: number, companyId: number, diff --git a/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte index 6580d455..a7498cd4 100644 --- a/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte @@ -7,6 +7,7 @@ getDodaAltaStatus, type DodaAltaStatusResponse } from '$lib/api/dashboard/a76/general_catalogs/doda'; + import type { ApiResponse } from '$lib/api'; import { m } from '$lib/i18n/messages'; let { @@ -14,6 +15,9 @@ taskId, dodaId, variant = 'doda', + title = m['sidebar.doda_alta.progress_title'](), + description = 'Task ID', + getStatus = getDodaAltaStatus, onComplete, onCancel }: { @@ -21,6 +25,9 @@ taskId: string; dodaId?: number; variant?: 'doda' | 'pita'; + title?: string; + description?: string; + getStatus?: (taskId: string) => Promise>; onComplete?: (result: DodaAltaStatusResponse) => void; onCancel?: () => void; } = $props(); @@ -84,7 +91,7 @@ if (!taskId || !pollingActive || pollInFlight) return; pollInFlight = true; try { - const res = await getDodaAltaStatus(taskId); + const res = await getStatus(taskId); if (res.error) { consecutivePollErrors += 1; @@ -151,8 +158,8 @@ - {m['sidebar.doda_alta.progress_title']()} - Alta {variantLabel} — Task ID: {taskId} + {title} + {description} {variantLabel} — Task ID: {taskId}
diff --git a/frontend/src/routes/dashboard/despacho/doda/+page.svelte b/frontend/src/routes/dashboard/despacho/doda/+page.svelte index b5b9126e..4edc440c 100644 --- a/frontend/src/routes/dashboard/despacho/doda/+page.svelte +++ b/frontend/src/routes/dashboard/despacho/doda/+page.svelte @@ -35,7 +35,14 @@ deleteDoda, exportDodaPedimentosDetail, postDodaAlta, + postDodaConsulta, + postDodaConsultaApply, + postDodaEliminar, + getDodaAltaStatus, getDodaElegibilidad, + getDodaConsultaStatus, + getDodaEliminarStatus, + type DodaAltaStatusResponse, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda'; @@ -71,9 +78,13 @@ let exportDialogOpen = $state(false); let currentTaskId = $state(''); let currentVariant = $state<'doda' | 'pita'>('doda'); + let progressMode = $state<'alta' | 'consulta' | 'eliminar'>('alta'); let altaLoading = $state(false); + let consultaLoading = $state(false); + let eliminarExternoLoading = $state(false); let deleteLoading = $state(false); let pedimentosExportLoading = $state(false); + const hasIntegration = $derived(!!(selectedDoda?.integration_number || '').trim()); $effect(() => { if (data.dodas) { @@ -223,11 +234,94 @@ } } - function onAltaComplete() { - progressDialogOpen = false; - reloadDodas(); - toast.success(m['sidebar.doda_alta.progress_success']()); + async function handleConsultar() { + if (!selectedDoda || !companyStore.activeCompany || consultaLoading) return; + if (!hasIntegration) { + toast.error('El DODA aún no está generado para consultar.'); + return; + } + consultaLoading = true; + try { + const resp = await postDodaConsulta(selectedDoda.id, companyStore.activeCompany.id, altaVariant); + if (resp.error || !resp.data?.task_id) { + toast.error(resp.error || 'Error al enviar consulta DODA'); + return; + } + progressMode = 'consulta'; + currentTaskId = resp.data.task_id; + currentVariant = altaVariant; + progressDialogOpen = true; + } finally { + consultaLoading = false; + } } + + async function handleEliminarExterno() { + if (!selectedDoda || !companyStore.activeCompany || eliminarExternoLoading) return; + if (!hasIntegration) { + toast.error('El DODA aún no está generado para eliminar externamente.'); + return; + } + if (!confirm('¿Deseas eliminar este DODA en el servicio externo para volver a editarlo?')) return; + eliminarExternoLoading = true; + try { + const resp = await postDodaEliminar(selectedDoda.id, companyStore.activeCompany.id, altaVariant); + if (resp.error || !resp.data?.task_id) { + toast.error(resp.error || 'Error al enviar eliminación DODA'); + return; + } + progressMode = 'eliminar'; + currentTaskId = resp.data.task_id; + currentVariant = altaVariant; + progressDialogOpen = true; + } finally { + eliminarExternoLoading = false; + } + } + + async function onProgressComplete(_result: DodaAltaStatusResponse) { + progressDialogOpen = false; + if (progressMode === 'consulta' && selectedDoda && companyStore.activeCompany) { + const applyResp = await postDodaConsultaApply( + selectedDoda.id, + currentTaskId, + companyStore.activeCompany.id + ); + if (applyResp.error) { + toast.error(`Consulta completada, pero no se pudo aplicar al DODA: ${applyResp.error}`); + } + } + await reloadDodas(); + if (progressMode === 'eliminar') { + toast.success('Eliminación DODA completada. El registro quedó editable nuevamente.'); + } else if (progressMode === 'consulta') { + toast.success('Consulta DODA completada y aplicada al registro.'); + } else { + toast.success(m['sidebar.doda_alta.progress_success']()); + } + } + + const progressTitle = $derived( + progressMode === 'consulta' + ? 'Consulta DODA' + : progressMode === 'eliminar' + ? 'Eliminación DODA' + : m['sidebar.doda_alta.progress_title']() + ); + const progressDescription = $derived( + progressMode === 'consulta' + ? 'Consulta' + : progressMode === 'eliminar' + ? 'Eliminación' + : 'Alta' + ); + const progressStatusGetter = $derived( + progressMode === 'consulta' + ? getDodaConsultaStatus + : progressMode === 'eliminar' + ? getDodaEliminarStatus + : getDodaAltaStatus + );
@@ -343,11 +437,38 @@ variant="outline" size="sm" onclick={handleEdit} - disabled={selectedDodaIds.length !== 1} + disabled={selectedDodaIds.length !== 1 || hasIntegration} > {m['sidebar.doda_alta.action_edit']()} + +
+
+ + (formData.rfc_consulta = (e.target as HTMLInputElement).value.toUpperCase())} + placeholder="RFC para consulta" + maxlength={13} + disabled={loading} + /> +
+
($page.url.searchParams.get('type') || 'both'); - let filterDebounce: ReturnType | null = null; // Estado para el diálogo de crear let showCreateDialog = $state(false); let error = $state(data.error || null); - // Permisos - const canView = $derived(userHasPermission($authStore.user, 'partners_mgmt.view')); - const canCreate = $derived(userHasPermission($authStore.user, 'partners_mgmt.create')); - const canEdit = $derived(userHasPermission($authStore.user, 'partners_mgmt.edit')); - const canDelete = $derived(userHasPermission($authStore.user, 'partners_mgmt.delete')); - // --- Lifecycle --- onMount(() => { if (browser) { - const getCookie = (name: string): string | null => { + // Sincronizar token de cookies a localStorage si es necesario + const getCookie = (name: string) => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop()?.split(';').shift() || null; return null; }; - const cookieToken = getCookie('access_token'); const localToken = localStorage.getItem('access_token'); - if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken); @@ -77,7 +64,7 @@ // --- Actions --- - async function loadItems(pageToLoad = 1, append = false) { + async function loadItems(pageToLoad = 1) { const companyId = companyStore.activeCompany?.id; if (!companyId) return; @@ -85,10 +72,13 @@ try { const filters: any = {}; if (searchType !== 'both') filters.type = searchType; - const trimmedName = searchName.trim(); - const trimmedRfc = searchRfc.trim(); - if (trimmedName) filters.name = trimmedName; - if (trimmedRfc) filters.rfc = trimmedRfc; + // Note: The API technically supports name/rfc fitlering if backend implements it. + // Assuming backend supports 'name' and 'rfc' query params based on standard patterns, + // or we filter client side if the list is small. + // Given pagination, we should try sending them. If backend ignores them, we might need client filtering. + // Ideally backend should handle this. I will assume backend filters for now or add query params. + if (searchName) filters.name = searchName; + if (searchRfc) filters.rfc = searchRfc; const response = await clientsProvidersApi.list(companyId, pageToLoad, pageSize, filters); @@ -103,11 +93,7 @@ } if (response.data) { - if (append) { - items = [...items, ...response.data.items]; - } else { - items = response.data.items; - } + items = response.data.items; totalItems = response.data.total; currentPage = response.data.page; } @@ -119,13 +105,13 @@ } } - async function loadMore() { - if (isLoading || !hasMore) return; - await loadItems(currentPage + 1, true); - } - function handleTypeChange(value: string) { searchType = value; + loadItems(1); + } + + function handleSearch() { + loadItems(1); } function selectItem(item: ClientProvider) { @@ -142,10 +128,6 @@ if (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`); } - function handleSearch() { - loadItems(1); - } - import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosListaSocios } from '$lib/config/shortcuts/dashboard/clients_and_providers/list'; @@ -173,218 +155,331 @@ recargar: () => loadItems(1) }) ); - - $effect(() => { - const companyId = companyStore.activeCompany?.id; - if (!browser || !companyId) return; - - searchName; - searchRfc; - searchType; - pageSize; - - if (filterDebounce) clearTimeout(filterDebounce); - filterDebounce = setTimeout(() => { - selectedItem = null; - loadItems(1); - }, 350); - - return () => { - if (filterDebounce) clearTimeout(filterDebounce); - }; - }); -
- {#if !canView} - window.location.reload()} - /> - {:else} -
-
-

Socio Comercial

-

Administración de clientes y proveedores

-
-
- - {#if canCreate} - - {/if} - {#if canEdit} - - {/if} - {#if canDelete} - - {/if} -
-
+
+ +
+

CLIENTES Y PROVEEDORES

+

+ Gestiona el catálogo de clientes y proveedores de tu empresa +

+
- {#if error} -
- {typeof error === 'string' ? error : error.detail} -
- {/if} + + {#if error} + + + Error + + {typeof error === 'string' ? error : error.detail} + + + + {/if} -
- -
- -
-
-
-

Filtros

- Busque por nombre, RFC/TAX-ID o tipo -
-
-
- - e.key === 'Enter' && handleSearch()} - /> -
-
- - e.key === 'Enter' && handleSearch()} - /> -
-
- - - - {searchType === 'both' - ? 'Todos' - : searchType === 'client' - ? 'Clientes' - : 'Proveedores'} - - - Todos - Clientes - Proveedores - - -
-
- -
-
+
+ +
+ +
+
+
+

Filtros

+ Busque por nombre, RFC/TAX-ID o tipo
-
- - -
-
-

Listado

-
- - {totalItems} registros - -
- -
- loadItems(1))} - loading={isLoading} - {hasMore} - {loadMore} - onRowClick={(row) => selectItem(row as ClientProvider)} - selectedId={selectedItem?.id} - /> -
- -
-
-

- Detalles del Registro -

-

- {selectedItem?.name || '---'} -

-
- {taxIdOrRfcLabel(selectedItem)}: - {selectedItem?.rfc || ''} + +
+
+

Listado

+
+ + {totalItems} registros + +
-
- {#if selectedItem} -
-
- -

{selectedItem.client_or_provider}

-
- - {#if selectedItem.address} -
- -
-

{selectedItem.address.streets || ''} {selectedItem.address.exterior_number || ''}

-

{selectedItem.address.neighborhood || ''}

-

{selectedItem.address.city || ''}, {selectedItem.address.state || ''}

-

{selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''}

-
-
+
+ + + + + + + + + + + + {#if isLoading} + + {:else if items.length === 0} + + {:else} + {#each items as item (item.id)} + selectItem(item)} + > + + + + + + + {/each} {/if} - - {:else} -
- -

Selecciona un registro

-
- {/if} + +
#RFC / TAX-IDNombreTipoEstatus
Cargando...
No se encontraron registros
{item.id}{item.rfc}{item.name} + {#if item.client_or_provider === 'client'} + Cliente + {:else if item.client_or_provider === 'provider'} + Proveedor + {:else} + Ambos + {/if} + + + {item.is_active ? 'Activo' : 'Inactivo'} + +
+
+ +
+ + + Página {currentPage} de {Math.ceil(totalItems / pageSize)} + +
- {/if} + + +
+
+

+ Detalles del Registro +

+

+ {selectedItem?.name || '---'} +

+
+ {taxIdOrRfcLabel(selectedItem)}: + {selectedItem?.rfc || ''} +
+
+ +
+ {#if selectedItem} +
+
+ +

{selectedItem.client_or_provider}

+
+ + {#if selectedItem.address} +
+ +
+

+ {selectedItem.address.streets || ''} + {selectedItem.address.exterior_number || ''} + {selectedItem.address.interior_number + ? 'Int ' + selectedItem.address.interior_number + : ''} +

+

{selectedItem.address.neighborhood || ''}

+

{selectedItem.address.city || ''}, {selectedItem.address.state || ''}

+

+ {selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''} +

+
+
+ +
+ + {#if selectedItem.address.email} +
+ + {selectedItem.address.email} +
+ {/if} + {#if selectedItem.address.phone} +
+ + {selectedItem.address.phone} +
+ {/if} +
+ {:else} +
+

Sin dirección registrada

+
+ {/if} + + {#if selectedItem.programs} +
+ +
+
+ Programa + {selectedItem.programs.program || '-'} +
+
+ Número + {selectedItem.programs.program_number || '-'} +
+
+
+ {/if} +
+ {:else} +
+ +

Selecciona un registro

+
+ {/if} +
+
+
+
+ + +
+
+
+ + + +
+
diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index 961424b1..94d020b3 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -35,7 +35,6 @@ Briefcase } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; - import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; // Componentes Compartidos (Modales) import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte'; @@ -49,9 +48,6 @@ // API & Stores import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; - import { authStore, userHasPermission } from '$lib/auth'; - import { browser } from '$app/environment'; - // --- CONFIGURACIÓN --- let id = $derived($page.params.id); @@ -133,20 +129,6 @@ else if (!id || id === 'new') formData = getEmptyForm(); }); - // Permisos - const requiredPermission = $derived(isEditing ? 'partners_mgmt.edit' : 'partners_mgmt.create'); - const canAccess = $derived(userHasPermission($authStore.user, requiredPermission)); - - onMount(() => { - if (browser) { - const handleCompanyChange = () => { - if (isEditing) loadData(Number(id)); - }; - window.addEventListener('companyChanged', handleCompanyChange); - return () => window.removeEventListener('companyChanged', handleCompanyChange); - } - }); - async function loadData(clientId: number) { if (!companyStore.activeCompany?.id) return; loading = true; @@ -316,521 +298,530 @@ ); -
- {#if !canAccess} - window.location.reload()} - onBack={() => goto('/dashboard/clients_and_providers')} - /> - {:else} -
- -
-
-
- -

- {isEditing ? `Socio Comercial #${id}` : 'Nuevo Socio Comercial'} -

- {#if isEditing} - - {formData.is_active ? 'Activo' : 'Inactivo'} - - {:else} - Nuevo - {/if} -
-

- {isEditing - ? 'Edita la información del cliente o proveedor' - : 'Registra un nuevo cliente o proveedor en el sistema'} -

-
-
- - - - -
- -
{ - e.preventDefault(); - handleSubmit(); - }} - > - - - - - - Información General - Datos principales de identificación y clasificación. - - -
-
- - -
-
- - (formData.client_or_provider = v)} - > - - {typeLabels[formData.client_or_provider] || 'Selecciona un tipo'} - - - Cliente - Proveedor - Ambos - - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - (formData.type_nat_foreign = v)} - > - - {formData.type_nat_foreign === 'N' - ? 'Nacional' - : formData.type_nat_foreign === 'E' - ? 'Extranjero' - : 'Seleccione'} - - - Nacional - Extranjero - - -
-
- -
-
- - -
-
- - -
-
-
-
-
- - - - - - Dirección y Contacto - Ubicación fiscal y datos de contacto. - - -
-
- - -
-
-
- - -
-
- - -
-
-
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
- - -
-
-
- -
- - -
-
-
- - - -
-
- - -
-
- - -
-
-
-
-
- - - - - - Programas y Certificaciones - Información sobre IMMEX, PROSEC y otras certificaciones. - - - -
-
- - Programas de Fomento -
- -
-
- - (formData.program = v)} - disabled={loading} - > - - {formData.program || 'Selecciona un programa'} - - - {#each scaiiPrograms as prog} - - {prog.label} - - {/each} - - -
-
- - -
-
- - -
-
- -
- - -
-
-
-
- - -
-
- - Identificación Industrial -
- -
-
- - -
-
-
- - -
-
- - Certificaciones y Seguridad -
- -
-
- - -
-
- -
- -

- Indica si cuenta con certificación de empresa -

-
-
-
-
-
-
-
- - - - - - Configuración - Ajustes de estado y atributos especiales. - - -
-
- -
- -

- Habilitar o deshabilitar este socio comercial -

-
-
-
-
-
-
-
-
+
+ +
+
+
+ +

+ {isEditing ? `Socio Comercial #${id}` : 'Nuevo Socio Comercial'} +

+ {#if isEditing} + + {formData.is_active ? 'Activo' : 'Inactivo'} + + {:else} + Nuevo + {/if}
+

+ {isEditing + ? 'Edita la información del cliente o proveedor' + : 'Registra un nuevo cliente o proveedor en el sistema'} +

- {/if} +
+ + + + +
+ +
{ + e.preventDefault(); + handleSubmit(); + }} + > + + + + + + Información General + Datos principales de identificación y clasificación. + + +
+
+ + +
+
+ + (formData.client_or_provider = v)} + > + + {typeLabels[formData.client_or_provider] || 'Selecciona un tipo'} + + + Cliente + Proveedor + Ambos + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + (formData.type_nat_foreign = v)} + > + + {formData.type_nat_foreign === 'N' + ? 'Nacional' + : formData.type_nat_foreign === 'E' + ? 'Extranjero' + : 'Seleccione'} + + + Nacional + Extranjero + + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+ + + + + + Dirección y Contacto + Ubicación fiscal y datos de contacto. + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+
+
+
+ + + + + + Programas y Certificaciones + Información sobre IMMEX, PROSEC y otras certificaciones. + + + +
+
+ + Programas de Fomento +
+ +
+
+ + (formData.program = v)} + disabled={loading} + > + + {formData.program || 'Selecciona un programa'} + + + {#each scaiiPrograms as prog} + + {prog.label} + + {/each} + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ + Identificación Industrial +
+ +
+
+ + +
+
+
+ + +
+
+ + Certificaciones y Seguridad +
+ +
+
+ + +
+
+ +
+ +

+ Indica si cuenta con certificación de empresa +

+
+
+
+
+
+
+
+ + + + + + Configuración + Ajustes de estado y atributos especiales. + + +
+
+ +
+ +

+ Habilitar o deshabilitar este socio comercial +

+
+
+
+
+
+
+
+
+
-{#if canAccess} -
-
- - -
- - - General - - - Dirección - - - Programas - - - Config - - -
-
- - -
- - +
+
+ + +
+ + + General + + + Dirección + + + Programas + + + Config + +
+
+ + +
+ + {#if !isEditing} + + {/if} +
-{/if} +
- - (formData.country = c.code_3)} /> - (formData.state = s.code)} /> - (formData.prosec = sc.code)} /> + + (formData.country = country.m3_key)} +/> + + { + formData.state = state.description; + if (state.m3_key && !formData.country) { + formData.country = state.m3_key; + } + }} +/> + + (formData.prosec = sector.key)} +/> diff --git a/frontend/src/routes/dashboard/customs_brokers/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/+page.svelte index 7eacd2b6..aabebb79 100644 --- a/frontend/src/routes/dashboard/customs_brokers/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/+page.svelte @@ -13,15 +13,13 @@ import { page } from '$app/stores'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosListaAgentes } from '$lib/config/shortcuts/dashboard/customs_brokers/list'; - import { authStore, userHasPermission } from '$lib/auth'; - import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; import { customsSectionsApi, type CustomsSection } from '$lib/api/dashboard/reference_data/customs_sections'; import SectionsDataTable from '$lib/components/dashboard/reference_data/customs_sections/data-table.svelte'; - import { createColumns as createSectionColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns.js'; + import { createColumns as createSectionColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns'; import * as Card from '$lib/components/ui/card'; // Specialized Broker Components @@ -70,12 +68,6 @@ let sectionsPage = $state(1); let hasMoreSections = $derived(sections.length < totalSections); - // Permisos - const canView = $derived(userHasPermission($authStore.user, 'customs_brokers.view')); - const canCreate = $derived(userHasPermission($authStore.user, 'customs_brokers.create')); - const canEdit = $derived(userHasPermission($authStore.user, 'customs_brokers.edit')); - const canDelete = $derived(userHasPermission($authStore.user, 'customs_brokers.delete')); - // --- Lifecycle --- onMount(() => { if (browser) { @@ -196,266 +188,256 @@
- {#if !canView} - window.location.reload()} - /> - {:else} -
-
-

Gestión Aduanal

-

Administración de Agentes y Secciones Aduanales

-
-
- {#if canCreate} - - {/if} - {#if canEdit} - - {/if} - {#if canDelete} - - {/if} -
+
+
+

Gestión Aduanal

+

Administración de Agentes y Secciones Aduanales

+
- - - - Agentes Aduanales - - - Secciones Aduanales - - - - + + -
-
-
-
-

Filtros

- Busque por nombre o patente -
-
-
- - -
-
- - -
-
-
-
-
+ Agentes Aduanales + + + Secciones Aduanales + + -
-
-

Listado

-
- - {filteredItems.length} registros - - -
+ +
+
+
+
+

Filtros

+ Busque por nombre o patente
- -
- -
- {#if totalItems > pageSize} -
- - - Página {currentPage} de {Math.ceil(totalItems / pageSize)} - - +
+
+ +
- {/if} +
+ + +
+
+
-
-
-

- Detalles del Agente -

-

- {selectedItem?.name || '---'} -

-
- Patente: {selectedItem?.broker_key || ''} +
+
+

Listado

+
+ + {filteredItems.length} registros + +
- - {#if selectedItem} - - {/if}
-
- {#if selectedItem} -
+
+ +
+ {#if totalItems > pageSize} +
+ + + Página {currentPage} de {Math.ceil(totalItems / pageSize)} + + +
+ {/if} +
+
+ +
+
+

+ Detalles del Agente +

+

+ {selectedItem?.name || '---'} +

+
+ Patente: {selectedItem?.broker_key || ''} +
+
+ +
+ {#if selectedItem} +
+
+ +

{selectedItem.license || '-'}

+
+ + {#if selectedItem.tax_id}
-

{selectedItem.license || '-'}

+

{selectedItem.tax_id}

+ {/if} - {#if selectedItem.tax_id} -
- -

{selectedItem.tax_id}

+
+ +
+

{selectedItem.address || ''}

+

+ {[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')} +

+

+ {[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')} +

+
+
+ +
+ + {#if selectedItem.email} +
+ + {selectedItem.email}
{/if} - -
- -
-

{selectedItem.address || ''}

-

- {[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')} -

-

- {[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')} -

+ {#if selectedItem.phone} +
+ + {selectedItem.phone}
-
- -
- - {#if selectedItem.email} -
- - {selectedItem.email} -
- {/if} - {#if selectedItem.phone} -
- - {selectedItem.phone} -
- {/if} - {#if selectedItem.contact} -
- Contacto: - {selectedItem.contact} -
- {/if} -
+ {/if} + {#if selectedItem.contact} +
+ Contacto: + {selectedItem.contact} +
+ {/if}
- {:else} -
- -

Selecciona un agente

-
- {/if} -
+
+ {:else} +
+ +

Selecciona un agente

+
+ {/if}
- +
+ - - - - - - - - + + + + + + + + -
- {/if} +
+
+
+
+
+ {#if activeTab === 'brokers'} + + + + {:else} + + {/if} +
+
diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index 6174b0f1..f08dcc61 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -1,8 +1,5 @@ -
- {#if !canAccess} - window.location.reload()} - onBack={() => goto('/dashboard/customs_brokers')} - /> - {:else} - -
- -
-
-
- -

- {isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'} -

- - {isEdit ? 'Edición' : 'Nuevo'} - -
-

- {isEdit - ? 'Modifica la información del agente aduanal' - : 'Registra un nuevo agente aduanal en el sistema'} -

-
-
- - + +
+ +
+
+
+ +

+ {isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'} +

+ + {isEdit ? 'Edición' : 'Nuevo'} +
+

+ {isEdit + ? 'Modifica la información del agente aduanal' + : 'Registra un nuevo agente aduanal en el sistema'} +

+
+
- -
-
{ - e.preventDefault(); - handleSave(); - }} - > - - - - - Información General - Identificación oficial del agente y patente. - - -
-
- - (formData.type = v)} - disabled={loading} - > - - {formData.type === 'MEX' - ? 'Agente Aduanal Mexicano' - : formData.type === 'USA' - ? 'Agente Aduanal Americano (Broker)' - : 'Selecciona un tipo...'} - - - Agente Aduanal Mexicano - Agente Aduanal Americano (Broker) - - -
-
+ -
-
- - { - const val = e.currentTarget.value.toUpperCase(); - if (val.length > 5) { - brokerKeyError = true; - formData.broker_key = val.slice(0, 5); - e.currentTarget.value = formData.broker_key; - - clearTimeout(brokerKeyTimeout); - brokerKeyTimeout = setTimeout(() => { - brokerKeyError = false; - }, 3000); - } else { - brokerKeyError = false; - formData.broker_key = val; - } - }} - placeholder="Ej. 550" - maxlength={6} - class={`h-10 ${brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} - disabled={isEdit || loading} - /> - {#if brokerKeyError} -

- La clave no debe superar los 5 caracteres -

- {/if} -
-
- - { - const val = e.currentTarget.value.replace(/\D/g, ''); - if (val.length > 4) { - licenseError = true; - formData.license = val.slice(0, 4); - e.currentTarget.value = formData.license; - - clearTimeout(licenseTimeout); - licenseTimeout = setTimeout(() => { - licenseError = false; - }, 3000); - } else { - licenseError = false; - formData.license = val; - } - }} - placeholder="Ej. 3421" - maxlength={5} - class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} - disabled={loading} - /> - {#if licenseError} -

- La patente no debe superar los 4 dígitos -

- {/if} -
-
- - - -
- - -
- -
-
- - { - formData.tax_id = e.currentTarget.value - .toUpperCase() - .replace(/[^A-Z0-9&Ñ]/g, '') - .slice(0, 13); - e.currentTarget.value = formData.tax_id; - }} - placeholder="RFC de la empresa" - disabled={loading} - class="h-10" - /> -
-
- - { - formData.personal_id = e.currentTarget.value - .toUpperCase() - .replace(/[^A-Z0-9]/g, '') - .slice(0, 18); - e.currentTarget.value = formData.personal_id; - }} - placeholder="CURP si aplica" - disabled={loading} - class="h-10" - /> -
-
-
-
-
- - - - - - Información de Contacto - Datos para comunicación con el agente. - - -
-
- - { - formData.contact = e.currentTarget.value.replace( - /[^a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]/g, - '' - ); - e.currentTarget.value = formData.contact; - }} - placeholder="Nombre del contacto" - disabled={loading} - class="h-10" - /> -
-
- - -
-
- - - -
-
- - { - formData.phone = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); - e.currentTarget.value = formData.phone; - }} - placeholder="656-000-0000" - disabled={loading} - class="h-10" - /> -
-
- - { - formData.fax = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); - e.currentTarget.value = formData.fax; - }} - disabled={loading} - class="h-10" - /> -
-
- - -
-
-
-
-
- - - - - - Domicilio Fiscal - Ubicación registrada del agente aduanal. - - -
- - -
-
-
- - { - formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); - }} - /> -
-
- - -
-
-
-
- -
- (showStateDialog = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showStateDialog = true))} - /> - -
-
-
- -
- (showCountryDialog = true)} - onkeydown={(event) => - openOnEnterOrSpace(event, () => (showCountryDialog = true))} - /> - -
-
-
-
-
- - -
- - - - - Ventanilla Única / Web Services - Certificados y credenciales para integración con DODA/PITA. +
+ { + e.preventDefault(); + handleSave(); + }} + > + + + + + Información General + Identificación oficial del agente y patente. + + +
+
+ + (formData.type = v)} + disabled={loading} > - - -
-
- - -
-
- - -
-
- - - -
-
- -
- (pendingVuFiles.certificate = file)} - disabled={loading} - /> - {#if vuData.certificate_path} - - {getFileDisplayName(vuData.certificate_path)} - - {/if} -
-
-
- -
- (pendingVuFiles.key = file)} - disabled={loading} - /> - {#if vuData.key_path} - - {getFileDisplayName(vuData.key_path)} - - {/if} -
-
-
- -
-
- - -
-
- - -
-
-
- - - - - - - DODA / PITA - Credenciales exclusivas para el servicio DODA. - - -
-
- - -
-
- - -
+ + {formData.type === 'MEX' + ? 'Agente Aduanal Mexicano' + : formData.type === 'USA' + ? 'Agente Aduanal Americano (Broker)' + : 'Selecciona un tipo...'} + + + Agente Aduanal Mexicano + Agente Aduanal Americano (Broker) + +
- -
-
- -
- (pendingVuFiles.dodaCertificate = file)} - disabled={loading} - /> - {#if vuData.doda_certificate_path} - - {getFileDisplayName(vuData.doda_certificate_path)} - - {/if} -
-
-
- -
- (pendingVuFiles.dodaKey = file)} - disabled={loading} - /> - {#if vuData.doda_key_path} - - {getFileDisplayName(vuData.doda_key_path)} - - {/if} -
-
-
-
-
-
+
- - - - ANAM / Otros Servicios - Configuraciones de rutas y archivos locales. - - -
-
- - -
-
- - -
+
+
+ + { + const val = e.currentTarget.value.toUpperCase(); + if (val.length > 5) { + brokerKeyError = true; + formData.broker_key = val.slice(0, 5); + e.currentTarget.value = formData.broker_key; + + clearTimeout(brokerKeyTimeout); + brokerKeyTimeout = setTimeout(() => { + brokerKeyError = false; + }, 3000); + } else { + brokerKeyError = false; + formData.broker_key = val; + } + }} + placeholder="Ej. 550" + maxlength={6} + class={`h-10 ${brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} + disabled={isEdit || loading} + /> + {#if brokerKeyError} +

+ La clave no debe superar los 5 caracteres +

+ {/if}
- + { + const val = e.currentTarget.value.replace(/\D/g, ''); + if (val.length > 4) { + licenseError = true; + formData.license = val.slice(0, 4); + e.currentTarget.value = formData.license; + + clearTimeout(licenseTimeout); + licenseTimeout = setTimeout(() => { + licenseError = false; + }, 3000); + } else { + licenseError = false; + formData.license = val; + } + }} + placeholder="Ej. 3421" + maxlength={5} + class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} + disabled={loading} + /> + {#if licenseError} +

+ La patente no debe superar los 4 dígitos +

+ {/if} +
+
+ + + +
+ + +
+ +
+
+ + { + formData.tax_id = e.currentTarget.value + .toUpperCase() + .replace(/[^A-Z0-9&Ñ]/g, '') + .slice(0, 13); + e.currentTarget.value = formData.tax_id; + }} + placeholder="RFC de la empresa" disabled={loading} class="h-10" />
- - - - -
- - {/if} -
+
+ + { + formData.personal_id = e.currentTarget.value + .toUpperCase() + .replace(/[^A-Z0-9]/g, '') + .slice(0, 18); + e.currentTarget.value = formData.personal_id; + }} + placeholder="CURP si aplica" + disabled={loading} + class="h-10" + /> +
+
+
+
+
- -{#if canAccess} + + + + + Información de Contacto + Datos para comunicación con el agente. + + +
+
+ + { + formData.contact = e.currentTarget.value.replace( + /[^a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]/g, + '' + ); + e.currentTarget.value = formData.contact; + }} + placeholder="Nombre del contacto" + disabled={loading} + class="h-10" + /> +
+
+ + +
+
+ + + +
+
+ + { + formData.phone = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); + e.currentTarget.value = formData.phone; + }} + placeholder="656-000-0000" + disabled={loading} + class="h-10" + /> +
+
+ + { + formData.fax = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, ''); + e.currentTarget.value = formData.fax; + }} + disabled={loading} + class="h-10" + /> +
+
+ + +
+
+
+
+
+ + + + + + Domicilio Fiscal + Ubicación registrada del agente aduanal. + + +
+ + +
+
+
+ + { + formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); + }} + /> +
+
+ + +
+
+
+
+ +
+ (showStateDialog = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showStateDialog = true))} + /> + +
+
+
+ +
+ (showCountryDialog = true)} + onkeydown={(event) => + openOnEnterOrSpace(event, () => (showCountryDialog = true))} + /> + +
+
+
+
+
+ + +
+ + + + + Ventanilla Única / Web Services + Certificados y credenciales para integración con DODA/PITA. + + +
+
+ + { + vuData.certificate_path = file.name; + pendingVuFiles.certificate = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ + { + vuData.key_path = file.name; + pendingVuFiles.key = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ +
+
+ + +
+
+ + + + {vuData.vu_figure_type || 'Seleccionar tipo de figura'} + + + AGENTE ADUANAL + APODERADO ADUANAL + MANDATARIO + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + { + vuData.xml_files_path = file.name; + pendingVuFiles.cove = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ + + +
+

+ Configuración Adicional +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + DODA-PITA + Configuración de servicios DODA / PITA. + + +
+
+ + +
+
+ + +
+
+ +
+
+ + { + vuData.doda_certificate_path = file.name; + pendingVuFiles.dodaCertificate = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ + { + vuData.doda_key_path = file.name; + pendingVuFiles.dodaKey = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+ +
+
+ + +
+
+ + { + vuData.doda_xml_files_path = file.name; + pendingVuFiles.dodaCove = file; + toast.success(`Archivo ${file.name} seleccionado`); + }} + /> +
+
+
+
+
+ + + + ANAM + Configuración de acceso para ANAM. + + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+ +
- - -
- - - General - - - Contacto - - - Dirección - - - VU - - - DODA - - - ANAM - - -
-
+ +
+ + General + Contacto + Domicilio + VU + DODA + ANAM + +
- - +
-{/if} + diff --git a/frontend/src/routes/dashboard/digitalizacion/+page.svelte b/frontend/src/routes/dashboard/despacho/digitalizacion/+page.svelte similarity index 87% rename from frontend/src/routes/dashboard/digitalizacion/+page.svelte rename to frontend/src/routes/dashboard/despacho/digitalizacion/+page.svelte index 9262cdf0..7a04901a 100644 --- a/frontend/src/routes/dashboard/digitalizacion/+page.svelte +++ b/frontend/src/routes/dashboard/despacho/digitalizacion/+page.svelte @@ -7,7 +7,9 @@ import * as Card from '$lib/components/ui/card'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; - import { Plus, RefreshCw, FileCheck2, Download, FolderArchive, Pencil, Trash2 } from 'lucide-svelte'; + import { Label } from '$lib/components/ui/label'; + import * as Select from '$lib/components/ui/select'; + import { Plus, RefreshCw, FileCheck2, Download, FolderArchive, Pencil, Trash2, Filter } from 'lucide-svelte'; import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte'; import CreateEditDialog from '$lib/components/dashboard/digitalizacion/create-edit-dialog.svelte'; @@ -31,6 +33,10 @@ let hasMore = $derived(data.length < totalItems); let search = $state($page.url.searchParams.get('search') || ''); + let searchEDocument = $state(''); + let searchRFC = $state(''); + let searchStatus = $state('all'); + let showFilters = $state(false); let searchTimeout: ReturnType; // Selección de filas @@ -63,7 +69,10 @@ const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedIt const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, { page: 1, page_size: pageSize, - search: search || undefined + search: search || undefined, + e_document: searchEDocument || undefined, + rfc_consulta: searchRFC || undefined, + status: searchStatus === 'all' ? undefined : searchStatus }); if (res.data) { data = res.data.items; @@ -85,7 +94,10 @@ const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedIt const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, { page: currentPage + 1, page_size: pageSize, - search: search || undefined + search: search || undefined, + e_document: searchEDocument || undefined, + rfc_consulta: searchRFC || undefined, + status: searchStatus === 'all' ? undefined : searchStatus }); if (res.data?.items) { data = [...data, ...res.data.items]; @@ -393,6 +405,15 @@ async function handleDownloadArtifact(
{m['sidebar.digitalizacion.table_title']()}
+
+ + {#if showFilters} +
+ +
+ + +
+ + +
+ + +
+ + +
+ + { + searchStatus = v || 'all'; + loadData(); + }} + > + + + + + Todos + Pendiente + Procesando + Completado + Fallido + + +
+ + +
+ +
+
+ {/if} {#if loading && data.length === 0} diff --git a/frontend/src/routes/dashboard/despacho/doda/+page.server.ts b/frontend/src/routes/dashboard/despacho/doda/+page.server.ts index 96caa12d..8d3a8b86 100644 --- a/frontend/src/routes/dashboard/despacho/doda/+page.server.ts +++ b/frontend/src/routes/dashboard/despacho/doda/+page.server.ts @@ -2,67 +2,80 @@ import type { PageServerLoad } from './$types'; import { getAuthTokens, authenticatedFetch } from '$lib/server/api'; export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => { - const parentData = await parent(); - const { accessToken } = getAuthTokens(cookies); + const parentData = await parent(); + const { accessToken } = getAuthTokens(cookies); - if (!accessToken) { - return { - error: 'No authenticated', - dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } - }; - } + const pUser = parentData.user as + | { preferred_username?: string; name?: string; email?: string } + | undefined + | null; + const defaultLastUser = + (pUser?.preferred_username?.trim() || pUser?.name?.trim() || pUser?.email?.trim() || '') || ''; - try { - const page = Number(url.searchParams.get('page')) || 1; - const pageSize = Number(url.searchParams.get('pageSize')) || 50; + if (!accessToken) { + return { + error: 'No authenticated', + dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }, + defaultLastUser + }; + } - const cookieCompanyId = cookies.get('active_company_id'); - const companyId = cookieCompanyId - ? parseInt(cookieCompanyId) - : parentData.companies?.[0]?.id; + try { + const page = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('pageSize')) || 50; + + // Obtener company_id de la cookie o usar el primero disponible + const cookieCompanyId = cookies.get('active_company_id'); + const companyId = cookieCompanyId + ? parseInt(cookieCompanyId) + : parentData.companies?.[0]?.id; - if (!companyId) { - return { - error: 'No company selected', - dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } - }; - } + if (!companyId) { + return { + error: 'No company selected', + dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }, + defaultLastUser + }; + } - const filters: Record = {}; - const integrationNumber = url.searchParams.get('integration_number'); - const patent = url.searchParams.get('patent'); - const status = url.searchParams.get('status'); - const operationType = url.searchParams.get('operation_type'); + const filters: Record = {}; + const integrationNumber = url.searchParams.get('integration_number'); - if (integrationNumber) filters.integration_number = integrationNumber; - if (patent) filters.patent = patent; - if (status) filters.status = status; - if (operationType) filters.operation_type = operationType; + if (integrationNumber) filters.integration_number = integrationNumber; - const queryParams = new URLSearchParams({ - page: page.toString(), - page_size: pageSize.toString(), - company_id: companyId.toString(), - ...filters - }); + const queryParams = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + company_id: companyId.toString(), + ...filters + }); + const response = await authenticatedFetch(`v1/a76/doda?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch); - const response = await authenticatedFetch( - `v1/a76/doda?${queryParams.toString()}`, - { method: 'GET' }, - cookies, - fetch - ); + if (!response.ok) { + let errorMsg = 'Failed to load'; + try { + const errorData = await response.json(); + errorMsg = errorData.detail || errorData.message || errorMsg; + } catch (e) { + // Ignore json parsing error + } + return { + error: errorMsg, + status: response.status, + dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 }, + defaultLastUser + }; + } - if (!response.ok) { - return { - error: 'Failed to load', - dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 } - }; - } - - return { dodas: await response.json() }; - } catch (error) { - console.error('Error loading DODAs:', error); - return { error: 'Error loading', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } }; - } + const dodasData = await response.json(); + return { dodas: dodasData, status: 200, defaultLastUser }; + } catch (error: any) { + console.error('Error loading DODAs:', error); + return { + error: error.message || 'Error loading', + status: error.status || 500, + dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }, + defaultLastUser + }; + } }; diff --git a/frontend/src/routes/dashboard/despacho/doda/+page.svelte b/frontend/src/routes/dashboard/despacho/doda/+page.svelte index b5b9126e..0aa358f9 100644 --- a/frontend/src/routes/dashboard/despacho/doda/+page.svelte +++ b/frontend/src/routes/dashboard/despacho/doda/+page.svelte @@ -1,414 +1,357 @@ -
-
-
-

{m['sidebar.doda_alta.title']()}

-

{m['sidebar.doda_alta.subtitle']()}

-
-
- - -
-
- - - -
- {m['sidebar.doda_alta.table_title']()} -
-
- - -
- - (filters.status = v)} - > - - {filters.status || m['sidebar.doda_alta.filter_status']()} - - - Todos - PENDIENTE - GENERADO - VALIDADO - ELIMINADO - - - (filters.operation_type = v)} - > - - {filters.operation_type === 'I' - ? 'Importación' - : filters.operation_type === 'E' - ? 'Exportación' - : m['sidebar.doda_alta.filter_operation_type']()} - - - Todas - I - Importación - E - Exportación - - - -
-
-
- -
- { - selectedDodaIds = selectedDodaIds.includes(row.id) ? [] : [row.id]; - }} - onRowDoubleClick={(item) => - goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`, { noScroll: true })} - /> -
-
-
- -
- Mostrando {allDodas.length} de {dodaTotal} registros - - Filtros activos: {Object.values(filters).filter((v) => v !== '').length} -
- -
- -
-
-
- - - -