Merge pull request 'fix/tipo_cambio' (#170) from fix/tipo_cambio into development

Reviewed-on: ADUANASOFT/anexo76#170
This commit is contained in:
2026-02-26 19:23:19 +00:00
3 changed files with 201 additions and 206 deletions

View File

@@ -166,7 +166,7 @@
<Dialog.Overlay class="fixed inset-0 z-[9999] {overlayClass}" />
<Dialog.Content
class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[480px] translate-x-[-50%] translate-y-[-50%] overflow-hidden border-0 bg-transparent p-0 shadow-2xl sm:rounded-xl"
class="fixed top-[50%] left-[50%] z-[10000] w-full max-w-[480px] translate-x-[-50%] translate-y-[-50%] overflow-hidden border-0 bg-transparent p-0 shadow-2xl sm:rounded-xl"
>
<div
class="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-background"
@@ -181,7 +181,7 @@
<AlertCircle class="text-amber-600 dark:text-amber-400" size={24} />
</div>
<div class="space-y-1">
<h3 class="text-lg font-semibold leading-tight text-amber-900 dark:text-amber-100">
<h3 class="text-lg leading-tight font-semibold text-amber-900 dark:text-amber-100">
{title}
</h3>
<p class="text-sm leading-relaxed text-amber-800/80 dark:text-amber-200/80">
@@ -232,7 +232,7 @@
/>
{#if isMissingRateContext}
<div
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-600"
class="absolute top-1/2 right-3 -translate-y-1/2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-600"
>
Requerida
</div>
@@ -241,28 +241,12 @@
</div>
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="value" class="ml-1 text-sm font-medium text-muted-foreground"
>Tipo de Cambio (MXN/USD)</Label
>
<Button
type="button"
variant="ghost"
size="sm"
class="-mr-2 h-6 px-2 text-xs text-primary hover:text-primary/80"
disabled={scraping || loading || !formData.date}
onclick={() => fetchFromDof(formData.date)}
>
{#if scraping}
<span class="mr-1.5 animate-spin"></span> Consultando...
{:else}
<CloudDownload size={14} class="mr-1.5" /> Consultar DOF
{/if}
</Button>
</div>
<Label for="value" class="ml-1 text-sm font-medium text-muted-foreground"
>Tipo de Cambio (MXN/USD)</Label
>
<div class="group relative">
<div
class="absolute left-3 top-1/2 -translate-y-1/2 font-semibold text-muted-foreground"
class="absolute top-1/2 left-3 -translate-y-1/2 font-semibold text-muted-foreground"
>
$
</div>
@@ -270,6 +254,7 @@
id="value"
type="number"
step="0.0001"
lang="en"
bind:value={formData.value}
disabled={loading}
required
@@ -278,18 +263,30 @@
autofocus
/>
</div>
<p class="px-1 text-right text-[11px] text-muted-foreground">Ej. 24.1234</p>
<div class="mt-1 flex items-start justify-between">
<Button
type="button"
variant="secondary"
size="sm"
class="h-8 font-medium shadow-sm transition-all"
disabled={scraping || loading || !formData.date}
onclick={() => fetchFromDof(formData.date)}
>
{#if scraping}
<span class="mr-2 animate-spin"></span> Consultando...
{:else}
<CloudDownload size={15} class="mr-2" /> Consultar DOF
{/if}
</Button>
<p class="mt-2 px-1 text-right text-[11px] text-muted-foreground">Ej. 24.1234</p>
</div>
</div>
</div>
<Dialog.Footer class="grid grid-cols-4 gap-2 pt-2">
<Button type="button" variant="outline" class="w-full text-xs" disabled>
Cargar TC
</Button>
<Button type="button" variant="outline" class="w-full text-xs" disabled>Ayuda</Button>
<Dialog.Footer class="mt-4 grid grid-cols-2 gap-3 pt-2">
<Button
type="button"
variant="ghost"
variant="outline"
onclick={() => (open = false)}
disabled={loading}
class="w-full hover:bg-muted/50"

View File

@@ -1,15 +1,20 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { invoicesApi, type Invoice, type CreateInvoiceData, type UpdateInvoiceData } from "$lib/api/dashboard/a76/invoices";
import { getExchangeRates } from "$lib/api/dashboard/a76/general_catalogs/exchange-rate";
import { companyStore } from "$lib/stores/company.svelte";
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import {
invoicesApi,
type Invoice,
type CreateInvoiceData,
type UpdateInvoiceData
} from '$lib/api/dashboard/a76/invoices';
import { getExchangeRates } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
import { companyStore } from '$lib/stores/company.svelte';
import { LoaderCircle } from 'lucide-svelte';
import * as Tabs from "$lib/components/ui/tabs";
import ExchangeRateDialog from "$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte";
import * as Tabs from '$lib/components/ui/tabs';
import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte';
let {
open = $bindable(false),
@@ -27,34 +32,34 @@
let formData = $state({
// Header fields
operation_type: "imp" as "imp" | "exp",
invoice_type: "",
invoice_number: "",
project_number: "",
purchase_order: "",
operation_type: 'imp' as 'imp' | 'exp',
invoice_type: '',
invoice_number: '',
project_number: '',
purchase_order: '',
related_doc_id: null as number | null,
invoice_date: "",
traffic_light_status: "",
observation_es: "",
observation_en: "",
comments_status: "",
cfdi_uuid: "",
path_pdf: "",
path_xml: "",
invoice_date: '',
traffic_light_status: '',
observation_es: '',
observation_en: '',
comments_status: '',
cfdi_uuid: '',
path_pdf: '',
path_xml: '',
// Compliance MX fields
remesa: null as number | null,
aduana: "",
customs_broker_id: "",
provider_id: "",
sold_to_id: "",
shipped_to_id: "",
shipped_by_id: "",
aduana: '',
customs_broker_id: '',
provider_id: '',
sold_to_id: '',
shipped_to_id: '',
shipped_by_id: '',
is_mixed: false,
waste_type: "",
waste_type: '',
appendix_17: null as number | null,
edocument: "",
edocument: '',
// Financials fields
currency: "MXN",
currency: 'MXN',
exchange_rate: null as number | null,
value_mn: null as number | null,
value_me: null as number | null,
@@ -73,38 +78,38 @@
let error = $state<string | null>(null);
let showExchangeRateDialog = $state(false);
let missingExchangeRateDate = $state("");
let missingExchangeRateDate = $state('');
// Actualizar formData cuando item cambia
$effect(() => {
if (item) {
formData = {
operation_type: item.operation_type || "imp",
invoice_type: item.invoice_type || "",
invoice_number: item.invoice_number || "",
project_number: item.project_number || "",
purchase_order: item.purchase_order || "",
operation_type: item.operation_type || 'imp',
invoice_type: item.invoice_type || '',
invoice_number: item.invoice_number || '',
project_number: item.project_number || '',
purchase_order: item.purchase_order || '',
related_doc_id: item.related_doc_id || null,
invoice_date: item.invoice_date || "",
traffic_light_status: item.traffic_light_status || "",
observation_es: item.observation_es || "",
observation_en: item.observation_en || "",
comments_status: item.comments_status || "",
cfdi_uuid: item.cfdi_uuid || "",
path_pdf: item.path_pdf || "",
path_xml: item.path_xml || "",
invoice_date: item.invoice_date || '',
traffic_light_status: item.traffic_light_status || '',
observation_es: item.observation_es || '',
observation_en: item.observation_en || '',
comments_status: item.comments_status || '',
cfdi_uuid: item.cfdi_uuid || '',
path_pdf: item.path_pdf || '',
path_xml: item.path_xml || '',
remesa: item.compliance_mx?.remesa || null,
aduana: item.compliance_mx?.aduana || "",
customs_broker_id: item.compliance_mx?.customs_broker_id?.toString() || "",
provider_id: item.compliance_mx?.provider_id?.toString() || "",
sold_to_id: item.compliance_mx?.sold_to_id?.toString() || "",
shipped_to_id: item.compliance_mx?.shipped_to_id?.toString() || "",
shipped_by_id: item.compliance_mx?.shipped_by_id?.toString() || "",
aduana: item.compliance_mx?.aduana || '',
customs_broker_id: item.compliance_mx?.customs_broker_id?.toString() || '',
provider_id: item.compliance_mx?.provider_id?.toString() || '',
sold_to_id: item.compliance_mx?.sold_to_id?.toString() || '',
shipped_to_id: item.compliance_mx?.shipped_to_id?.toString() || '',
shipped_by_id: item.compliance_mx?.shipped_by_id?.toString() || '',
is_mixed: item.compliance_mx?.is_mixed || false,
waste_type: item.compliance_mx?.waste_type || "",
waste_type: item.compliance_mx?.waste_type || '',
appendix_17: item.compliance_mx?.appendix_17 || null,
edocument: item.compliance_mx?.edocument || "",
currency: item.financials?.currency || "MXN",
edocument: item.compliance_mx?.edocument || '',
currency: item.financials?.currency || 'MXN',
exchange_rate: item.financials?.exchange_rate || null,
value_mn: item.financials?.value_mn || null,
value_me: item.financials?.value_me || null,
@@ -127,32 +132,32 @@
function resetForm() {
formData = {
operation_type: defaultOperationType || "imp",
invoice_type: defaultInvoiceType || "",
invoice_number: "",
project_number: "",
purchase_order: "",
operation_type: defaultOperationType || 'imp',
invoice_type: defaultInvoiceType || '',
invoice_number: '',
project_number: '',
purchase_order: '',
related_doc_id: null,
invoice_date: "",
traffic_light_status: "",
observation_es: "",
observation_en: "",
comments_status: "",
cfdi_uuid: "",
path_pdf: "",
path_xml: "",
invoice_date: '',
traffic_light_status: '',
observation_es: '',
observation_en: '',
comments_status: '',
cfdi_uuid: '',
path_pdf: '',
path_xml: '',
remesa: null,
aduana: "",
customs_broker_id: "",
provider_id: "",
sold_to_id: "",
shipped_to_id: "",
shipped_by_id: "",
aduana: '',
customs_broker_id: '',
provider_id: '',
sold_to_id: '',
shipped_to_id: '',
shipped_by_id: '',
is_mixed: false,
waste_type: "",
waste_type: '',
appendix_17: null,
edocument: "",
currency: "MXN",
edocument: '',
currency: 'MXN',
exchange_rate: null,
value_mn: null,
value_me: null,
@@ -170,9 +175,9 @@
async function handleSubmit(e: Event) {
e.preventDefault();
if (!companyStore.activeCompany) {
error = "No hay compañía seleccionada";
error = 'No hay compañía seleccionada';
return;
}
@@ -180,14 +185,14 @@
error = null;
try {
// Verificar tipo de cambio antes de guardar
if (formData.invoice_date) {
const rateExists = await checkExchangeRate(formData.invoice_date);
if (!rateExists) {
loading = false;
return;
}
}
// Verificar tipo de cambio antes de guardar
if (formData.invoice_date) {
const rateExists = await checkExchangeRate(formData.invoice_date);
if (!rateExists) {
loading = false;
return;
}
}
let response;
if (isEditing && item) {
@@ -210,7 +215,9 @@
compliance_mx: {
remesa: formData.remesa,
aduana: formData.aduana || null,
customs_broker_id: formData.customs_broker_id ? parseInt(formData.customs_broker_id) : null,
customs_broker_id: formData.customs_broker_id
? parseInt(formData.customs_broker_id)
: null,
provider_id: formData.provider_id ? parseInt(formData.provider_id) : null,
sold_to_id: formData.sold_to_id ? parseInt(formData.sold_to_id) : null,
shipped_to_id: formData.shipped_to_id ? parseInt(formData.shipped_to_id) : null,
@@ -238,9 +245,9 @@
};
response = await invoicesApi.update(item.id, companyStore.activeCompany.id, payload);
} else {
const payload: CreateInvoiceData = {
system: "a76",
document_type: "invoice",
const payload: CreateInvoiceData = {
system: 'a76',
document_type: 'invoice',
operation_type: formData.operation_type,
invoice_type: formData.invoice_type,
invoice_number: formData.invoice_number,
@@ -258,7 +265,9 @@
compliance_mx: {
remesa: formData.remesa,
aduana: formData.aduana || null,
customs_broker_id: formData.customs_broker_id ? parseInt(formData.customs_broker_id) : null,
customs_broker_id: formData.customs_broker_id
? parseInt(formData.customs_broker_id)
: null,
provider_id: formData.provider_id ? parseInt(formData.provider_id) : null,
sold_to_id: formData.sold_to_id ? parseInt(formData.sold_to_id) : null,
shipped_to_id: formData.shipped_to_id ? parseInt(formData.shipped_to_id) : null,
@@ -294,21 +303,23 @@
window.location.reload();
}, 1500);
} else {
// Convertir el error a string para buscar mensajes específicos (maneja objetos/arrays de DRF)
const errorStr = typeof response.error === 'string'
? response.error
: JSON.stringify(response.error);
if (errorStr.includes('No existe un Tipo de Cambio registrado') || errorStr.includes('financials.exchange_rate')) {
// Interceptar error de tipo de cambio
error = null;
const dateMatch = errorStr.match(/(\d{4}-\d{2}-\d{2})/);
missingExchangeRateDate = dateMatch ? dateMatch[0] : (formData.invoice_date || "");
showExchangeRateDialog = true;
return;
}
// Convertir el error a string para buscar mensajes específicos (maneja objetos/arrays de DRF)
const errorStr =
typeof response.error === 'string' ? response.error : JSON.stringify(response.error);
if (
errorStr.includes('No existe un Tipo de Cambio registrado') ||
errorStr.includes('financials.exchange_rate')
) {
// Interceptar error de tipo de cambio
error = null;
const dateMatch = errorStr.match(/(\d{4}-\d{2}-\d{2})/);
missingExchangeRateDate = dateMatch ? dateMatch[0] : formData.invoice_date || '';
showExchangeRateDialog = true;
return;
}
error = response.error;
}
@@ -316,13 +327,13 @@
}
// Check exchange rate BEFORE calling API to avoid 400 error
if (formData.invoice_date) {
const rateExists = await checkExchangeRate(formData.invoice_date);
if (!rateExists) {
loading = false;
return;
}
}
if (formData.invoice_date) {
const rateExists = await checkExchangeRate(formData.invoice_date);
if (!rateExists) {
loading = false;
return;
}
}
// Éxito
open = false;
@@ -330,8 +341,8 @@
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al guardar";
console.error("Error saving:", e);
error = e instanceof Error ? e.message : 'Error al guardar';
console.error('Error saving:', e);
} finally {
loading = false;
}
@@ -345,11 +356,11 @@
date: date,
page_size: 1
});
// api.get returns { data: ..., status: ... } and types now reflect that
const items = response.data?.items || [];
// Verificar estrictamente que haya items
// Verificar estrictamente que haya items
if (items.length === 0) {
missingExchangeRateDate = date;
showExchangeRateDialog = true;
@@ -360,7 +371,7 @@
console.error('Error checking exchange rate:', error);
missingExchangeRateDate = date;
showExchangeRateDialog = true;
return false;
return false;
}
}
@@ -374,15 +385,13 @@
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-full max-h-[90vh] overflow-y-auto">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-full">
<Dialog.Header>
<Dialog.Title>
{isEditing ? "Editar Factura" : "Nueva Factura"}
{isEditing ? 'Editar Factura' : 'Nueva Factura'}
</Dialog.Title>
<Dialog.Description>
{isEditing
? "Modifica los datos de la factura"
: "Ingresa los datos de la nueva factura"}
{isEditing ? 'Modifica los datos de la factura' : 'Ingresa los datos de la nueva factura'}
</Dialog.Description>
</Dialog.Header>
@@ -397,24 +406,29 @@
<!-- General Tab -->
<Tabs.Content value="general" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="operation_type">Tipo de Operación *</Label>
<Select.Root
type="single"
value={formData.operation_type}
onValueChange={(v: string) => {
if (v) formData.operation_type = v as "imp" | "exp";
}}
>
<Select.Trigger>
{formData.operation_type === 'imp' ? 'Importación' : formData.operation_type === 'exp' ? 'Exportación' : 'Seleccionar tipo'}
</Select.Trigger>
<Select.Content>
<Select.Item value="imp">Importación</Select.Item>
<Select.Item value="exp">Exportación</Select.Item>
</Select.Content>
</Select.Root>
</div> <div class="space-y-2">
<div class="space-y-2">
<Label for="operation_type">Tipo de Operación *</Label>
<Select.Root
type="single"
value={formData.operation_type}
onValueChange={(v: string) => {
if (v) formData.operation_type = v as 'imp' | 'exp';
}}
>
<Select.Trigger>
{formData.operation_type === 'imp'
? 'Importación'
: formData.operation_type === 'exp'
? 'Exportación'
: 'Seleccionar tipo'}
</Select.Trigger>
<Select.Content>
<Select.Item value="imp">Importación</Select.Item>
<Select.Item value="exp">Exportación</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label for="invoice_number">Número de Factura</Label>
<Input
id="invoice_number"
@@ -452,11 +466,7 @@
<div class="space-y-2">
<Label for="invoice_date">Fecha de Factura</Label>
<Input
id="invoice_date"
type="date"
bind:value={formData.invoice_date}
/>
<Input id="invoice_date" type="date" bind:value={formData.invoice_date} />
</div>
<div class="space-y-2">
@@ -470,11 +480,7 @@
<div class="space-y-2">
<Label for="cfdi_uuid">CFDI UUID</Label>
<Input
id="cfdi_uuid"
bind:value={formData.cfdi_uuid}
placeholder="UUID del CFDI"
/>
<Input id="cfdi_uuid" bind:value={formData.cfdi_uuid} placeholder="UUID del CFDI" />
</div>
</div>
@@ -514,11 +520,7 @@
<div class="space-y-2">
<Label for="aduana">Aduana</Label>
<Input
id="aduana"
bind:value={formData.aduana}
placeholder="Código de aduana"
/>
<Input id="aduana" bind:value={formData.aduana} placeholder="Código de aduana" />
</div>
<div class="space-y-2">
@@ -548,7 +550,7 @@
/>
</div>
<div class="space-y-2 flex items-center gap-2 pt-8">
<div class="flex items-center gap-2 space-y-2 pt-8">
<input
id="is_mixed"
type="checkbox"
@@ -565,11 +567,7 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="currency">Moneda</Label>
<Input
id="currency"
bind:value={formData.currency}
placeholder="MXN, USD, etc."
/>
<Input id="currency" bind:value={formData.currency} placeholder="MXN, USD, etc." />
</div>
<div class="space-y-2">
@@ -696,34 +694,31 @@
</Tabs.Root>
{#if error}
<div class="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (open = false)}
disabled={loading}
>
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{/if}
{isEditing ? "Actualizar" : "Crear"}
{isEditing ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<ExchangeRateDialog
bind:open={showExchangeRateDialog}
<ExchangeRateDialog
bind:open={showExchangeRateDialog}
initialDate={missingExchangeRateDate}
overlayClass="bg-black/20"
onSuccess={() => {/* Optional: maybe refresh something or just let user continue */}}
onSuccess={() => {
/* Optional: maybe refresh something or just let user continue */
}}
/>

View File

@@ -8,6 +8,7 @@
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { companyStore } from '$lib/stores/company.svelte';
import ExchangeRateGuard from '$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte';
import { page } from '$app/state';
let { data, children }: { data: LayoutData; children: any } = $props();
@@ -63,4 +64,6 @@
</Sidebar.Inset>
</Sidebar.Provider>
<ExchangeRateGuard overlayClass="bg-black/5" />
{#if !page.url.pathname.includes('/dashboard/invoices') && !page.url.pathname.includes('/dashboard/pedimentos')}
<ExchangeRateGuard overlayClass="bg-black/5" />
{/if}