diff --git a/backend/api/v1/modules/a76/factura_cove/routes.py b/backend/api/v1/modules/a76/factura_cove/routes.py index 9c20bb3a..9a311de6 100644 --- a/backend/api/v1/modules/a76/factura_cove/routes.py +++ b/backend/api/v1/modules/a76/factura_cove/routes.py @@ -59,12 +59,16 @@ def trigger_cove_for_invoice( task=factura_cove_generate, tenant_id=tenant_id, company_id=body.company_id, - # Hardcodeado a petición: siempre registrar esta tarea con el correo de Hugo Reyes. - requested_by_user="hreyes@aduanasoft.com.mx", + requested_by_user=( + current_user.get("email") + or current_user.get("preferred_username") + or current_user.get("username") + or "system" + ), task_name="factura_cove_generate", task_group="factura_cove", task_origin="a76/factura_cove/invoices/cove", - args=[invoice_id, int(tenant_id), body.company_id], + args=[invoice_id, int(tenant_id), body.company_id, body.recipient_email], ) return FacturaCoveResponse( diff --git a/backend/api/v1/modules/a76/factura_cove/schemas.py b/backend/api/v1/modules/a76/factura_cove/schemas.py index 0afcc2a2..d9365630 100644 --- a/backend/api/v1/modules/a76/factura_cove/schemas.py +++ b/backend/api/v1/modules/a76/factura_cove/schemas.py @@ -105,6 +105,7 @@ class GenerateCoveFromInvoiceRequest(BaseModel): company_id: int force_regen: Optional[bool] = False + recipient_email: Optional[EmailStr] = None class GenerateCoveResult(BaseModel): diff --git a/backend/api/v1/modules/a76/factura_cove/service.py b/backend/api/v1/modules/a76/factura_cove/service.py index 7f51c469..91decc9f 100644 --- a/backend/api/v1/modules/a76/factura_cove/service.py +++ b/backend/api/v1/modules/a76/factura_cove/service.py @@ -107,6 +107,17 @@ class FacturaCoveDomainService: """ vu = ctx.vu + if not vu: + errors.add_error( + field="vu", + message="La factura no tiene configuración VU asociada en el agente aduanal", + solution=[ + "Configura los datos VU del agente aduanal y sube certificado (.cer) y llave (.key) antes de generar COVE." + ], + code="MISSING_VU_CONFIGURATION", + ) + return None + # Determinar usuario efectivo de WebService: # - Preferimos el usuario configurado en VU (web_service_user) # - Si no existe, usamos el de DODA-PITA (doda_web_service_user) @@ -567,7 +578,11 @@ class FacturaCoveDomainService: return mercancias def build_factura_cove_request( - self, invoice_id: int, tenant_id: int, company_id: int + self, + invoice_id: int, + tenant_id: int, + company_id: int, + recipient_email: str | None = None, ) -> FacturaCoveRequest: """ Construye el FacturaCoveRequest completo a partir de una factura, @@ -646,6 +661,8 @@ class FacturaCoveDomainService: # Fallback: recortar a máximo 10 caracteres para cumplir el esquema tipo_figura = raw_figura[:10] + correo_destino = (recipient_email or (ctx.vu.vu_email if ctx.vu else None) or "").strip() or None + return FacturaCoveRequest( configuracion_vu=configuracion_vu, # El RFC de consulta NO debe ser igual al RFC del que registra el comprobante. @@ -660,8 +677,7 @@ class FacturaCoveDomainService: patente_aduanal=patente_aduanal, fecha_expedicion=fecha_expedicion, observaciones=ctx.invoice.vu_observations or None, - # Correo hardcodeado temporalmente para pruebas de COVE - correo_electronico="hreyes@aduanasoft.com.mx", + correo_electronico=correo_destino, tiene_subdivision=bool(ctx.invoice.logistics and ctx.invoice.logistics.is_subdivision), certificado_origen=False, numero_exportador_autorizado=None, @@ -675,7 +691,6 @@ class FacturaCoveDomainService: Versión "ligera" para frontend: evalúa si la factura puede generar COVE e informa por qué no, sin disparar la tarea Celery. """ - db = self.db or CoreSessionLocal() errors = ErrorCollector() try: diff --git a/backend/api/v1/modules/a76/factura_cove/tasks.py b/backend/api/v1/modules/a76/factura_cove/tasks.py index c0f2b4c5..76a6a09c 100644 --- a/backend/api/v1/modules/a76/factura_cove/tasks.py +++ b/backend/api/v1/modules/a76/factura_cove/tasks.py @@ -152,7 +152,13 @@ def _poll_external_status( @celery_app.task(bind=True, name="factura_cove_generate") -def factura_cove_generate(self: Task, invoice_id: int, tenant_id: int, company_id: int) -> dict: +def factura_cove_generate( + self: Task, + invoice_id: int, + tenant_id: int, + company_id: int, + recipient_email: str | None = None, +) -> dict: """ Tarea Celery para preparar (y en el futuro generar) un COVE a partir de una factura. @@ -173,7 +179,10 @@ def factura_cove_generate(self: Task, invoice_id: int, tenant_id: int, company_i # Esta llamada valida todo y construye el payload; si algo falla, lanza ValidationException request_payload = service.build_factura_cove_request( - invoice_id=invoice_id, tenant_id=tenant_id, company_id=company_id + invoice_id=invoice_id, + tenant_id=tenant_id, + company_id=company_id, + recipient_email=recipient_email, ) _progress(self, 80, "Enviando solicitud al servicio COVE...") diff --git a/backend/tests/unit/factura_cove/test_service.py b/backend/tests/unit/factura_cove/test_service.py new file mode 100644 index 00000000..0751308a --- /dev/null +++ b/backend/tests/unit/factura_cove/test_service.py @@ -0,0 +1,29 @@ +from types import SimpleNamespace + +from api.v1.modules.a76.factura_cove.service import FacturaCoveDomainService, InvoiceContext +from core.exceptions import ErrorCollector + + +def test_build_configuracion_vu_returns_validation_error_when_vu_is_missing() -> None: + service = FacturaCoveDomainService(db=SimpleNamespace()) + ctx = InvoiceContext( + invoice=SimpleNamespace(), + broker=None, + vu=None, + ) + errors = ErrorCollector() + + configuracion = service._build_configuracion_vu(ctx, errors) + + assert configuracion is None + assert errors.has_errors() + assert errors.get_errors() == [ + { + "field": "vu", + "message": "La factura no tiene configuración VU asociada en el agente aduanal", + "solution": [ + "Configura los datos VU del agente aduanal y sube certificado (.cer) y llave (.key) antes de generar COVE." + ], + "code": "MISSING_VU_CONFIGURATION", + } + ] \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index 4b876838..e8828c7d 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -545,15 +545,19 @@ export const invoicesApi = { }>(`/v1/a76/invoices/revert/${taskId}/status`); }, - generateCove: (invoiceId: number, companyId: number) => { + generateCove: (invoiceId: number, companyId: number, recipientEmail?: string | null) => { const params = new URLSearchParams({ company_id: companyId.toString() }); + const body: Record = { + company_id: companyId + }; + if (recipientEmail) { + body.recipient_email = recipientEmail; + } return api.post<{ task_id: string }>( `/v1/a76/factura-cove/invoices/${invoiceId}/cove?${params.toString()}`, - { - company_id: companyId - } + body ); }, diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index db8b883e..c50517f8 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -15,12 +15,17 @@ import { createColumns } from '$lib/components/dashboard/invoices/columns.js'; import * as Card from '$lib/components/ui/card'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import * as Dialog from '$lib/components/ui/dialog'; + import * as Select from '$lib/components/ui/select'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import type { PageData } from './$types'; import { browser } from '$app/environment'; + import { currentUser } from '$lib/auth'; import { companyStore } from '$lib/stores/company.svelte'; + import { getCompany } from '$lib/api/dashboard/a76/general_catalogs/company'; + import { usersAPI } from '$lib/api/dashboard/users'; import { Plus, RefreshCw, @@ -41,7 +46,8 @@ BadgeCent, ArrowRightLeft, Database, - ChevronUp + ChevronUp, + Mail } from 'lucide-svelte'; import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte'; import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte'; @@ -57,6 +63,13 @@ // Los datos iniciales vienen del servidor let { data }: { data: PageData } = $props(); + type CoveRecipientOption = { + email: string; + label: string; + description: string; + source: 'company' | 'user'; + }; + // Estado para filtros // Nota: Los query parameters invoice_type y operation_type se pueden usar para filtrar // Ejemplo: /dashboard/invoices?invoice_type=TEM&operation_type=imp @@ -425,10 +438,189 @@ // Estado para el diálogo de progreso let showProgressDialog = $state(false); let isWinsaiiConfirmOpen = $state(false); + let isCoveDialogOpen = $state(false); let currentTaskId = $state(null); let currentStatusFunction = $state<((taskId: string) => Promise) | null>(null); let progressDialogTitle = $state('Generando documento'); let progressDialogSteps = $state<{ label: string; percent: number }[] | null>(null); + let coveRecipientsLoading = $state(false); + let coveRecipientsError = $state(null); + let coveRecipientEmail = $state(''); + let coveRecipientOptions = $state([]); + let coveRecipientsRequestId = 0; + + const currentUserEmail = $derived(($currentUser?.email || '').trim()); + const selectedCoveRecipient = $derived( + coveRecipientOptions.find( + (option) => option.email.trim().toLowerCase() === coveRecipientEmail.trim().toLowerCase() + ) ?? null + ); + + function normalizeEmail(email?: string | null) { + return (email || '').trim().toLowerCase(); + } + + function createRecipientLabel(prefix: string, email: string) { + return `${prefix} - ${email}`; + } + + async function loadCoveRecipients() { + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + coveRecipientsError = null; + coveRecipientOptions = []; + coveRecipientEmail = ''; + return; + } + + const requestId = ++coveRecipientsRequestId; + coveRecipientsLoading = true; + coveRecipientsError = null; + + try { + const [companyResult, usersResult] = await Promise.allSettled([ + getCompany(companyId), + usersAPI.list(companyId, { page_size: 100 }) + ]); + + if (requestId !== coveRecipientsRequestId) return; + + const recipientMap = new Map(); + + const addRecipient = ( + email?: string | null, + label?: string, + description?: string, + source: 'company' | 'user' = 'user' + ) => { + const normalizedEmail = normalizeEmail(email); + if (!normalizedEmail) return; + if (recipientMap.has(normalizedEmail)) return; + recipientMap.set(normalizedEmail, { + email: email!.trim(), + label: label || email!.trim(), + description: description || email!.trim(), + source + }); + }; + + if (companyResult.status === 'fulfilled' && companyResult.value.data) { + const company = companyResult.value.data as Record; + addRecipient( + company.vu_email, + 'Correo VU de la empresa', + company.name ? `Empresa ${company.name}` : 'Correo de ventanilla única', + 'company' + ); + addRecipient( + company.main_email, + 'Correo principal de la empresa', + company.name ? `Empresa ${company.name}` : 'Correo principal', + 'company' + ); + addRecipient( + company.ind1_email, + 'Correo industrial 1', + company.name ? `Empresa ${company.name}` : 'Correo industrial 1', + 'company' + ); + addRecipient( + company.ind2_email, + 'Correo industrial 2', + company.name ? `Empresa ${company.name}` : 'Correo industrial 2', + 'company' + ); + } + + if (usersResult.status === 'fulfilled' && usersResult.value.users) { + for (const user of usersResult.value.users) { + const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim(); + addRecipient( + user.email, + fullName || user.username || user.email, + createRecipientLabel('Usuario de la empresa', user.email), + 'user' + ); + } + } + + if (currentUserEmail) { + addRecipient( + currentUserEmail, + 'Mi correo', + createRecipientLabel('Usuario autenticado', currentUserEmail), + 'user' + ); + } + + const recipients = [...recipientMap.values()].sort((left, right) => { + if (left.source !== right.source) { + return left.source === 'company' ? -1 : 1; + } + return left.label.localeCompare(right.label, 'es'); + }); + + const sourceFailures = [companyResult, usersResult].filter( + (result) => result.status === 'rejected' + ).length; + + coveRecipientOptions = recipients; + + const preferredEmail = normalizeEmail(currentUserEmail); + const existingSelection = + recipients.find((option) => normalizeEmail(option.email) === normalizeEmail(coveRecipientEmail)) || + null; + const preferredSelection = + recipients.find((option) => normalizeEmail(option.email) === preferredEmail) || recipients[0] || null; + + if (existingSelection) { + coveRecipientEmail = existingSelection.email; + } else if (preferredSelection) { + coveRecipientEmail = preferredSelection.email; + } else { + coveRecipientEmail = ''; + } + + if (recipientMap.size === 0 && sourceFailures > 0) { + coveRecipientsError = 'No se pudieron cargar los correos disponibles para COVE'; + } else if (recipientMap.size === 0) { + coveRecipientsError = 'No hay correos configurados para COVE'; + } + } catch (error) { + if (requestId !== coveRecipientsRequestId) return; + console.error('Error cargando correos de COVE:', error); + coveRecipientsError = 'No se pudieron cargar los correos disponibles para COVE'; + } finally { + if (requestId === coveRecipientsRequestId) { + coveRecipientsLoading = false; + } + } + } + + $effect(() => { + const companyId = companyStore.activeCompany?.id; + const userEmail = currentUserEmail; + + if (!companyId) { + coveRecipientsError = null; + coveRecipientOptions = []; + coveRecipientEmail = ''; + return; + } + + void userEmail; + void loadCoveRecipients(); + }); + + function openCoveDialog() { + if (!selectedInvoice || !companyStore.activeCompany) { + toast.info('Selecciona una factura para generar COVE'); + return; + } + + void loadCoveRecipients(); + isCoveDialogOpen = true; + } // Utilidad para convertir Base64 a Blob function base64ToBlob(base64: string, type: string) { @@ -808,12 +1000,18 @@ } } - async function handleGenerateCove() { + async function handleGenerateCove(recipientEmail = coveRecipientEmail) { if (!selectedInvoice || !companyStore.activeCompany) { toast.info('Selecciona una factura para generar COVE'); return; } + const selectedRecipientEmail = normalizeEmail(recipientEmail); + if (!selectedRecipientEmail) { + toast.error('Selecciona un correo para enviar el COVE'); + return; + } + const companyId = companyStore.activeCompany.id; // Paso 1: Checar elegibilidad antes de disparar la tarea @@ -840,13 +1038,19 @@ // Paso 2: Disparar tarea Celery de COVE try { - const response = await invoicesApi.generateCove(selectedInvoice.id, companyId); + const response = await invoicesApi.generateCove( + selectedInvoice.id, + companyId, + selectedRecipientEmail + ); if (response.error) { toast.error(`Error al iniciar generación de COVE: ${response.error}`); return; } + isCoveDialogOpen = false; + currentTaskId = response.data!.task_id; currentStatusFunction = invoicesApi.getCoveStatus; progressDialogTitle = 'Validando datos para COVE'; @@ -1065,6 +1269,101 @@
+ + + + Generar COVE + + Selecciona el correo destinatario para la factura + {selectedInvoice?.invoice_number}. + + + + + +
+ + + + {#snippet children()} +
+
+ +
+
+
+ Destino COVE +
+
+ {selectedCoveRecipient?.label || 'Selecciona un correo'} +
+
+ {selectedCoveRecipient?.email || 'Se enviará al correo del usuario que generó la factura'} +
+
+
+ {/snippet} +
+ + {#if coveRecipientsLoading} +
+ Cargando correos disponibles... +
+ {:else if coveRecipientOptions.length === 0} +
+ No hay correos disponibles para COVE. +
+ {:else} + {#each coveRecipientOptions as recipient} + + {#snippet children({ selected })} +
+ + {recipient.label} + {#if selected} + Seleccionado + {/if} + + + {recipient.description} + +
+ {/snippet} +
+ {/each} + {/if} +
+
+ {#if coveRecipientsError} +

{coveRecipientsError}

+ {/if} +
+
+
+ + + + + +
+
+
(showVuSubmenu = false)} - on:contextmenu|preventDefault={() => (showVuSubmenu = false)} - /> + onclick={() => (showVuSubmenu = false)} + oncontextmenu={(event) => { + event.preventDefault(); + showVuSubmenu = false; + }} + >
{ + onclick={() => { showVuSubmenu = false; toast.info('Consulta VU - Próximamente'); }} @@ -1154,7 +1456,7 @@