feature/COVE-api-email-selector

This commit is contained in:
2026-04-13 14:12:25 -06:00
parent 07dd69a28a
commit 233da55f40
7 changed files with 389 additions and 25 deletions

View File

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

View File

@@ -105,6 +105,7 @@ class GenerateCoveFromInvoiceRequest(BaseModel):
company_id: int
force_regen: Optional[bool] = False
recipient_email: Optional[EmailStr] = None
class GenerateCoveResult(BaseModel):

View File

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

View File

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

View File

@@ -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",
}
]

View File

@@ -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<string, unknown> = {
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
);
},

View File

@@ -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<string | null>(null);
let currentStatusFunction = $state<((taskId: string) => Promise<any>) | null>(null);
let progressDialogTitle = $state('Generando documento');
let progressDialogSteps = $state<{ label: string; percent: number }[] | null>(null);
let coveRecipientsLoading = $state(false);
let coveRecipientsError = $state<string | null>(null);
let coveRecipientEmail = $state('');
let coveRecipientOptions = $state<CoveRecipientOption[]>([]);
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<string, CoveRecipientOption>();
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<string, any>;
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 @@
<div class="h-20"></div>
<Dialog.Root bind:open={isCoveDialogOpen}>
<Dialog.Content class="sm:!max-w-2xl">
<Dialog.Header>
<Dialog.Title>Generar COVE</Dialog.Title>
<Dialog.Description>
Selecciona el correo destinatario para la factura
<strong>{selectedInvoice?.invoice_number}</strong>.
</Dialog.Description>
</Dialog.Header>
<Card.Root class="border bg-card/90 shadow-none">
<Card.Content class="space-y-4 p-4 sm:p-5">
<div class="space-y-2">
<Label for="cove-recipient">Correo destinatario</Label>
<Select.Root bind:value={coveRecipientEmail}>
<Select.Trigger
id="cove-recipient"
class="h-auto min-h-14 w-full bg-background px-3 py-2"
disabled={coveRecipientsLoading || coveRecipientOptions.length === 0}
>
{#snippet children()}
<div class="flex min-w-0 flex-1 items-center gap-3 text-left">
<div class="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted">
<Mail class="size-4 text-muted-foreground" />
</div>
<div class="min-w-0 flex-1">
<div class="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
Destino COVE
</div>
<div class="truncate text-sm font-medium">
{selectedCoveRecipient?.label || 'Selecciona un correo'}
</div>
<div class="truncate text-xs text-muted-foreground">
{selectedCoveRecipient?.email || 'Se enviará al correo del usuario que generó la factura'}
</div>
</div>
</div>
{/snippet}
</Select.Trigger>
<Select.Content searchable searchPlaceholder="Buscar correo...">
{#if coveRecipientsLoading}
<div class="px-2 py-3 text-sm text-muted-foreground">
Cargando correos disponibles...
</div>
{:else if coveRecipientOptions.length === 0}
<div class="px-2 py-3 text-sm text-muted-foreground">
No hay correos disponibles para COVE.
</div>
{:else}
{#each coveRecipientOptions as recipient}
<Select.Item
value={recipient.email}
label={recipient.label}
searchText={`${recipient.label} ${recipient.email} ${recipient.description}`}
>
{#snippet children({ selected })}
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium">
{recipient.label}
{#if selected}
<span class="ml-2 text-xs text-primary">Seleccionado</span>
{/if}
</span>
<span class="truncate text-xs text-muted-foreground">
{recipient.description}
</span>
</div>
{/snippet}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{#if coveRecipientsError}
<p class="text-sm text-destructive">{coveRecipientsError}</p>
{/if}
</div>
</Card.Content>
</Card.Root>
<Dialog.Footer>
<Button variant="outline" onclick={() => (isCoveDialogOpen = false)}>
Cancelar
</Button>
<Button
onclick={() => handleGenerateCove(coveRecipientEmail)}
disabled={coveRecipientsLoading || !coveRecipientEmail}
>
<Mail class="mr-2 h-4 w-4" />
Generar COVE
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<PdfProgressDialog
bind:open={showProgressDialog}
taskId={currentTaskId}
@@ -1132,9 +1431,12 @@
<!-- Capa para cerrar el submenú al hacer click fuera -->
<div
class="fixed inset-0 z-40"
on:click={() => (showVuSubmenu = false)}
on:contextmenu|preventDefault={() => (showVuSubmenu = false)}
/>
onclick={() => (showVuSubmenu = false)}
oncontextmenu={(event) => {
event.preventDefault();
showVuSubmenu = false;
}}
></div>
<!-- Submenú contextual de Interface VU -->
<div
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm shadow-md"
@@ -1143,7 +1445,7 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
toast.info('Consulta VU - Próximamente');
}}
@@ -1154,7 +1456,7 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
toast.info('Adenda VU - Próximamente');
}}
@@ -1165,9 +1467,9 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
handleGenerateCove();
openCoveDialog();
}}
>
<Files class="mr-2 h-4 w-4" />
@@ -1176,7 +1478,7 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
toast.info('COVE Masivos - Próximamente');
}}
@@ -1291,7 +1593,7 @@
<!-- Interface VU: click izquierdo = COVE, click derecho = submenú -->
<DropdownMenu.Item
class="cursor-pointer"
onclick={() => handleGenerateCove()}
onclick={() => openCoveDialog()}
oncontextmenu={(event) => {
event.preventDefault();
event.stopPropagation();