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>
309 lines
8.3 KiB
Python
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 — {datetime.now().strftime('%d/%m/%Y %H:%M')}
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>"""
|