Files
Autoparts-DB/pos/services/pdf_generator.py
consultoria-as 5d5a2777eb feat(pos): add 3 improvements — Spanish translations, PDF quotes, push notifications
1. Spanish translations for TecDoc catalog (translations.py) applied to
   catalog_service.py and dashboard server.py endpoints
2. Printable quotation HTML endpoint (/pos/api/quotations/<id>/pdf) with
   @media print CSS for clean browser-to-PDF output
3. Web Push notifications to owner/admin on sale cancellation, stock zero,
   and cash register differences > $500. Includes service worker, VAPID
   key management, and subscription endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:05:11 +00:00

309 lines
8.3 KiB
Python

# /home/Autopartes/pos/services/pdf_generator.py
"""Generate printable HTML for quotations (browser print-to-PDF).
Returns self-contained HTML with @media print CSS for clean PDF output.
No external dependencies required — uses the browser's built-in print.
"""
from datetime import datetime
def generate_quote_html(quotation, items, business_info=None, customer_info=None):
"""Generate printable HTML for a quotation.
Args:
quotation: dict with keys: id, subtotal, tax_total, total, valid_until, created_at, notes, employee_name
items: list of dicts: part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
business_info: dict with keys: name, rfc, address, phone, email (optional)
customer_info: dict with keys: name, rfc, phone, email (optional)
Returns:
str: Complete HTML document ready for printing
"""
biz = business_info or {}
biz_name = biz.get('name', 'Autopartes Nexus')
biz_rfc = biz.get('rfc', '')
biz_address = biz.get('address', '')
biz_phone = biz.get('phone', '')
biz_email = biz.get('email', '')
cust = customer_info or {}
cust_name = cust.get('name', 'Publico en General')
cust_rfc = cust.get('rfc', 'XAXX010101000')
cust_phone = cust.get('phone', '')
cust_email = cust.get('email', '')
quot_id = quotation.get('id', 0)
created = quotation.get('created_at', '')
valid_until = quotation.get('valid_until', '')
notes = quotation.get('notes', '')
employee_name = quotation.get('employee_name', '')
subtotal = float(quotation.get('subtotal', 0))
tax_total = float(quotation.get('tax_total', 0))
total = float(quotation.get('total', 0))
# Build items rows
items_html = ''
for i, item in enumerate(items, 1):
qty = item.get('quantity', 1)
price = float(item.get('unit_price', 0))
disc = float(item.get('discount_pct', 0))
line_sub = float(item.get('subtotal', qty * price))
pn = item.get('part_number', '')
name = item.get('name', '')
disc_str = f'{disc:.0f}%' if disc > 0 else '-'
items_html += f"""
<tr>
<td style="text-align:center">{i}</td>
<td>{qty}</td>
<td class="mono">{pn}</td>
<td>{name}</td>
<td class="right">${price:,.2f}</td>
<td class="right">{disc_str}</td>
<td class="right"><strong>${line_sub:,.2f}</strong></td>
</tr>"""
try:
created_fmt = datetime.fromisoformat(str(created).replace('Z', '+00:00')).strftime('%d/%m/%Y %H:%M')
except Exception:
created_fmt = str(created)[:16] if created else ''
try:
valid_fmt = datetime.fromisoformat(str(valid_until)).strftime('%d/%m/%Y')
except Exception:
valid_fmt = str(valid_until)[:10] if valid_until else ''
return f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Cotizacion #{quot_id}</title>
<style>
@page {{
size: letter;
margin: 1.5cm;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 11pt;
color: #222;
line-height: 1.4;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 3px solid #1a237e;
padding-bottom: 12px;
margin-bottom: 16px;
}}
.header h1 {{
color: #1a237e;
font-size: 22pt;
margin-bottom: 4px;
}}
.header .biz-info {{
font-size: 9pt;
color: #555;
}}
.header .quot-info {{
text-align: right;
}}
.header .quot-number {{
font-size: 16pt;
font-weight: bold;
color: #1a237e;
}}
.header .quot-date {{
font-size: 9pt;
color: #555;
}}
.parties {{
display: flex;
gap: 30px;
margin-bottom: 16px;
}}
.parties .box {{
flex: 1;
border: 1px solid #ccc;
border-radius: 6px;
padding: 10px 14px;
}}
.parties .box h3 {{
font-size: 9pt;
text-transform: uppercase;
color: #888;
margin-bottom: 6px;
letter-spacing: 0.5px;
}}
.parties .box p {{
margin-bottom: 2px;
font-size: 10pt;
}}
.parties .box .name {{
font-weight: bold;
font-size: 11pt;
}}
table {{
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}}
thead th {{
background: #1a237e;
color: white;
padding: 8px 10px;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.3px;
text-align: left;
}}
thead th.right {{ text-align: right; }}
tbody td {{
padding: 6px 10px;
border-bottom: 1px solid #e0e0e0;
font-size: 10pt;
vertical-align: top;
}}
tbody tr:nth-child(even) {{ background: #f8f9fa; }}
.right {{ text-align: right; }}
.mono {{ font-family: 'Courier New', monospace; font-size: 9pt; }}
.totals {{
width: 280px;
margin-left: auto;
border: 1px solid #ccc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 20px;
}}
.totals tr td {{
padding: 6px 14px;
border-bottom: 1px solid #eee;
font-size: 10pt;
}}
.totals tr:last-child td {{
background: #1a237e;
color: white;
font-size: 13pt;
font-weight: bold;
padding: 10px 14px;
border-bottom: none;
}}
.totals .label {{ text-align: left; }}
.totals .value {{ text-align: right; }}
.footer {{
border-top: 1px solid #ccc;
padding-top: 12px;
font-size: 9pt;
color: #666;
}}
.footer .validity {{
background: #fff3e0;
border: 1px solid #ffb74d;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 10px;
color: #e65100;
font-weight: 500;
}}
.footer .notes {{
margin-bottom: 8px;
}}
.no-print {{
text-align: center;
margin-bottom: 20px;
}}
.no-print button {{
background: #1a237e;
color: white;
border: none;
padding: 10px 30px;
border-radius: 6px;
font-size: 12pt;
cursor: pointer;
}}
.no-print button:hover {{ background: #283593; }}
@media print {{
.no-print {{ display: none !important; }}
body {{ padding: 0; }}
.header {{ break-after: avoid; }}
tbody tr {{ break-inside: avoid; }}
}}
</style>
</head>
<body>
<div class="no-print">
<button onclick="window.print()">Imprimir / Guardar PDF</button>
</div>
<div class="header">
<div>
<h1>{biz_name}</h1>
<div class="biz-info">
{f'RFC: {biz_rfc}<br>' if biz_rfc else ''}
{f'{biz_address}<br>' if biz_address else ''}
{f'Tel: {biz_phone}<br>' if biz_phone else ''}
{f'{biz_email}' if biz_email else ''}
</div>
</div>
<div class="quot-info">
<div class="quot-number">COTIZACION #{quot_id}</div>
<div class="quot-date">
Fecha: {created_fmt}<br>
{f'Vendedor: {employee_name}' if employee_name else ''}
</div>
</div>
</div>
<div class="parties">
<div class="box">
<h3>Cliente</h3>
<p class="name">{cust_name}</p>
{f'<p>RFC: {cust_rfc}</p>' if cust_rfc else ''}
{f'<p>Tel: {cust_phone}</p>' if cust_phone else ''}
{f'<p>{cust_email}</p>' if cust_email else ''}
</div>
</div>
<table>
<thead>
<tr>
<th style="width:40px;text-align:center">#</th>
<th style="width:45px">Cant</th>
<th style="width:120px">No. Parte</th>
<th>Descripcion</th>
<th class="right" style="width:90px">P. Unit.</th>
<th class="right" style="width:60px">Desc.</th>
<th class="right" style="width:100px">Subtotal</th>
</tr>
</thead>
<tbody>
{items_html}
</tbody>
</table>
<table class="totals">
<tr><td class="label">Subtotal:</td><td class="value">${subtotal:,.2f}</td></tr>
<tr><td class="label">IVA (16%):</td><td class="value">${tax_total:,.2f}</td></tr>
<tr><td class="label">TOTAL:</td><td class="value">${total:,.2f} MXN</td></tr>
</table>
<div class="footer">
{f'<div class="validity">Cotizacion valida hasta: <strong>{valid_fmt}</strong>. Precios sujetos a cambio despues de esta fecha.</div>' if valid_fmt else ''}
{f'<div class="notes"><strong>Notas:</strong> {notes}</div>' if notes else ''}
<p style="text-align:center;margin-top:30px;color:#999">
Este documento es una cotizacion, no un comprobante fiscal.<br>
Generado por Nexus POS &mdash; {datetime.now().strftime('%d/%m/%Y %H:%M')}
</p>
</div>
</body>
</html>"""