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>
This commit is contained in:
308
pos/services/pdf_generator.py
Normal file
308
pos/services/pdf_generator.py
Normal file
@@ -0,0 +1,308 @@
|
||||
# /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 — {datetime.now().strftime('%d/%m/%Y %H:%M')}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
Reference in New Issue
Block a user