diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/mappers.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/mappers.py index 3975f19a..e83ebb44 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/mappers.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/mappers.py @@ -2,7 +2,6 @@ Mapeo fila CSV → datos para PedimentosCreate. Soporta layout Clarion (PEDIMENTO, TIPO_OPERACION, CLAVE_PEDIMENTO, etc.) y legacy (AÑO, ADUANA, PATENTE, NUMERO). """ -from datetime import datetime from decimal import Decimal, InvalidOperation from typing import Dict, Any, Optional @@ -71,8 +70,11 @@ def _row_to_pedimento_data_clarion( elif tipo == "E": data["operation_type"] = "exp" - ind_con = (row_norm.get("INDIVIDUAL_CONSOLIDADO") or "").strip().upper() or "CON" - data["pedimento_type"] = "normal" if ind_con == "IND" else "consolidated" + ind_con = (row_norm.get("INDIVIDUAL_CONSOLIDADO") or "").strip().upper() + if ind_con == "IND": + data["pedimento_type"] = "normal" + elif ind_con == "CON": + data["pedimento_type"] = "consolidated" status = (row_norm.get("ESTATUS") or "").strip() if status: @@ -87,19 +89,20 @@ def _row_to_pedimento_data_clarion( data["observations"] = obs # Fechas E, F, G → pedimento_dates + # Paridad legacy: Col.G es la fecha de referencia operativa (pago/entrada). start_str = (row_norm.get("FECHA_INICIO") or "").strip() end_str = (row_norm.get("FECHA_FINAL") or "").strip() payment_str = (row_norm.get("FECHA_PAGO") or "").strip() - base = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - start_dt = parse_date(start_str, date_format_preference) if start_str else base - end_dt = parse_date(end_str, date_format_preference) if end_str else base - payment_dt = parse_date(payment_str, date_format_preference) if payment_str else base + start_dt = parse_date(start_str, date_format_preference) if start_str else None + end_dt = parse_date(end_str, date_format_preference) if end_str else None + payment_dt = parse_date(payment_str, date_format_preference) if payment_str else None if start_dt and end_dt: + entry_dt = payment_dt or start_dt data["pedimento_dates"] = { - "entry_date": start_dt, + "entry_date": entry_dt, "end_date": end_dt, "start_date": start_dt, - "payment_date": payment_dt, + "payment_date": payment_dt or entry_dt, } return data diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py index b855d390..e18541ef 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py @@ -34,7 +34,17 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { # Col E, F, G {"canonical": "FECHA_INICIO", "aliases": ["FECHA INICIO", "FECHA INICIAL"]}, {"canonical": "FECHA_FINAL", "aliases": ["FECHA FINAL", "FECHA FIN"]}, - {"canonical": "FECHA_PAGO", "aliases": ["FECHA DE PAGO", "FECHA PAGO"]}, + { + "canonical": "FECHA_PAGO", + "aliases": [ + "FECHA DE PAGO", + "FECHA PAGO", + "FECHA_ENTRADA", + "FECHA ENTRADA", + "ENTRY_DATE", + "ENTRY DATE", + ], + }, # Col H {"canonical": "ADUANA_SECCION_CRUCE", "aliases": ["ADUANA Y SECCION DE CRUCE", "ADUANA Y SECCION CRUCE", "ADUANA", "CUSTOMS_OFFICE", "CUSTOMS OFFICE"]}, # Col I diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py index ce840f19..9df12c92 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py @@ -180,7 +180,7 @@ def validate_row_patente( def validate_row_pedimento_required_full( row: Dict[str, Any], line_num: int ) -> Optional[Dict[str, Any]]: - """Obligatorios cuando no es actualizar: TIPO (B), CLAVE_PEDIMENTO (C), REGIMEN (D), FECHA_INICIO (E), FECHA_FINAL (F), FECHA_PAGO (G), ADUANA_SECCION_CRUCE (H).""" + """Obligatorios cuando no es actualizar: TIPO (B), CLAVE_PEDIMENTO (C), REGIMEN (D), FECHA_INICIO (E), FECHA_FINAL (F), FECHA_PAGO/ENTRADA (G), ADUANA_SECCION_CRUCE (H), IND/CON (J).""" cols_missing = [] col_labels = [ ("TIPO_OPERACION", "Col.B) Tipo Operación"), @@ -188,8 +188,9 @@ def validate_row_pedimento_required_full( ("REGIMEN", "Col.D) Clave Régimen"), ("FECHA_INICIO", "Col.E) Fecha Inicio"), ("FECHA_FINAL", "Col.F) Fecha Final"), - ("FECHA_PAGO", "Col.G) Fecha de Pago"), + ("FECHA_PAGO", "Col.G) Fecha de Entrada/Referencia"), ("ADUANA_SECCION_CRUCE", "Col.H) Aduana y Sección de Cruce"), + ("INDIVIDUAL_CONSOLIDADO", "Col.J) Tipo de Pedimento (IND/CON)"), ] for key, label in col_labels: if not (row.get(key) or "").strip(): @@ -375,9 +376,77 @@ def validaciones_pedimento( if err: errors.append(err) + err = validate_row_fechas_coherencia_clarion(row, line_num, date_format_preference) + if err: + errors.append(err) + return errors +def validate_row_fechas_coherencia_clarion( + row: Dict[str, Any], + line_num: int, + date_format_preference: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """ + Reglas legacy Clarion: + - CON: inicio <= final <= fecha de referencia (pago/entrada). + - IND: si las fechas difieren, advertencia no bloqueante. + En este layout CSV la referencia operativa está en FECHA_PAGO (Col G). + """ + start_raw = (row.get("FECHA_INICIO") or "").strip() + end_raw = (row.get("FECHA_FINAL") or "").strip() + ref_raw = (row.get("FECHA_PAGO") or "").strip() + if not start_raw or not end_raw or not ref_raw: + return None + + start_dt = parse_date(start_raw, date_format_preference) + end_dt = parse_date(end_raw, date_format_preference) + ref_dt = parse_date(ref_raw, date_format_preference) + if not start_dt or not end_dt or not ref_dt: + return None + + start_date = start_dt.date() + end_date = end_dt.date() + ref_date = ref_dt.date() + + ind_con = (row.get("INDIVIDUAL_CONSOLIDADO") or "").strip().upper() + # Compatibilidad legacy: vacío se trata como consolidado. + if not ind_con: + ind_con = "CON" + + if ind_con == "CON": + if start_date > end_date: + return { + "line": line_num, + "col": "FECHA_FINAL", + "msg": "Error: La fecha inicio no puede ser mayor que la fecha final.", + } + if start_date > ref_date: + return { + "line": line_num, + "col": "FECHA_PAGO", + "msg": "Error: La fecha inicio no puede ser mayor que la fecha de referencia (Col.G).", + } + if end_date > ref_date: + return { + "line": line_num, + "col": "FECHA_PAGO", + "msg": "Error: La fecha final no puede ser mayor que la fecha de referencia (Col.G).", + } + return None + + if ind_con == "IND": + if not (start_date == end_date == ref_date): + return { + "line": line_num, + "col": "FECHA_INICIO", + "msg": "Advertencia: En pedimento individual las fechas inicio/final/referencia son distintas.", + "warning": True, + } + return None + + # --- Legacy (layout sin PEDIMENTO único): mantener para compatibilidad --- def validate_row_pedimento_required_legacy( row: Dict[str, Any], line_num: int diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_print.py b/backend/tests/unit/general_catalogs/doda/test_doda_print.py index e6604ea6..a8098ef5 100644 --- a/backend/tests/unit/general_catalogs/doda/test_doda_print.py +++ b/backend/tests/unit/general_catalogs/doda/test_doda_print.py @@ -37,7 +37,7 @@ def test_doda_fingerprint_changes_when_field_changes(db_session: Session): def _print_client(db_session: Session, test_tenant) -> TestClient: app = FastAPI() - app.include_router(doda_routes.router, prefix="/doda") + app.include_router(doda_routes.router) def _override_get_db(): yield db_session diff --git a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte index e0f4679b..13b75e2c 100644 --- a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte @@ -16,6 +16,68 @@ import * as Tabs from '$lib/components/ui/tabs'; import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte'; import { m } from '$lib/i18n/messages'; + import { tick } from 'svelte'; + import { normalizeInvoiceFieldPath } from './focus-invoice-field'; + + type DialogTabKind = 'general' | 'compliance' | 'financials'; + + let dialogTab = $state('general'); + + const DIALOG_FIELD_TAB: Record = { + operation_type: 'general', + invoice_type: 'general', + invoice_number: 'general', + invoice_date: 'general', + project_number: 'general', + purchase_order: 'general', + traffic_light_status: 'general', + cfdi_uuid: 'general', + observation_es: 'general', + observation_en: 'general', + comments_status: 'general', + aduana: 'compliance', + pedimento: 'compliance', + remesa: 'compliance', + customs_broker_id: 'compliance', + provider_id: 'compliance', + edocument: 'compliance', + sold_to_id: 'compliance', + shipped_to_id: 'compliance', + exchange_rate: 'financials', + currency: 'financials', + currency_foreign: 'financials', + value_mn: 'financials', + value_me: 'financials', + customs_value_mn: 'financials', + freight: 'financials', + insurance: 'financials', + iva_mn: 'financials', + iva_factor: 'financials' + }; + + async function focusInvoiceDialogControl(fieldRaw: string) { + const key = normalizeInvoiceFieldPath(fieldRaw); + if (!key) return; + const tab = DIALOG_FIELD_TAB[key]; + if (tab) dialogTab = tab; + await tick(); + await new Promise((r) => requestAnimationFrame(() => r())); + let el = document.querySelector(`[data-invoice-dialog-field="${key}"]`) as HTMLElement | null; + if (!el && key === 'currency_foreign') { + el = document.querySelector('[data-invoice-dialog-field="exchange_rate"]') as HTMLElement | null; + } + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); + try { + el.focus({ preventScroll: true }); + } catch { + try { + el.focus(); + } catch { + /* ignore */ + } + } + } let { open = $bindable(false), @@ -189,10 +251,12 @@ String(formData.exchange_rate).trim() === '' ) { error = m.invoice_edit_form_exchange_rate_required(); + await focusInvoiceDialogControl('exchange_rate'); return; } if (Number(formData.exchange_rate) <= 0) { error = m.invoice_edit_form_exchange_rate_positive(); + await focusInvoiceDialogControl('exchange_rate'); return; } @@ -333,10 +397,15 @@ missingExchangeRateDate = dateMatch ? dateMatch[0] : formData.invoice_date || ''; showExchangeRateDialog = true; + await focusInvoiceDialogControl('financials.exchange_rate'); return; } error = response.error; + const firstErr = response.validationErrors?.[0]?.field; + if (firstErr) { + await focusInvoiceDialogControl(firstErr); + } } return; } @@ -394,6 +463,7 @@ if (!newOpen) { resetForm(); error = null; + dialogTab = 'general'; } open = newOpen; } @@ -411,7 +481,7 @@
- + {m.invoice_edit_tabs_general()} {m.invoice_edit_tabs_compliance()} @@ -430,7 +500,7 @@ if (v) formData.operation_type = v as 'imp' | 'exp'; }} > - + {formData.operation_type === 'imp' ? m.invoice_edit_form_operation_type_import() : formData.operation_type === 'exp' @@ -447,6 +517,7 @@ @@ -456,6 +527,7 @@ @@ -481,7 +553,12 @@
- +
@@ -535,7 +612,12 @@
- +
@@ -589,6 +671,7 @@ = { + operation_type: { tab: null, elementId: 'invoice-field-operation_type' }, + invoice_type: { tab: null, elementId: 'invoice-field-invoice_type' }, + invoice_number: { tab: null, elementId: 'invoice_number' }, + invoice_date: { tab: null, elementId: 'invoice_date' }, + emission_date: { tab: null, elementId: 'emission_date' }, + invoice_id: { tab: null, elementId: 'invoice_number' }, + document_type: { tab: 'general', elementId: 'document_type' }, + aduana: { tab: 'general', elementId: 'aduana' }, + trailer_num: { tab: 'general', elementId: 'trailer_num' }, + transport_type: { tab: 'general', elementId: 'transport_type' }, + provider_id: { tab: 'general', elementId: 'provider_id' }, + provider_header: { tab: 'general', elementId: 'provider_header' }, + sold_to_header: { tab: 'general', elementId: 'sold_to_header' }, + sold_to_id: { tab: 'general', elementId: 'sold_to_id' }, + shipped_to_header: { tab: 'general', elementId: 'shipped_to_header' }, + shipped_to_id: { tab: 'general', elementId: 'shipped_to_id' }, + customs_broker_id: { tab: 'general', elementId: 'customs_broker_id' }, + pedimento: { tab: null, elementId: 'pedimento' }, + pedimento_id: { tab: null, elementId: 'pedimento' }, + remesa: { tab: null, elementId: 'remesa' }, + observation_es: { tab: 'observations', elementId: 'observation_es' }, + observation_en: { tab: 'observations', elementId: 'observation_en' }, + freight: { tab: 'observations', elementId: 'freight' }, + incoterm: { tab: 'observations', elementId: 'incoterm' }, + manifest_number: { tab: 'general', elementId: 'manifest_number' }, + carrier_id: { tab: 'general', elementId: 'carrier_id' }, + transport_id: { tab: 'general', elementId: 'transport_id' }, + driver_name: { tab: 'general', elementId: 'driver_name' }, + currency_type: { tab: 'general', elementId: 'currency_type' }, + weight_type: { tab: 'general', elementId: 'weight_type' }, + iva_factor: { tab: 'general', elementId: 'iva_factor' }, + customs_broker_us_id: { tab: 'general', elementId: 'customs_broker_us_id' }, + 'currency-foreign': { tab: 'general', elementId: 'currency-foreign' }, + exchange_rate: { tab: 'general', elementId: 'currency-foreign' }, + [ITEMS_LINE_PLACEHOLDER]: { tab: 'items', elementId: 'invoice-items-tab-root' } +}; + +export function resolveInvoiceFocusTarget(normalizedKey: string): FocusResolution | null { + if (!normalizedKey) return null; + const direct = REGISTRY[normalizedKey]; + if (direct) return direct; + return null; +} + +/** + * Switches tab when needed, scrolls to and focuses the target control. + */ +export async function focusInvoiceInvalidField( + setTab: (t: InvoiceEditTab) => void, + fieldPath: string | undefined +): Promise { + const norm = normalizeInvoiceFieldPath(fieldPath || ''); + const target = resolveInvoiceFocusTarget(norm); + if (!target) return false; + + if (target.tab) { + setTab(target.tab); + await tick(); + await new Promise((r) => requestAnimationFrame(() => r())); + } + + const el = document.getElementById(target.elementId); + if (!el) return false; + + el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); + + try { + (el as HTMLElement).focus({ preventScroll: true }); + } catch { + try { + (el as HTMLElement).focus(); + } catch { + /* non-focusable */ + } + } + + return true; +} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index 5f7a1294..013ad08f 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -6,6 +6,7 @@ import { Button } from '$lib/components/ui/button'; import { Search, Upload } from 'lucide-svelte'; import { m } from '$lib/i18n/messages'; + import { cn } from '$lib/utils'; import ManifestSelectorModal from './ManifestSelectorModal.svelte'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import type { InvoiceType } from '$lib/api/dashboard/reference_data/invoice_types'; @@ -35,7 +36,9 @@ defaultOperationType = undefined, exchangeRate = undefined, invoiceType = undefined, - isSettings = false + isSettings = false, + highlightFieldId = null, + onDismissHighlightForField = undefined }: { invoice: Invoice | null; formData?: any; @@ -57,8 +60,83 @@ exchangeRate?: number | null; invoiceType?: string; isSettings?: boolean; + highlightFieldId?: string | null; + onDismissHighlightForField?: (fieldKey: string) => void; } = $props(); + function hl(id: string) { + return cn(highlightFieldId === id && 'ring-2 ring-destructive ring-offset-2 rounded-md'); + } + + function snapshotValueForHighlight(fd: any, key: string): unknown { + if (!fd) return undefined; + if (key === 'currency-foreign') return fd.currency; + switch (key) { + case 'provider_header': + return fd.provider_header; + case 'provider_id': + return fd.provider_id; + case 'sold_to_header': + return fd.sold_to_header; + case 'sold_to_id': + return fd.sold_to_id; + case 'shipped_to_header': + return fd.shipped_to_header; + case 'shipped_to_id': + return fd.shipped_to_id; + case 'customs_broker_id': + return fd.customs_broker_id; + case 'customs_broker_us_id': + return fd.customs_broker_us_id; + case 'currency_type': + return fd.currency_type; + case 'weight_type': + return fd.weight_type; + case 'iva_factor': + return fd.iva_factor; + case 'manifest_number': + return fd.manifest_number; + case 'carrier_id': + return fd.carrier_id; + case 'transport_id': + return fd.transport_id; + case 'driver_name': + return fd.driver_name; + case 'transport_type': + return fd.transport_type; + case 'trailer_num': + return fd.trailer_num; + case 'aduana': + return fd.aduana; + case 'document_type': + return fd.document_type; + default: + return fd[key]; + } + } + + let generalHighlightSnapshot = $state<{ key: string; val: unknown } | null>(null); + + $effect(() => { + const key = highlightFieldId; + if (!key || !formData) { + generalHighlightSnapshot = null; + return; + } + const now = snapshotValueForHighlight(formData, key); + if (!generalHighlightSnapshot || generalHighlightSnapshot.key !== key) { + generalHighlightSnapshot = { key, val: now }; + return; + } + const same = + JSON.stringify(now) === JSON.stringify(generalHighlightSnapshot.val) || + String(now ?? '') === String(generalHighlightSnapshot.val ?? ''); + if (!same) { + onDismissHighlightForField?.(key); + generalHighlightSnapshot = null; + } + }); + let showManifestModal = $state(false); function handleManifestSelect(manifest: any) { @@ -382,7 +460,7 @@ formData.provider_header = v ?? ''; }} > - + {providerHeaderOptions.find( (o) => o.value === (formData.provider_header || providerHeaderOptions[0]?.value) @@ -404,7 +482,7 @@ formData.provider_id = v ? parseInt(v) : null; }} > - + {#if formData.provider_id} {providers.find((p) => p.id === formData.provider_id)?.name || m.invoice_edit_general_select_placeholder()} @@ -431,7 +509,7 @@ formData.sold_to_header = v ?? ''; }} > - + {soldToHeaderOptions.find( (o) => o.value === (formData.sold_to_header || soldToHeaderOptions[0]?.value) @@ -453,7 +531,7 @@ formData.sold_to_id = v ? parseInt(v) : null; }} > - + {#if formData.sold_to_id} {clients.find((c) => c.id === formData.sold_to_id)?.name || m.invoice_edit_general_select_placeholder()} @@ -480,7 +558,7 @@ formData.shipped_to_header = v ?? ''; }} > - + {shippedToHeaderOptions.find( (o) => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value) @@ -502,7 +580,7 @@ formData.shipped_to_id = v ? parseInt(v) : null; }} > - + {#if formData.shipped_to_id} {allClientsProviders.find((cp) => cp.id === formData.shipped_to_id)?.name || @@ -534,7 +612,10 @@ formData.customs_broker_id = v ? parseInt(v) : null; }} > - + {formData.customs_broker_id ? customsBrokers.find((cb) => cb.id === formData.customs_broker_id)?.name || @@ -561,7 +642,7 @@ formData.customs_broker_us_id = v ? parseInt(v) : null; }} > - + {formData.customs_broker_us_id ? customsBrokers.find((cb) => cb.id === formData.customs_broker_us_id)?.name || @@ -606,7 +687,11 @@
- + @@ -635,7 +720,7 @@ formData.currency_type = v ?? ''; }} > - + {formData.currency_type || '...'} @@ -660,7 +745,7 @@ formData.weight_type = v ?? 'kgs'; }} > - + {weightTypeOptions.find((w) => w.value === formData.weight_type)?.label || m.invoice_edit_general_weight_type_kgs()} @@ -685,7 +770,7 @@ step="0.0001" bind:value={formData.iva_factor} placeholder="0.16" - class="h-7 text-xs" + class={cn('h-7 text-xs', hl('iva_factor'))} />
{/if} @@ -697,7 +782,7 @@ id="manifest_number" bind:value={formData.manifest_number} placeholder="Manifiesto..." - class="h-7 flex-1 text-xs" + class={cn('h-7 flex-1 text-xs', hl('manifest_number'))} />