feat(pos): add 5 quick improvements — dark mode, email quotes, barcode scan, returns, offline catalog
1. Auto dark mode: detect system prefers-color-scheme, auto-switch industrial/modern theme 2. Email quotation endpoint: POST /quotations/:id/email sends HTML email via SMTP 3. Camera barcode scanner: BarcodeDetector API with getUserMedia overlay in catalog 4. Returns with warranty: POST /returns endpoint with stock restoration and sale status tracking 5. Partial offline catalog: cache top 500 parts in IndexedDB, search when offline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -595,6 +595,178 @@ def get_quotation(quot_id):
|
||||
return jsonify(quot)
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_quotation_pdf(quot_id):
|
||||
"""Get printable HTML for a quotation (browser print-to-PDF)."""
|
||||
from services.pdf_generator import generate_quote_html
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get quotation
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.created_at, q.notes, q.customer_id, q.employee_id,
|
||||
e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN employees e ON q.employee_id = e.id
|
||||
WHERE q.id = %s
|
||||
""", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
cols = [desc[0] for desc in cur.description]
|
||||
quot = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'tax_total', 'total'):
|
||||
if quot.get(k) is not None:
|
||||
quot[k] = float(quot[k])
|
||||
if quot.get('created_at'):
|
||||
quot['created_at'] = str(quot['created_at'])
|
||||
if quot.get('valid_until'):
|
||||
quot['valid_until'] = str(quot['valid_until'])
|
||||
|
||||
# Get items
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (quot_id,))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||
'unit_price': float(r[3]) if r[3] else 0,
|
||||
'discount_pct': float(r[4]) if r[4] else 0,
|
||||
'tax_rate': float(r[5]) if r[5] else 0,
|
||||
'subtotal': float(r[6]) if r[6] else 0,
|
||||
})
|
||||
|
||||
# Get customer info
|
||||
customer_info = None
|
||||
if quot.get('customer_id'):
|
||||
cur.execute("""
|
||||
SELECT name, rfc, phone, email FROM customers WHERE id = %s
|
||||
""", (quot['customer_id'],))
|
||||
cust = cur.fetchone()
|
||||
if cust:
|
||||
customer_info = {'name': cust[0], 'rfc': cust[1], 'phone': cust[2], 'email': cust[3]}
|
||||
|
||||
# Get business info from tenant config
|
||||
business_info = None
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address','phone','email')")
|
||||
config_rows = cur.fetchall()
|
||||
if config_rows:
|
||||
business_info = {r[0]: r[1] for r in config_rows}
|
||||
business_info['name'] = business_info.pop('business_name', '')
|
||||
except Exception:
|
||||
pass # config table may not exist
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
html = generate_quote_html(quot, items, business_info, customer_info)
|
||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/email', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def email_quotation(quot_id):
|
||||
"""Send a quotation as HTML email.
|
||||
|
||||
Body: {email: str}
|
||||
"""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import config
|
||||
|
||||
data = request.get_json() or {}
|
||||
email_to = data.get('email', '').strip()
|
||||
if not email_to or '@' not in email_to:
|
||||
return jsonify({'error': 'Valid email address required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.notes, q.created_at, c.name as customer_name, e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
LEFT JOIN employees e ON q.employee_id = e.id
|
||||
WHERE q.id = %s
|
||||
""", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
q_id, subtotal, tax_total, total, valid_until, notes, created_at, cust_name, emp_name = row
|
||||
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (quot_id,))
|
||||
items = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
|
||||
# Build HTML email
|
||||
items_html = ''
|
||||
for it in items:
|
||||
items_html += (
|
||||
f'<tr><td>{it[0]}</td><td>{it[1]}</td><td style="text-align:center">{it[2]}</td>'
|
||||
f'<td style="text-align:right">${float(it[3]):,.2f}</td>'
|
||||
f'<td style="text-align:right">${float(it[6]):,.2f}</td></tr>'
|
||||
)
|
||||
|
||||
html_body = f"""
|
||||
<html><body style="font-family:Arial,sans-serif;color:#333;">
|
||||
<h2>Cotizacion #{q_id} - Nexus Autoparts</h2>
|
||||
<p><strong>Cliente:</strong> {cust_name or 'Publico general'}</p>
|
||||
<p><strong>Vendedor:</strong> {emp_name or '-'}</p>
|
||||
<p><strong>Fecha:</strong> {created_at}</p>
|
||||
<p><strong>Vigencia:</strong> {valid_until or 'N/A'}</p>
|
||||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%;">
|
||||
<tr style="background:#f5a623;color:#fff;">
|
||||
<th>No. Parte</th><th>Descripcion</th><th>Cant.</th><th>P. Unit.</th><th>Subtotal</th>
|
||||
</tr>
|
||||
{items_html}
|
||||
</table>
|
||||
<p style="text-align:right;margin-top:12px;">
|
||||
<strong>Subtotal:</strong> ${float(subtotal):,.2f}<br>
|
||||
<strong>IVA:</strong> ${float(tax_total):,.2f}<br>
|
||||
<strong style="font-size:1.2em;">Total: ${float(total):,.2f}</strong>
|
||||
</p>
|
||||
{f'<p><em>Notas: {notes}</em></p>' if notes else ''}
|
||||
<p style="color:#888;font-size:12px;">Este es un documento informativo, no tiene validez fiscal.</p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = f'Cotizacion #{q_id} - Nexus Autoparts'
|
||||
msg['From'] = config.SMTP_FROM
|
||||
msg['To'] = email_to
|
||||
msg.attach(MIMEText(html_body, 'html'))
|
||||
|
||||
if not config.SMTP_USER:
|
||||
return jsonify({'error': 'SMTP not configured on server'}), 503
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server:
|
||||
server.starttls()
|
||||
server.login(config.SMTP_USER, config.SMTP_PASS)
|
||||
server.sendmail(config.SMTP_FROM, [email_to], msg.as_string())
|
||||
|
||||
log_action(get_tenant_conn(g.tenant_id), 'QUOTATION_EMAIL', 'quotation', quot_id,
|
||||
new_value={'email': email_to})
|
||||
|
||||
return jsonify({'message': f'Quotation #{q_id} sent to {email_to}'})
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to send email: {str(e)}'}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/convert', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def convert_quotation(quot_id):
|
||||
@@ -1236,3 +1408,293 @@ def cancel_layaway(layaway_id):
|
||||
'items_unreserved': len(layaway_items),
|
||||
'note': 'Stock reservations reversed. Refund of paid amount must be processed separately.'
|
||||
})
|
||||
|
||||
|
||||
# ─── Returns / Warranty ───────────────────────────
|
||||
|
||||
@pos_bp.route('/returns', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def create_return():
|
||||
"""Process a product return with warranty support.
|
||||
|
||||
Body: {
|
||||
sale_id: int,
|
||||
items: [{sale_item_id: int, quantity: int, reason: str}],
|
||||
notes: str
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_id = data.get('sale_id')
|
||||
items = data.get('items', [])
|
||||
notes = data.get('notes', '')
|
||||
|
||||
if not sale_id:
|
||||
return jsonify({'error': 'sale_id is required'}), 400
|
||||
if not items:
|
||||
return jsonify({'error': 'At least one return item required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Validate sale exists and is completed
|
||||
cur.execute("""
|
||||
SELECT id, customer_id, total, status, branch_id
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
sale = cur.fetchone()
|
||||
if not sale:
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
if sale[3] not in ('completed', 'partially_returned'):
|
||||
return jsonify({'error': f'Cannot return items from a {sale[3]} sale'}), 400
|
||||
|
||||
sale_customer_id = sale[1]
|
||||
sale_branch_id = sale[4] or g.branch_id
|
||||
|
||||
# Validate each return item against original sale items
|
||||
total_refund = 0
|
||||
validated_items = []
|
||||
|
||||
for ri in items:
|
||||
si_id = ri.get('sale_item_id')
|
||||
ret_qty = int(ri.get('quantity', 0))
|
||||
reason = ri.get('reason', '').strip()
|
||||
|
||||
if ret_qty <= 0:
|
||||
raise ValueError(f'Invalid return quantity for sale_item_id {si_id}')
|
||||
if not reason:
|
||||
raise ValueError(f'Reason required for sale_item_id {si_id}')
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM sale_items WHERE id = %s AND sale_id = %s
|
||||
""", (si_id, sale_id))
|
||||
si = cur.fetchone()
|
||||
if not si:
|
||||
raise ValueError(f'Sale item {si_id} not found in sale #{sale_id}')
|
||||
|
||||
original_qty = si[2]
|
||||
|
||||
# Check how much has already been returned for this sale_item
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(ri2.quantity), 0)
|
||||
FROM return_items ri2
|
||||
JOIN returns r ON ri2.return_id = r.id
|
||||
WHERE ri2.sale_item_id = %s AND r.status = 'completed'
|
||||
""", (si_id,))
|
||||
already_returned = cur.fetchone()[0]
|
||||
|
||||
remaining = original_qty - already_returned
|
||||
if ret_qty > remaining:
|
||||
raise ValueError(
|
||||
f'Cannot return {ret_qty} of sale_item {si_id} — only {remaining} remaining'
|
||||
)
|
||||
|
||||
unit_price = float(si[3])
|
||||
discount_pct = float(si[4]) if si[4] else 0
|
||||
tax_rate = float(si[5]) if si[5] else 0.16
|
||||
price_after_discount = unit_price * (1 - discount_pct / 100)
|
||||
refund_amount = round(ret_qty * price_after_discount * (1 + tax_rate), 2)
|
||||
total_refund += refund_amount
|
||||
|
||||
validated_items.append({
|
||||
'sale_item_id': si_id,
|
||||
'inventory_id': si[1],
|
||||
'quantity': ret_qty,
|
||||
'unit_price': unit_price,
|
||||
'refund_amount': refund_amount,
|
||||
'reason': reason,
|
||||
})
|
||||
|
||||
# Create return record
|
||||
cur.execute("""
|
||||
INSERT INTO returns (sale_id, customer_id, employee_id, total_refund, reason, status)
|
||||
VALUES (%s, %s, %s, %s, %s, 'completed')
|
||||
RETURNING id
|
||||
""", (sale_id, sale_customer_id, g.employee_id, total_refund, notes or 'Devolucion'))
|
||||
|
||||
return_id = cur.fetchone()[0]
|
||||
|
||||
# Create return items and restore inventory
|
||||
from services.inventory_engine import record_operation
|
||||
|
||||
for vi in validated_items:
|
||||
cur.execute("""
|
||||
INSERT INTO return_items
|
||||
(return_id, sale_item_id, inventory_id, quantity, unit_price, refund_amount)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (return_id, vi['sale_item_id'], vi['inventory_id'],
|
||||
vi['quantity'], vi['unit_price'], vi['refund_amount']))
|
||||
|
||||
# Return stock to inventory
|
||||
record_operation(
|
||||
conn, vi['inventory_id'], sale_branch_id,
|
||||
operation_type='RETURN',
|
||||
quantity=vi['quantity'],
|
||||
notes=f'Devolucion #{return_id} de venta #{sale_id}: {vi["reason"]}'
|
||||
)
|
||||
|
||||
# Update sale status if all items returned
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(ri2.quantity), 0), COALESCE(SUM(si2.quantity), 0)
|
||||
FROM sale_items si2
|
||||
LEFT JOIN (
|
||||
SELECT sale_item_id, SUM(quantity) as quantity
|
||||
FROM return_items ri3
|
||||
JOIN returns r2 ON ri3.return_id = r2.id
|
||||
WHERE r2.sale_id = %s AND r2.status = 'completed'
|
||||
GROUP BY sale_item_id
|
||||
) ri2 ON ri2.sale_item_id = si2.id
|
||||
WHERE si2.sale_id = %s
|
||||
""", (sale_id, sale_id))
|
||||
returned_total, sold_total = cur.fetchone()
|
||||
new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
|
||||
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
|
||||
|
||||
# Update customer credit if applicable
|
||||
if sale_customer_id:
|
||||
cur.execute("""
|
||||
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) + %s
|
||||
WHERE id = %s
|
||||
""", (total_refund, sale_customer_id))
|
||||
|
||||
log_action(conn, 'RETURN_CREATE', 'return', return_id,
|
||||
new_value={
|
||||
'sale_id': sale_id,
|
||||
'total_refund': total_refund,
|
||||
'items_count': len(validated_items),
|
||||
'sale_status': new_status
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'id': return_id,
|
||||
'sale_id': sale_id,
|
||||
'total_refund': total_refund,
|
||||
'items': validated_items,
|
||||
'sale_status': new_status,
|
||||
'message': f'Return #{return_id} created — ${total_refund:,.2f} refund'
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/returns', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def list_returns():
|
||||
"""List returns with optional filters."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
sale_id = request.args.get('sale_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
if sale_id:
|
||||
where_clauses.append("r.sale_id = %s")
|
||||
params.append(int(sale_id))
|
||||
if customer_id:
|
||||
where_clauses.append("r.customer_id = %s")
|
||||
params.append(int(customer_id))
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM returns r WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT r.id, r.sale_id, r.customer_id, r.employee_id, r.total_refund,
|
||||
r.reason, r.status, r.created_at,
|
||||
e.name as employee_name, c.name as customer_name
|
||||
FROM returns r
|
||||
LEFT JOIN employees e ON r.employee_id = e.id
|
||||
LEFT JOIN customers c ON r.customer_id = c.id
|
||||
WHERE {where}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
|
||||
returns = []
|
||||
for row in cur.fetchall():
|
||||
returns.append({
|
||||
'id': row[0], 'sale_id': row[1], 'customer_id': row[2],
|
||||
'employee_id': row[3], 'total_refund': float(row[4]) if row[4] else 0,
|
||||
'reason': row[5], 'status': row[6], 'created_at': str(row[7]),
|
||||
'employee_name': row[8], 'customer_name': row[9],
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return jsonify({
|
||||
'data': returns,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||
})
|
||||
|
||||
|
||||
# ─── Push Notifications ───────────────────────────
|
||||
|
||||
@pos_bp.route('/push/subscribe', methods=['POST'])
|
||||
@require_auth('pos.view')
|
||||
def push_subscribe():
|
||||
"""Save push subscription for current employee.
|
||||
|
||||
Body: {subscription: <PushSubscription JSON from browser>}
|
||||
"""
|
||||
from services.push_service import save_subscription, ensure_push_table, get_or_create_vapid_keys
|
||||
|
||||
data = request.get_json() or {}
|
||||
subscription = data.get('subscription')
|
||||
if not subscription:
|
||||
return jsonify({'error': 'subscription required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
ensure_push_table(conn)
|
||||
save_subscription(conn, g.employee_id, subscription)
|
||||
conn.close()
|
||||
return jsonify({'message': 'Push subscription saved'})
|
||||
|
||||
|
||||
@pos_bp.route('/push/vapid-key', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def push_vapid_key():
|
||||
"""Get the VAPID public key for push subscription."""
|
||||
from services.push_service import get_or_create_vapid_keys, ensure_push_table
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
ensure_push_table(conn)
|
||||
_, public_key = get_or_create_vapid_keys(conn)
|
||||
conn.close()
|
||||
|
||||
if not public_key:
|
||||
return jsonify({'error': 'Push not available (pywebpush not installed)'}), 503
|
||||
return jsonify({'public_key': public_key})
|
||||
|
||||
|
||||
@pos_bp.route('/push/test', methods=['POST'])
|
||||
@require_auth('pos.view')
|
||||
def push_test():
|
||||
"""Send a test push notification to the current employee."""
|
||||
from services.push_service import send_push, ensure_push_table
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
ensure_push_table(conn)
|
||||
ok = send_push(conn, g.employee_id, 'Prueba Nexus POS',
|
||||
'Las notificaciones push estan funcionando correctamente.', '/pos')
|
||||
conn.close()
|
||||
|
||||
if ok:
|
||||
return jsonify({'message': 'Test notification sent'})
|
||||
return jsonify({'error': 'No subscription found or push failed'}), 400
|
||||
|
||||
Reference in New Issue
Block a user