Merge branch 'feature/sitar-fractions-upgrade' into feature/visible-clicks-csv

This commit is contained in:
2026-04-29 08:24:39 -06:00
16 changed files with 736 additions and 63 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

@@ -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"

View File

@@ -39,16 +39,81 @@ export interface SitarALADI {
}
export async function getSitarTLCS(filters: { fraccion: string; nico?: string }): Promise<ApiResponse<SitarTLCS[]>> {
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<string, string>
)
);
return await api.get(`/v1/sitar/tlcs/?${queryParams.toString()}`);
}
export async function getSitarPROSEC(filters: { fraccion: string; nico?: string }): Promise<ApiResponse<SitarPROSEC[]>> {
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<string, string>
)
);
return await api.get(`/v1/sitar/prosec/?${queryParams.toString()}`);
}
export async function getSitarALADI(filters: { fraccion: string; nico?: string }): Promise<ApiResponse<SitarALADI[]>> {
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<string, string>
)
);
return await api.get(`/v1/sitar/aladi2/?${queryParams.toString()}`);
}
export type SitarGenericRecord = Record<string, string | number | boolean | null>;
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<ApiResponse<SitarGenericRecord[]>> {
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<string, string>
)
);
return await api.get(`/v1/sitar/${endpoint}/?${queryParams.toString()}`);
}

View File

@@ -1041,8 +1041,8 @@
<table class="w-full">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Código</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Prefijo</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Clave</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Fracción</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Ad valorem</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Costo Fijo</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Descripción</th>
@@ -1054,8 +1054,8 @@
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => selectUSFraction(fraction)}
>
<td class="px-4 py-2 whitespace-nowrap">{fraction.fraction || fraction.code}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.code || '—'}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.fraction || '—'}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.adv_impo ?? '—'}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.adv_expo ?? '—'}</td>
<td class="px-4 py-2">{fraction.description || ''}</td>

View File

@@ -0,0 +1,434 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs';
import { ClipboardList, FileText, Globe, Layers, ShieldCheck } from 'lucide-svelte';
import type { TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions';
import {
getSitarALADI,
getSitarPROSEC,
getSitarTLCS,
getSitarDataset,
type SitarALADI,
type SitarPROSEC,
type SitarTLCS,
type SitarGenericRecord
} from '$lib/api/dashboard/a76/sitar';
import {
buildMexTariffDigitsFromCatalogRow,
formatMexTariffDigitsForDisplay,
splitMexTariffDigitsForSitar
} from '$lib/utils/mexican-tariff-fraction';
type TabKey = 'descripcion' | 'tlcs' | 'prosec' | 'aladi' | 'immex' | 'acuerdos';
type NonDescTabKey = Exclude<TabKey, 'descripcion'>;
let { selectedFraction, catalog = 'mex' }: {
selectedFraction: TariffFraction | null;
catalog?: string;
} = $props();
let activeTab = $state<TabKey>('descripcion');
let isResolvingTabs = $state(false);
let tlcsCache = $state<Record<string, SitarTLCS[]>>({});
let prosecCache = $state<Record<string, SitarPROSEC[]>>({});
let aladiCache = $state<Record<string, SitarALADI[]>>({});
let immexCache = $state<Record<string, SitarGenericRecord[]>>({});
let acuerdosCache = $state<Record<string, SitarGenericRecord[]>>({});
let sitarTLCSData = $state<SitarTLCS[]>([]);
let sitarPROSECData = $state<SitarPROSEC[]>([]);
let sitarALADIData = $state<SitarALADI[]>([]);
let sitarIMMEXData = $state<SitarGenericRecord[]>([]);
let sitarAcuerdosData = $state<SitarGenericRecord[]>([]);
let activeError = $state('');
// All non-description tabs start blocked; resolveTabAvailability updates them after selection.
let noDataTabs = $state<Record<TabKey, boolean>>({
descripcion: false,
tlcs: true,
prosec: true,
aladi: true,
immex: true,
acuerdos: true
});
// Monotonically incrementing token to discard stale async responses on rapid row changes.
let resolveToken = 0;
const fractionDigits = $derived(
catalog === 'mex' && selectedFraction
? splitMexTariffDigitsForSitar(buildMexTariffDigitsFromCatalogRow(selectedFraction))
: null
);
const hasSelection = $derived(!!selectedFraction);
const headerFraction = $derived(
selectedFraction
? catalog === 'mex' && fractionDigits
? formatMexTariffDigitsForDisplay(buildMexTariffDigitsFromCatalogRow(selectedFraction))
: selectedFraction.fraction || selectedFraction.code || ''
: ''
);
function buildCacheKey(
fracDigits: NonNullable<typeof fractionDigits>,
frac: TariffFraction,
tab: NonDescTabKey
): string {
return `${fracDigits.fraccion8}:${frac.nico || fracDigits.nico2 || ''}:${tab}`;
}
// Hydrates display state from cache when the user navigates to a tab.
// By the time a tab is enabled, its data is already in cache from resolveTabAvailability.
function hydrateTabFromCache(tab: NonDescTabKey) {
if (!fractionDigits || !selectedFraction) return;
const key = buildCacheKey(fractionDigits, selectedFraction, tab);
if (tab === 'tlcs') sitarTLCSData = tlcsCache[key] ?? [];
else if (tab === 'prosec') sitarPROSECData = prosecCache[key] ?? [];
else if (tab === 'aladi') sitarALADIData = aladiCache[key] ?? [];
else if (tab === 'immex') sitarIMMEXData = immexCache[key] ?? [];
else if (tab === 'acuerdos') sitarAcuerdosData = acuerdosCache[key] ?? [];
}
// Fires all 5 tab queries in parallel after a fraction is selected, updates noDataTabs
// and populates caches so tab navigation is instant and never re-queries.
async function resolveTabAvailability(
token: number,
fracDigits: NonNullable<typeof fractionDigits>,
frac: TariffFraction
) {
const filters = { fraccion: fracDigits.fraccion8 };
const [tlcsRes, prosecRes, aladiRes, immexRes, acuerdosRes] = await Promise.allSettled([
getSitarTLCS(filters),
getSitarPROSEC(filters),
getSitarALADI(filters),
getSitarDataset('reit', filters),
getSitarDataset('requisito-previo', filters)
]);
// Discard results if the fraction changed while waiting
if (resolveToken !== token) return;
const tlcsRows = tlcsRes.status === 'fulfilled' ? ((tlcsRes.value.data ?? []) as SitarTLCS[]) : [];
const prosecRows = prosecRes.status === 'fulfilled' ? ((prosecRes.value.data ?? []) as SitarPROSEC[]) : [];
const aladiRows = aladiRes.status === 'fulfilled' ? ((aladiRes.value.data ?? []) as SitarALADI[]) : [];
const immexRows = immexRes.status === 'fulfilled' ? (immexRes.value.data ?? []) : [];
const acuerdosRows = acuerdosRes.status === 'fulfilled' ? (acuerdosRes.value.data ?? []) : [];
tlcsCache = { ...tlcsCache, [buildCacheKey(fracDigits, frac, 'tlcs')]: tlcsRows };
prosecCache = { ...prosecCache, [buildCacheKey(fracDigits, frac, 'prosec')]: prosecRows };
aladiCache = { ...aladiCache, [buildCacheKey(fracDigits, frac, 'aladi')]: aladiRows };
immexCache = { ...immexCache, [buildCacheKey(fracDigits, frac, 'immex')]: immexRows };
acuerdosCache = { ...acuerdosCache, [buildCacheKey(fracDigits, frac, 'acuerdos')]: acuerdosRows };
noDataTabs = {
descripcion: false,
tlcs: tlcsRows.length === 0,
prosec: prosecRows.length === 0,
aladi: aladiRows.length === 0,
immex: immexRows.length === 0,
acuerdos: acuerdosRows.length === 0
};
isResolvingTabs = false;
}
$effect(() => {
const frac = selectedFraction;
const fracDigits = fractionDigits;
activeTab = 'descripcion';
sitarTLCSData = [];
sitarPROSECData = [];
sitarALADIData = [];
sitarIMMEXData = [];
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 };
return;
}
// Block all non-description tabs while resolving availability for the new selection
noDataTabs = { descripcion: false, tlcs: true, prosec: true, aladi: true, immex: true, acuerdos: true };
isResolvingTabs = true;
const token = ++resolveToken;
void resolveTabAvailability(token, fracDigits, frac);
});
$effect(() => {
if (!selectedFraction || activeTab === 'descripcion') return;
hydrateTabFromCache(activeTab);
});
</script>
<div class="flex h-full flex-col gap-4 overflow-hidden">
<div class="overflow-hidden rounded-xl border bg-card shadow-sm">
<div class="border-b bg-muted/50 p-3">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider uppercase">
<ClipboardList class="h-4 w-4" /> Información Arancelaria (Solo Consulta)
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="border-b bg-muted/30">
<tr>
<th class="px-4 py-2 text-left font-bold">Fracción</th>
<th class="px-4 py-2 text-center font-bold">UMT</th>
<th class="px-4 py-2 text-center font-bold">UM</th>
<th class="px-4 py-2 text-center font-bold">Advalorem Impo</th>
<th class="px-4 py-2 text-center font-bold">Advalorem Expo</th>
</tr>
</thead>
<tbody class="divide-y">
<tr>
<td class="px-4 py-3 font-mono text-sm font-bold">
{headerFraction || '-'}
</td>
<td class="px-4 py-3 text-center">{selectedFraction?.umt || '-'}</td>
<td class="px-4 py-3 text-center">{selectedFraction?.um_code || '-'}</td>
<td class="px-4 py-3 text-center font-bold text-blue-600">{selectedFraction?.adv_impo || '-'}</td>
<td class="px-4 py-3 text-center font-bold text-orange-600">{selectedFraction?.adv_expo || '-'}</td>
</tr>
</tbody>
</table>
</div>
</div>
{#if !hasSelection}
<div class="flex-1 rounded-xl border border-dashed bg-muted/10 p-4 text-xs text-muted-foreground">
{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.'}
</div>
{:else if catalog !== 'mex'}
<div class="flex-1 min-h-0 overflow-hidden rounded-xl border bg-card">
<div class="flex items-center justify-between border-b bg-muted/30 p-3">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
<FileText class="h-4 w-4" /> Descripción de la Fracción
</h3>
</div>
<div class="p-4 text-sm leading-relaxed font-medium whitespace-pre-wrap text-slate-600 dark:text-slate-400">
{selectedFraction?.description || 'No hay descripción disponible para esta fracción.'}
</div>
</div>
{:else}
<div class="flex-1 min-h-0">
<Tabs.Root value={activeTab} onValueChange={(v) => (activeTab = v as TabKey)} class="h-full w-full flex flex-col">
<Tabs.List class="mb-2 grid w-full grid-cols-6">
<Tabs.Trigger value="descripcion" class="text-[10px] font-bold uppercase">Descripción</Tabs.Trigger>
<Tabs.Trigger value="tlcs" class="text-[10px] font-bold uppercase" disabled={noDataTabs.tlcs || isResolvingTabs}>TLCS</Tabs.Trigger>
<Tabs.Trigger value="prosec" class="text-[10px] font-bold uppercase" disabled={noDataTabs.prosec || isResolvingTabs}>PROSEC</Tabs.Trigger>
<Tabs.Trigger value="aladi" class="text-[10px] font-bold uppercase" disabled={noDataTabs.aladi || isResolvingTabs}>ALADI</Tabs.Trigger>
<Tabs.Trigger value="immex" class="text-[10px] font-bold uppercase" disabled={noDataTabs.immex || isResolvingTabs}>IMMEX</Tabs.Trigger>
<Tabs.Trigger value="acuerdos" class="text-[10px] font-bold uppercase" disabled={noDataTabs.acuerdos || isResolvingTabs}>ACUERDOS</Tabs.Trigger>
</Tabs.List>
{#if activeError}
<div class="mb-2 rounded-md border border-destructive p-2 text-xs text-destructive">{activeError}</div>
{/if}
<Tabs.Content value="descripcion" class="min-h-0 flex-1">
<div class="min-h-[120px] rounded-xl border bg-slate-50/50 p-4 dark:bg-slate-900/10">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
<FileText class="h-4 w-4" /> Descripción de la Fracción
</h3>
<div class="mt-2 text-sm leading-relaxed font-medium whitespace-pre-wrap text-slate-600 dark:text-slate-400">
{selectedFraction?.description || 'No hay descripción disponible para esta fracción.'}
</div>
</div>
</Tabs.Content>
<Tabs.Content value="tlcs" class="min-h-0 flex-1">
<div class="min-h-[120px] h-full overflow-hidden rounded-xl border bg-card">
<div class="flex items-center justify-between border-b bg-muted/30 p-3">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
<ShieldCheck class="h-4 w-4" /> Información TLCS
</h3>
</div>
<div class="h-full overflow-y-auto overflow-x-auto">
<table class="w-full text-xs">
<thead class="border-b bg-muted/10">
<tr>
<th class="px-4 py-2 text-left font-bold">País</th>
<th class="px-4 py-2 text-center font-bold">Tasa</th>
<th class="px-4 py-2 text-center font-bold">D.O.F</th>
<th class="px-4 py-2 text-left font-bold">Notas</th>
</tr>
</thead>
<tbody class="divide-y">
{#each sitarTLCSData as item}
<tr>
<td class="px-4 py-2 font-bold">{item.PAIS}</td>
<td class="px-4 py-2 text-center font-mono text-blue-600">{item.TASATXT}</td>
<td class="px-4 py-2 text-center font-mono">{item.DOF || '-'}</td>
<td class="max-w-xs truncate px-4 py-2 text-left text-[10px]" title={item.NOTA || ''}>{item.NOTA || '-'}</td>
</tr>
{:else}
<tr><td colspan="4" class="px-4 py-8 text-center text-muted-foreground italic">No hay información de TLCS disponible para esta fracción.</td></tr>
{/each}
</tbody>
</table>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="prosec" class="min-h-0 flex-1">
<div class="min-h-[120px] h-full overflow-hidden rounded-xl border bg-card">
<div class="flex items-center justify-between border-b bg-muted/30 p-3">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
<Layers class="h-4 w-4" /> Programa PROSEC
</h3>
</div>
<div class="h-full overflow-y-auto overflow-x-auto">
<table class="w-full text-xs">
<thead class="border-b bg-muted/10">
<tr>
<th class="px-4 py-2 text-left font-bold">Artículo</th>
<th class="px-4 py-2 text-center font-bold">Sector</th>
<th class="px-4 py-2 text-center font-bold">Tasa Txt</th>
<th class="px-4 py-2 text-center font-bold">D.O.F</th>
</tr>
</thead>
<tbody class="divide-y">
{#each sitarPROSECData as item}
<tr>
<td class="max-w-xs truncate px-4 py-2 text-left font-medium" title={item.PRODUCTO}>{item.PRODUCTO}</td>
<td class="px-4 py-2 text-center font-mono">{item.SECTOR}</td>
<td class="px-4 py-2 text-center font-mono font-bold text-orange-600">{item.TASA}</td>
<td class="px-4 py-2 text-center font-mono">{item.DOF || '-'}</td>
</tr>
{:else}
<tr><td colspan="4" class="px-4 py-8 text-center text-muted-foreground italic">No hay información de PROSEC disponible para esta fracción.</td></tr>
{/each}
</tbody>
</table>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="aladi" class="min-h-0 flex-1">
<div class="min-h-[120px] h-full overflow-hidden rounded-xl border bg-card">
<div class="flex items-center justify-between border-b bg-muted/30 p-3">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
<Globe class="h-4 w-4" /> Acuerdo ALADI
</h3>
</div>
<div class="h-full overflow-y-auto overflow-x-auto">
<table class="w-full text-xs">
<thead class="border-b bg-muted/10">
<tr>
<th class="px-4 py-2 text-left font-bold">Acuerdo</th>
<th class="px-4 py-2 text-center font-bold">País</th>
<th class="px-4 py-2 text-center font-bold">Tasa</th>
<th class="px-4 py-2 text-center font-bold">D.O.F</th>
</tr>
</thead>
<tbody class="divide-y">
{#each sitarALADIData as item}
<tr>
<td class="px-4 py-2 text-left font-medium">{item.ACUERDO}</td>
<td class="px-4 py-2 text-center font-bold">{item.PAIS}</td>
<td class="px-4 py-2 text-center font-mono text-green-600">{item.TASATXT}</td>
<td class="px-4 py-2 text-center font-mono">{item.DOF || '-'}</td>
</tr>
{:else}
<tr><td colspan="4" class="px-4 py-8 text-center text-muted-foreground italic">No hay información de ALADI disponible para esta fracción.</td></tr>
{/each}
</tbody>
</table>
</div>
</div>
</Tabs.Content>
<!-- IMMEX / REIT -->
<Tabs.Content value="immex" class="min-h-0 flex-1">
<div class="min-h-[120px] h-full overflow-hidden rounded-xl border bg-card">
<div class="flex items-center justify-between border-b bg-muted/30 p-3">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
IMMEX / REIT
</h3>
</div>
<div class="h-full overflow-y-auto overflow-x-auto">
<table class="w-full text-xs">
<thead class="border-b bg-muted/10">
<tr>
<th class="px-4 py-2 text-left font-bold">Artículo</th>
<th class="px-4 py-2 text-left font-bold">Fundamento</th>
<th class="px-4 py-2 text-left font-bold">Acuerdo</th>
<th class="px-4 py-2 text-center font-bold">Permiso</th>
<th class="px-4 py-2 text-center font-bold">D.O.F</th>
</tr>
</thead>
<tbody class="divide-y">
{#each sitarIMMEXData as item}
<tr>
<td class="px-4 py-2 text-left">{item.ARTICULO || ''}</td>
<td class="px-4 py-2 text-left">{item.FUNDAMENTO || ''}</td>
<td class="px-4 py-2 text-left">{item.ACUERDO || ''}</td>
<td class="px-4 py-2 text-center font-mono">{item.PERMISO || '-'}</td>
<td class="px-4 py-2 text-center font-mono">{item.DOF || '-'}</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground italic">
No hay información de IMMEX disponible para esta fracción.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</Tabs.Content>
<!-- ACUERDOS / Requisito previo -->
<Tabs.Content value="acuerdos" class="min-h-0 flex-1">
<div class="min-h-[120px] h-full overflow-hidden rounded-xl border bg-card">
<div class="flex items-center justify-between border-b bg-muted/30 p-3">
<h3 class="flex items-center gap-2 text-xs font-bold tracking-wider text-muted-foreground uppercase">
Acuerdos / Requisitos previos
</h3>
</div>
<div class="h-full overflow-y-auto overflow-x-auto">
<table class="w-full text-xs">
<thead class="border-b bg-muted/10">
<tr>
<th class="px-4 py-2 text-left font-bold">Descripción</th>
<th class="px-4 py-2 text-center font-bold">Permiso</th>
<th class="px-4 py-2 text-center font-bold">D.O.F</th>
<th class="px-4 py-2 text-center font-bold">Vigencia</th>
</tr>
</thead>
<tbody class="divide-y">
{#each sitarAcuerdosData as item}
<tr>
<td class="px-4 py-2 text-left">{item.DESCRIPCION || ''}</td>
<td class="px-4 py-2 text-center font-mono">{item.PERMISO || '-'}</td>
<td class="px-4 py-2 text-center font-mono">{item.DOF || '-'}</td>
<td class="px-4 py-2 text-center font-mono">{item.VIGENCIA || '-'}</td>
</tr>
{:else}
<tr>
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground italic">
No hay información de ACUERDOS disponible para esta fracción.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</Tabs.Content>
</Tabs.Root>
</div>
{/if}
</div>

View File

@@ -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<TariffFraction[]>([]);
@@ -74,6 +81,7 @@
let isFormDialogOpen = $state(false);
let selectedFraction = $state<TariffFraction | null>(null);
let selectedDetailFraction = $state<TariffFraction | null>(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) return;
selectedDetailFraction = fraction;
}
function confirmDelete(fraction: TariffFraction) {
fractionToDelete = fraction;
showDeleteConfirm = true;
@@ -244,7 +262,8 @@
</div>
{/if}
<Card.Root class="border bg-background flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-12">
<Card.Root class="border bg-background flex min-h-0 flex-1 flex-col overflow-hidden xl:col-span-8">
<Card.Header>
<div class="flex flex-wrap items-center justify-end gap-3">
<div class="flex flex-wrap items-center gap-2">
@@ -289,9 +308,12 @@
</TableRow>
{:else}
{#each fractions as fraction (fraction.id)}
<TableRow class="catalog-table-row">
<TableCell class="font-mono">{fraction.um_code || fraction.code}</TableCell>
<TableCell class="font-medium">{fraction.fraction}</TableCell>
<TableRow
class="catalog-table-row {selectedDetailFraction?.id === fraction.id ? 'bg-muted/40' : ''}"
onclick={() => selectFractionDetail(fraction)}
>
<TableCell class="font-mono">{getTariffFractionDisplayKey(fraction)}</TableCell>
<TableCell class="font-medium">{getTariffFractionDisplayFraction(fraction)}</TableCell>
<TableCell class="max-w-md truncate" title={fraction.description}>
{fraction.description}
</TableCell>
@@ -346,6 +368,12 @@
</div>
</Card.Content>
</Card.Root>
{#if showSitarTabsOnSelect}
<div class="min-h-0 xl:col-span-4 flex flex-col overflow-hidden">
<SitarFractionTabs selectedFraction={selectedDetailFraction} {catalog} />
</div>
{/if}
</div>
<div class="flex-none text-sm text-muted-foreground">Mostrando {fractions.length} de {totalFractions} registros</div>
{/if}

View File

@@ -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;
}}
>
<td class="px-4 py-2 font-mono whitespace-nowrap">{fraction.um_code}</td>
<td class="px-4 py-2 font-mono whitespace-nowrap">{getTariffFractionDisplayKey(fraction)}</td>
<td class="px-4 py-2 whitespace-nowrap font-mono"
>{formatMexTariffDigitsForDisplay(
buildMexTariffDigitsFromCatalogRow(fraction)

View File

@@ -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 @@
<div class="flex items-center gap-2">
<Hash class="h-3 w-3 text-blue-500" />
<span class="font-mono font-bold text-blue-600 dark:text-blue-400">
{item.fraction || item.code}
{getTariffFractionDisplayKey(item)}
</span>
</div>
</Table.Cell>

View File

@@ -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>): 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');
});
});

View File

@@ -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 || '-';
}

View File

@@ -9,4 +9,5 @@
levelFilter={5}
readOnly={true}
basePerm="frac_sitar_7"
showSitarTabsOnSelect={true}
/>

View File

@@ -6,6 +6,7 @@
<TariffFractionList
title={m['sidebar.fractions.sitar']()}
catalog="mex"
levelFilter={-1}
levelFilter={5}
readOnly={true}
showSitarTabsOnSelect={true}
/>

View File

@@ -3,4 +3,4 @@
import { m } from '$lib/i18n/messages';
</script>
<TariffFractionList title={m['sidebar.fractions.sitar_us']()} catalog="usa" readOnly={true} />
<TariffFractionList title={m['sidebar.fractions.sitar_us']()} catalog="usa" readOnly={true} showSitarTabsOnSelect={true} />