feat(pos): add real thermal ticket printing via ESC/POS (#21)
- Add thermal_printer.py service generating raw ESC/POS bytes for 58mm/80mm printers - Add /pos/api/sales/<id>/print endpoint (escpos_raw or browser mode) - Add printer.js with WebUSB and Web Serial support for direct browser-to-printer - Add thermal print button in ticket modal with connect/print workflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1698,3 +1698,96 @@ def push_test():
|
||||
if ok:
|
||||
return jsonify({'message': 'Test notification sent'})
|
||||
return jsonify({'error': 'No subscription found or push failed'}), 400
|
||||
|
||||
|
||||
# ─── Thermal Printing ──────────────────────────────
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/print', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def print_ticket(sale_id):
|
||||
"""Generate a printable ticket for a sale.
|
||||
|
||||
Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80}
|
||||
- escpos_raw: returns raw ESC/POS bytes (application/octet-stream)
|
||||
- browser: returns printable HTML fragment (text/html)
|
||||
"""
|
||||
from flask import Response
|
||||
from services.thermal_printer import generate_ticket
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
printer_type = body.get('printer_type', 'escpos_raw')
|
||||
width = int(body.get('width', 80))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Fetch sale
|
||||
cur.execute("""
|
||||
SELECT s.*, e.name as employee_name, c.name as customer_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
WHERE s.id = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
|
||||
cols = [desc[0] for desc in cur.description]
|
||||
sale = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
|
||||
if sale.get(k) is not None:
|
||||
sale[k] = float(sale[k])
|
||||
|
||||
# Fetch items
|
||||
cur.execute("""
|
||||
SELECT name, quantity, unit_price, subtotal
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'name': r[0], 'quantity': r[1],
|
||||
'unit_price': float(r[2]) if r[2] else 0,
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
})
|
||||
|
||||
# Fetch business info from config
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
else:
|
||||
business_info[rw[0]] = rw[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
sale_data = {
|
||||
'folio': f'V-{sale["id"]}',
|
||||
'date': str(sale.get('created_at', '')),
|
||||
'employee': sale.get('employee_name', ''),
|
||||
'customer': sale.get('customer_name', ''),
|
||||
'items': items,
|
||||
'subtotal': sale.get('subtotal', 0),
|
||||
'discount_total': sale.get('discount_total', 0),
|
||||
'tax_total': sale.get('tax_total', 0),
|
||||
'total': sale.get('total', 0),
|
||||
'payment_method': sale.get('payment_method', 'efectivo'),
|
||||
'amount_paid': sale.get('amount_paid'),
|
||||
'change_given': sale.get('change_given'),
|
||||
}
|
||||
|
||||
if printer_type == 'browser':
|
||||
# Return the sale data as JSON for browser-side rendering
|
||||
return jsonify(sale_data)
|
||||
|
||||
# Default: ESC/POS raw bytes
|
||||
raw = generate_ticket(sale_data, business_info, width=width)
|
||||
return Response(raw, mimetype='application/octet-stream',
|
||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||
|
||||
Reference in New Issue
Block a user