se agregan las plantillas de aceptado/rechazado y su renderizador

plantilla/ con los dos HTML de correo y ejemplos.json (6 escenarios ficticios);
plantillas.py renderiza el subconjunto de Mustache que usan (variables con
escape de HTML, secciones e invertidas), en modo estricto: un dato que falta
da error en vez de un correo con huecos. tools/previsualizar_plantillas.py
genera las vistas previas y envía correos de prueba (--enviar). En la
plantilla de rechazo el RUC, la fila Comprobante y la tabla de guías son
opcionales, para el rechazo de un correo sin comprobante.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 08:55:54 -05:00
1 parent d5a82ea025
commit 7ae2580565
7 files changed
+1012

No files matched your search

+1
View File
@@ -14,3 +14,4 @@ emails.db
*.egg-info/ *.egg-info/
build/ build/
procesos/ procesos/
tools/previews/
+280
View File
@@ -0,0 +1,280 @@
{
"aceptado_con_pago": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Aceptado · Factura F002-486 · ABR-261019-00042",
"preheader": "Su factura F002-486 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00042",
"fecha_estimada_pago": "18/11/2026",
"guias": [
{
"guia": "T001-127773",
"importe_factura": "S/ 624.38",
"conforme": true
},
{
"guia": "T001-127774",
"importe_factura": "S/ 311.20",
"conforme": true
},
{
"guia": "T001-127775",
"importe_factura": "S/ 1,250.00",
"conforme": true
}
]
}
},
"aceptado_sin_pago": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "SERVICIOS LOGÍSTICOS DEL SUR S.A.",
"proveedor_ruc": "20111111111",
"asunto": "Aceptado · Factura E001-2403 · ABR-261019-00043",
"preheader": "Su factura E001-2403 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "E001-2403",
"codigo_seguimiento": "ABR-261019-00043",
"fecha_estimada_pago": null,
"guias": [
{
"guia": "T001-129352",
"importe_factura": "S/ 828.73",
"conforme": true
}
]
}
},
"aceptado_muchas_guias": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Aceptado · Factura F004-5989 · ABR-261019-00044",
"preheader": "Su factura F004-5989 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "F004-5989",
"codigo_seguimiento": "ABR-261019-00044",
"fecha_estimada_pago": "30/11/2026",
"guias": [
{
"guia": "T001-130000",
"importe_factura": "S/ 137.50",
"conforme": true
},
{
"guia": "T001-130001",
"importe_factura": "S/ 275.00",
"conforme": true
},
{
"guia": "T001-130002",
"importe_factura": "S/ 412.50",
"conforme": true
},
{
"guia": "T001-130003",
"importe_factura": "S/ 550.00",
"conforme": true
},
{
"guia": "T001-130004",
"importe_factura": "S/ 687.50",
"conforme": true
},
{
"guia": "T001-130005",
"importe_factura": "S/ 825.00",
"conforme": true
},
{
"guia": "T001-130006",
"importe_factura": "S/ 962.50",
"conforme": true
},
{
"guia": "T001-130007",
"importe_factura": "S/ 1,100.00",
"conforme": true
},
{
"guia": "T001-130008",
"importe_factura": "S/ 1,237.50",
"conforme": true
},
{
"guia": "T001-130009",
"importe_factura": "S/ 1,375.00",
"conforme": true
},
{
"guia": "T001-130010",
"importe_factura": "S/ 1,512.50",
"conforme": true
},
{
"guia": "T001-130011",
"importe_factura": "S/ 1,650.00",
"conforme": true
}
]
}
},
"rechazado_dos_observaciones": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Rechazado · Factura F002-486 · ABR-261019-00045",
"preheader": "Tiene 2 observaciones. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00045",
"guias": [
{
"guia": "T001-127773",
"importe_factura": "S/ 624.38",
"conforme": false
},
{
"guia": "T001-127774",
"importe_factura": "S/ 311.20",
"conforme": true
}
],
"observaciones_texto": "2 observaciones",
"observaciones": [
{
"numero": 1,
"titulo": "El importe no coincide con nuestro registro",
"filas": [
{
"etiqueta": "Importe en su factura",
"valor": "S/ 624.38"
},
{
"etiqueta": "Importe esperado",
"valor": "S/ 600.00"
}
],
"como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto."
},
{
"numero": 2,
"titulo": "No encontramos la guía T001-128912",
"filas": [
{
"etiqueta": "Guía",
"valor": "T001-128912"
}
],
"como_corregir": "Verifique que la guía esté registrada y que la serie y el número estén bien escritos."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": true
}
},
"rechazado_una_observacion": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES \"EL RÁPIDO\" <Lima> E.I.R.L.",
"proveedor_ruc": "10060028686",
"asunto": "Rechazado · Factura F002-2450 · ABR-261019-00046",
"preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": "F002-2450",
"codigo_seguimiento": "ABR-261019-00046",
"guias": [
{
"guia": "T001-128912",
"importe_factura": "S/ 624.38",
"conforme": false
}
],
"observaciones_texto": "1 observación",
"observaciones": [
{
"numero": 1,
"titulo": "El importe no coincide con nuestro registro",
"filas": [
{
"etiqueta": "Importe en su factura",
"valor": "S/ 624.38"
},
{
"etiqueta": "Importe esperado",
"valor": "S/ 600.00"
}
],
"como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": true
}
},
"rechazado_sin_comprobante": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "comprobante",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "proveedor@ejemplo.com",
"proveedor_ruc": null,
"asunto": "Rechazado · Comprobante no recibido · ABR-261019-00047",
"preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": null,
"codigo_seguimiento": "ABR-261019-00047",
"guias": [],
"observaciones_texto": "1 observación",
"observaciones": [
{
"numero": 1,
"titulo": "El correo no trae el XML ni el PDF del comprobante",
"filas": [
{
"etiqueta": "Archivos recibidos",
"valor": "0"
}
],
"como_corregir": "Adjunte el XML y el PDF del comprobante en el mismo correo."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": false
}
}
}
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#E8F3EC" style="background:#E8F3EC;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#1D7A46;"><span aria-hidden="true" style="color:#1D7A46;">&#10003;&nbsp;</span><span style="color:#1D7A46;">ACEPTADO</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">Registramos su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Pasó todas las validaciones. <strong>No necesita hacer nada más.</strong></p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
{{#fecha_estimada_pago}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Pago estimado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{fecha_estimada_pago}}</td>
</tr>
{{/fecha_estimada_pago}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#1D7A46;">Aceptado</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Guías revisadas</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #EDEDED;border-collapse:collapse;">
<thead><tr bgcolor="#F7F7F7" style="background:#F7F7F7;">
<th scope="col" align="left" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">GUÍA</th>
<th scope="col" align="right" class="hide-sm" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">IMPORTE</th>
<th scope="col" align="right" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">RESULTADO</th>
</tr></thead>
<tbody>
{{#guias}}
<tr>
<td style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{guia}}</td>
<td align="right" class="hide-sm" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{importe_factura}}</td>
<td align="right" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;font-weight:700;">{{#conforme}}<span style="color:#1D7A46;"><span aria-hidden="true">&#10003;&nbsp;</span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">&#10005;&nbsp;</span>No coincide</span>{{/conforme}}</td>
</tr>
{{/guias}}
</tbody>
</table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Qué sigue</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">El pago sigue según las condiciones acordadas.<br>Conserve el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong> para cualquier consulta.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#FDECE9" style="background:#FDECE9;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#C0291C;"><span aria-hidden="true" style="color:#C0291C;">&#10005;&nbsp;</span><span style="color:#C0291C;">RECHAZADO</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">No pudimos registrar su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Tiene <strong>{{observaciones_texto}}</strong>. Corríjalas y envíenos el comprobante nuevamente.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}{{#proveedor_ruc}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span>{{/proveedor_ruc}}</td>
</tr>
{{#comprobante_numero}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
{{/comprobante_numero}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#C0291C;">Rechazado · {{observaciones_texto}}</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
{{#tiene_guias}}
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Guías revisadas</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #EDEDED;border-collapse:collapse;">
<thead><tr bgcolor="#F7F7F7" style="background:#F7F7F7;">
<th scope="col" align="left" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">GUÍA</th>
<th scope="col" align="right" class="hide-sm" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">IMPORTE</th>
<th scope="col" align="right" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">RESULTADO</th>
</tr></thead>
<tbody>
{{#guias}}
<tr>
<td style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{guia}}</td>
<td align="right" class="hide-sm" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{importe_factura}}</td>
<td align="right" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;font-weight:700;">{{#conforme}}<span style="color:#1D7A46;"><span aria-hidden="true">&#10003;&nbsp;</span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">&#10005;&nbsp;</span>No coincide</span>{{/conforme}}</td>
</tr>
{{/guias}}
</tbody>
</table>
</td></tr>
{{/tiene_guias}}
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Observaciones</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{#observaciones}}
<tr><td style="padding:0 0 18px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#C0291C;">OBSERVACIÓN {{numero}}</p>
<p style="margin:2px 0 6px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">{{titulo}}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-left:2px solid #C0291C;padding:0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{#filas}}
<tr>
<td style="padding:2px 0 2px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">{{etiqueta}}</td>
<td align="right" style="padding:2px 0 2px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;white-space:nowrap;">{{valor}}</td>
</tr>
{{/filas}}
</table>
</td></tr></table>
<p style="margin:6px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:19px;color:#666666;">{{como_corregir}}</p>
</td></tr>
{{/observaciones}}
</table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Para continuar</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="32" style="width:32px;padding:0 0 10px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td width="22" height="22" align="center" bgcolor="#FF6900" style="width:22px;height:22px;border-radius:11px;background:#FF6900;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">1</td></tr></table>
</td>
<td valign="top" style="padding:1px 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Emita el comprobante corregido. Si corresponde, anule el anterior con una nota de crédito.</td>
</tr>
<tr>
<td valign="top" width="32" style="width:32px;padding:0 0 10px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td width="22" height="22" align="center" bgcolor="#FF6900" style="width:22px;height:22px;border-radius:11px;background:#FF6900;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">2</td></tr></table>
</td>
<td valign="top" style="padding:1px 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Envíe el XML y PDF a <a href="mailto:{{envio_correo}}" style="color:#B84800;font-weight:700;">{{envio_correo}}</a> con el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong>.</td>
</tr>
</table>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
"""
Renderizado de las plantillas HTML de correo (``plantilla/plantilla-*.html``).
Las plantillas usan un subconjunto de Mustache:
{{variable}} valor, con escape de HTML
{{#nombre}}...{{/nombre}} sección: se repite por cada elemento de una lista,
o se muestra una vez si el valor es verdadero
{{^nombre}}...{{/nombre}} sección invertida: se muestra si el valor es falso o vacío
El renderizado es **estricto**: toda variable o sección debe existir en el
contexto (usa ``None``, ``False`` o ``[]`` para ocultar algo). Así un dato que
falta provoca un error al renderizar, en vez de un correo con huecos.
No soporta ``{{{triple}}}``, parciales, comentarios ni nombres con punto; no se
usan en las plantillas.
"""
import html
import os
import re
CARPETA_PLANTILLAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla")
_ETIQUETA = re.compile(r"\{\{\s*([#^/]?)\s*([A-Za-z_]\w*)\s*\}\}")
class PlantillaError(ValueError):
"""La plantilla está mal formada o el contexto no tiene algún dato que necesita."""
# ============================================================================
def _analizar(texto: str) -> list:
"""Convierte el texto en un árbol de nodos: ``("texto", s)``, ``("var", nombre)`` o ``("sec", nombre, hijos, invertida)``."""
raiz: list = []
pila: list[tuple[str, list, bool]] = [] # (nombre, hijos, invertida) de las secciones abiertas
actual = raiz
posicion = 0
for m in _ETIQUETA.finditer(texto):
if m.start() > posicion:
actual.append(("texto", texto[posicion:m.start()]))
posicion = m.end()
marca, nombre = m.group(1), m.group(2)
if marca == "":
actual.append(("var", nombre))
elif marca in ("#", "^"):
hijos: list = []
actual.append(("sec", nombre, hijos, marca == "^"))
pila.append((nombre, actual, marca == "^"))
actual = hijos
else: # cierre
linea = texto.count("\n", 0, m.start()) + 1
if not pila or pila[-1][0] != nombre:
raise PlantillaError(f"Línea {linea}: '{{{{/{nombre}}}}}' no corresponde a ninguna sección abierta")
actual = pila.pop()[1]
if pila:
raise PlantillaError(f"La sección '{pila[-1][0]}' no se cierra")
if posicion < len(texto):
actual.append(("texto", texto[posicion:]))
return raiz
def _buscar(nombre: str, pila: list):
for marco in reversed(pila):
if isinstance(marco, dict) and nombre in marco:
return marco[nombre]
raise PlantillaError(f"Falta '{nombre}' en el contexto de la plantilla")
def _generar(nodos: list, pila: list) -> str:
salida: list[str] = []
for nodo in nodos:
tipo = nodo[0]
if tipo == "texto":
salida.append(nodo[1])
elif tipo == "var":
valor = _buscar(nodo[1], pila)
salida.append("" if valor is None else html.escape(str(valor), quote=True))
else:
_, nombre, hijos, invertida = nodo
valor = _buscar(nombre, pila)
if invertida:
if not valor:
salida.append(_generar(hijos, pila))
elif isinstance(valor, list):
for elemento in valor:
salida.append(_generar(hijos, pila + [elemento]))
elif valor:
salida.append(_generar(hijos, pila + [valor] if isinstance(valor, dict) else pila))
return "".join(salida)
# ============================================================================
def renderizar(texto: str, contexto: dict) -> str:
"""Renderiza ``texto`` (plantilla Mustache) con ``contexto``. Lanza ``PlantillaError`` si falta algún dato."""
return _generar(_analizar(texto), [contexto])
def nombres_usados(texto: str) -> set[str]:
"""Todos los nombres (variables y secciones) que usa la plantilla."""
def recorrer(nodos):
for n in nodos:
if n[0] == "var":
yield n[1]
elif n[0] == "sec":
yield n[1]
yield from recorrer(n[2])
return set(recorrer(_analizar(texto)))
def cargar_plantilla(nombre: str) -> str:
"""Lee ``plantilla/plantilla-<nombre>.html`` (por ejemplo ``aceptado`` o ``rechazado``)."""
ruta = os.path.join(CARPETA_PLANTILLAS, f"plantilla-{nombre}.html")
with open(ruta, encoding="utf-8") as f:
return f.read()
def renderizar_plantilla(nombre: str, contexto: dict) -> str:
"""Carga la plantilla ``nombre`` y la renderiza con ``contexto``."""
return renderizar(cargar_plantilla(nombre), contexto)
+179
View File
@@ -0,0 +1,179 @@
import json
import os
import pytest
import plantillas
from plantillas import PlantillaError, renderizar
EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json")
def _ejemplos() -> dict:
with open(EJEMPLOS, encoding="utf-8") as f:
return json.load(f)
# ============================================================================
# Motor de plantillas
# ============================================================================
def test_variable_con_escape_de_html():
assert renderizar("{{x}}", {"x": 'A & B <i> "c" \'d\''}) == "A &amp; B &lt;i&gt; &quot;c&quot; &#x27;d&#x27;"
def test_variable_none_queda_vacia_y_numeros_se_convierten():
assert renderizar("[{{a}}][{{b}}]", {"a": None, "b": 12.5}) == "[][12.5]"
def test_variable_faltante_lanza_error():
with pytest.raises(PlantillaError, match="Falta 'x'"):
renderizar("{{x}}", {})
def test_seccion_faltante_tambien_lanza_error():
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
renderizar("{{#opcional}}x{{/opcional}}", {})
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
renderizar("{{^opcional}}x{{/opcional}}", {})
def test_seccion_de_lista_se_repite_y_ve_el_contexto_externo():
texto = "{{titulo}}:{{#items}}[{{titulo}}-{{n}}]{{/items}}"
assert renderizar(texto, {"titulo": "T", "items": [{"n": 1}, {"n": 2}]}) == "T:[T-1][T-2]"
def test_lista_vacia_no_muestra_nada_y_la_invertida_si():
texto = "{{#items}}x{{/items}}{{^items}}vacío{{/items}}"
assert renderizar(texto, {"items": []}) == "vacío"
assert renderizar(texto, {"items": [{}]}) == "x"
@pytest.mark.parametrize("valor, visible", [(True, True), (False, False), (None, False), ("", False), ("a", True), (0, False)])
def test_seccion_booleana(valor, visible):
texto = "{{#f}}SI{{/f}}{{^f}}NO{{/f}}"
assert renderizar(texto, {"f": valor}) == ("SI" if visible else "NO")
def test_seccion_con_texto_muestra_una_vez_y_la_variable_sigue_disponible():
"""Caso de ``fecha_estimada_pago``: la sección usa un texto como condición y lo imprime dentro."""
texto = "{{#pago}}Pago: {{pago}}{{/pago}}"
assert renderizar(texto, {"pago": "18/11/2026"}) == "Pago: 18/11/2026"
assert renderizar(texto, {"pago": None}) == ""
def test_secciones_con_el_mismo_nombre_seguidas_en_una_linea():
"""Caso de ``conforme``: ``{{#conforme}}..{{/conforme}}{{^conforme}}..{{/conforme}}``."""
texto = "{{#guias}}<{{#ok}}Conforme{{/ok}}{{^ok}}No coincide{{/ok}}>{{/guias}}"
assert renderizar(texto, {"guias": [{"ok": True}, {"ok": False}]}) == "<Conforme><No coincide>"
def test_secciones_anidadas():
texto = "{{#obs}}{{t}}({{#filas}}{{k}}={{v}};{{/filas}}){{/obs}}"
contexto = {"obs": [{"t": "A", "filas": [{"k": "x", "v": 1}, {"k": "y", "v": 2}]}, {"t": "B", "filas": []}]}
assert renderizar(texto, contexto) == "A(x=1;y=2;)B()"
def test_seccion_sin_cerrar_o_cierre_sin_abrir():
with pytest.raises(PlantillaError, match="no se cierra"):
renderizar("{{#a}}x", {"a": True})
with pytest.raises(PlantillaError, match="no corresponde"):
renderizar("x{{/a}}", {"a": True})
with pytest.raises(PlantillaError, match="no corresponde"):
renderizar("{{#a}}{{#b}}x{{/a}}{{/b}}", {"a": True, "b": True})
def test_llaves_sueltas_de_css_no_se_confunden_con_etiquetas():
assert renderizar("a{color:red;}{{x}}", {"x": "1"}) == "a{color:red;}1"
def test_nombres_usados():
assert plantillas.nombres_usados("{{a}}{{#b}}{{c}}{{/b}}{{^d}}x{{/d}}") == {"a", "b", "c", "d"}
# ============================================================================
# Plantillas reales con los ejemplos de plantilla/ejemplos.json
# ============================================================================
ESCENARIOS = sorted(_ejemplos())
def _claves(obj) -> set[str]:
if isinstance(obj, dict):
return set(obj) | {c for v in obj.values() for c in _claves(v)}
if isinstance(obj, list):
return {c for v in obj for c in _claves(v)}
return set()
@pytest.mark.parametrize("escenario", ESCENARIOS)
def test_todos_los_escenarios_se_renderizan_completos(escenario):
ejemplo = _ejemplos()[escenario]
html = plantillas.renderizar_plantilla(ejemplo["plantilla"], ejemplo["contexto"])
assert "{{" not in html and "}}" not in html # nada sin resolver
assert ejemplo["contexto"]["codigo_seguimiento"] in html
assert html.lstrip().startswith("<!DOCTYPE html>")
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado"])
def test_los_ejemplos_cubren_las_variables_de_la_plantilla(nombre):
"""Si la plantilla pide algo nuevo (o un ejemplo sobra), el desfase se nota aquí."""
usados = plantillas.nombres_usados(plantillas.cargar_plantilla(nombre))
ejemplos = [e["contexto"] for e in _ejemplos().values() if e["plantilla"] == nombre]
assert ejemplos, f"No hay ejemplos para la plantilla {nombre}"
cubiertos = set().union(*(_claves(c) for c in ejemplos)) # el ejemplo sin guías no trae las claves de cada guía
assert usados <= cubiertos, f"Ningún ejemplo trae: {sorted(usados - cubiertos)}"
for contexto in ejemplos:
sobran = _claves(contexto) - usados
assert not sobran, f"Sobran en el ejemplo: {sorted(sobran)}"
def test_razon_social_con_caracteres_especiales_se_escapa():
ejemplo = _ejemplos()["rechazado_una_observacion"]
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
assert "&lt;Lima&gt;" in html and "&quot;EL RÁPIDO&quot;" in html
assert "<Lima>" not in html
def test_aceptado_muestra_pago_estimado_solo_si_hay_fecha():
ejemplos = _ejemplos()
con = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_con_pago"]["contexto"])
sin = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_sin_pago"]["contexto"])
assert "Pago estimado" in con and "18/11/2026" in con
assert "Pago estimado" not in sin
def test_rechazado_numera_las_observaciones_y_marca_las_guias():
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
assert "OBSERVACIÓN 1" in html and "OBSERVACIÓN 2" in html
assert "OBSERVACIÓN 3" not in html
assert html.count("Conforme") == 1 and html.count("No coincide") == 1
assert "Rechazado · 2 observaciones" in html
def test_enlaces_de_contacto_llevan_el_codigo_y_el_comprobante():
contexto = _ejemplos()["rechazado_dos_observaciones"]["contexto"]
html = plantillas.renderizar_plantilla("rechazado", contexto)
assert f"mailto:{contexto['contacto_correo']}?subject=Consulta%20F002-486%20ABR-261019-00045" in html
assert f'href="mailto:{contexto["envio_correo"]}"' in html
def test_rechazo_sin_comprobante_oculta_lo_que_no_existe():
ejemplo = _ejemplos()["rechazado_sin_comprobante"]
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
assert "Guías revisadas" not in html # sin tabla de guías vacía
assert "Comprobante</td>" not in html # sin la fila "Comprobante —"
assert "RUC —" not in html and html.count("RUC") == 1 # solo el RUC del pie (empresa)
assert "No pudimos registrar su comprobante" in html
def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante():
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
assert "Guías revisadas" in html and "RUC 20537929520" in html and "Comprobante</td>" in html
+116
View File
@@ -0,0 +1,116 @@
"""
Renderiza las plantillas de correo (plantilla/plantilla-*.html) con los datos de
ejemplo de plantilla/ejemplos.json, para revisarlas en el navegador o enviarse
un correo de prueba.
Uso (desde cualquier directorio):
python tools/previsualizar_plantillas.py --listar
python tools/previsualizar_plantillas.py --abrir # todos los escenarios
python tools/previsualizar_plantillas.py --escenario aceptado_con_pago --abrir
python tools/previsualizar_plantillas.py --escenario rechazado_dos_observaciones --enviar yo@dominio.com otro@dominio.com
Los HTML se guardan en tools/previews/ (ignorada por git). Para probar otros textos,
edita plantilla/ejemplos.json: el renderizado es estricto y avisa si falta algún dato.
``--enviar`` manda el correo real con Microsoft Graph desde el buzón EMAIL del .env,
con el asunto prefijado con "[PRUEBA]". Exige indicar el escenario (o ``todos``).
En el correo enviado el logo queda vacío mientras ``logo_url`` no tenga una URL pública.
"""
import argparse
import json
import os
import sys
import webbrowser
RAIZ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
sys.path.insert(0, RAIZ)
from dotenv import load_dotenv
import plantillas
RUTA_EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json")
SALIDA_POR_DEFECTO = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
# Solo para la vista previa local: los clientes de correo no muestran imágenes data:
LOGO_PROVISIONAL = (
"data:image/svg+xml;utf8,"
"<svg xmlns='http://www.w3.org/2000/svg' width='117' height='28'>"
"<rect width='117' height='28' fill='%23FF6900'/>"
"<text x='58' y='19' font-family='Arial' font-size='14' font-weight='bold' fill='%23282828' text-anchor='middle'>Aberio</text>"
"</svg>"
)
def cargar_ejemplos() -> dict:
with open(RUTA_EJEMPLOS, encoding="utf-8") as f:
return json.load(f)
def seleccionar(ejemplos: dict, escenario: str | None) -> dict:
if escenario in (None, "todos"):
return ejemplos
if escenario not in ejemplos:
sys.exit(f"❌ Escenario '{escenario}' no existe. Disponibles: {', '.join(ejemplos)}")
return {escenario: ejemplos[escenario]}
def renderizar(ejemplo: dict, para_navegador: bool) -> str:
contexto = dict(ejemplo["contexto"])
if para_navegador and not contexto.get("logo_url"):
contexto["logo_url"] = LOGO_PROVISIONAL
return plantillas.renderizar_plantilla(ejemplo["plantilla"], contexto)
def main() -> None:
parser = argparse.ArgumentParser(description="Vista previa y envío de prueba de las plantillas de correo.")
parser.add_argument("--listar", action="store_true", help="Lista los escenarios disponibles y termina.")
parser.add_argument("--escenario", "-e", help="Nombre del escenario, o 'todos'. Por defecto, todos (obligatorio con --enviar).")
parser.add_argument("--salida", default=SALIDA_POR_DEFECTO, help="Carpeta donde se guardan los HTML.")
parser.add_argument("--abrir", action="store_true", help="Abre cada HTML en el navegador.")
parser.add_argument("--enviar", metavar="CORREO", nargs="+", help="Envía el escenario a estos correos como prueba (uno o varios).")
args = parser.parse_args()
ejemplos = cargar_ejemplos()
if args.listar:
for nombre, e in ejemplos.items():
print(f"{nombre:32} ({e['plantilla']}) {e['contexto']['asunto']}")
return
if args.enviar and not args.escenario:
sys.exit("❌ Con --enviar indica el escenario: --escenario <nombre> (o --escenario todos).")
elegidos = seleccionar(ejemplos, args.escenario)
os.makedirs(args.salida, exist_ok=True)
for nombre, ejemplo in elegidos.items():
ruta = os.path.join(args.salida, f"{nombre}.html")
with open(ruta, "w", encoding="utf-8") as f:
f.write(renderizar(ejemplo, para_navegador=True))
print(f"📄 {os.path.relpath(ruta)}")
if args.abrir:
webbrowser.open("file://" + os.path.abspath(ruta))
if args.enviar:
import outlook_utilities
load_dotenv(dotenv_path=os.path.join(RAIZ, ".env"))
buzon = os.getenv("EMAIL", "")
if not buzon:
sys.exit("❌ EMAIL no configurado en .env")
try:
token = outlook_utilities.get_access_token()
except Exception as ex:
sys.exit(f"❌ No se pudo obtener el token de Microsoft Graph, no se envió nada:\n {ex}")
if not token:
sys.exit("❌ No se pudo obtener el token de Microsoft Graph, no se envió nada")
for nombre, ejemplo in elegidos.items():
asunto = f"[PRUEBA] {ejemplo['contexto']['asunto']}"
ok = outlook_utilities.send_email(token, buzon, args.enviar, asunto, renderizar(ejemplo, para_navegador=False))
print(f"{'✉️ Enviado' if ok else '❌ Falló'}: {asunto} → {', '.join(args.enviar)}")
if __name__ == "__main__":
main()