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:
43
pos/app.py
43
pos/app.py
@@ -157,6 +157,49 @@ def create_app():
|
||||
conn.close()
|
||||
return jsonify({'items': items, 'count': len(items)})
|
||||
|
||||
@app.route('/pos/api/sync/top-parts', methods=['GET'])
|
||||
@_require_auth()
|
||||
def sync_top_parts():
|
||||
"""Get top 500 most-sold parts for offline catalog cache."""
|
||||
from tenant_db import get_tenant_conn
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
branch_id = g.branch_id
|
||||
|
||||
cur.execute("""
|
||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
|
||||
i.category, COALESCE(s.stock, 0) AS stock,
|
||||
COALESCE(sv.total_sold, 0) AS total_sold
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
||||
FROM inventory_operations GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
LEFT JOIN (
|
||||
SELECT si.inventory_id, SUM(si.quantity) AS total_sold
|
||||
FROM sale_items si
|
||||
JOIN sales sa ON si.sale_id = sa.id
|
||||
WHERE sa.status IN ('completed', 'partially_returned')
|
||||
GROUP BY si.inventory_id
|
||||
) sv ON sv.inventory_id = i.id
|
||||
WHERE i.is_active = true AND i.branch_id = %s
|
||||
ORDER BY COALESCE(sv.total_sold, 0) DESC
|
||||
LIMIT 500
|
||||
""", [branch_id])
|
||||
|
||||
parts = []
|
||||
for r in cur.fetchall():
|
||||
parts.append({
|
||||
'part_number': r[0], 'name': r[1], 'brand': r[2],
|
||||
'price': float(r[3]) if r[3] else 0,
|
||||
'tax_rate': float(r[4]) if r[4] else 0.16,
|
||||
'category': r[5] or '',
|
||||
'stock': r[6], 'total_sold': r[7]
|
||||
})
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'parts': parts, 'count': len(parts)})
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,13 @@ OPENROUTER_API_KEY = os.environ.get(
|
||||
"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"
|
||||
)
|
||||
|
||||
# SMTP for email quotations / notifications
|
||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
|
||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
|
||||
SMTP_USER = os.environ.get('SMTP_USER', '')
|
||||
SMTP_PASS = os.environ.get('SMTP_PASS', '')
|
||||
SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com')
|
||||
|
||||
# WhatsApp Business Cloud API
|
||||
WHATSAPP_TOKEN = os.environ.get("WHATSAPP_TOKEN", "")
|
||||
WHATSAPP_PHONE_ID = os.environ.get("WHATSAPP_PHONE_ID", "")
|
||||
|
||||
32
pos/migrations/v1.5_returns.sql
Normal file
32
pos/migrations/v1.5_returns.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- v1.5 Returns & warranty support
|
||||
-- Applied to each tenant database
|
||||
|
||||
CREATE TABLE IF NOT EXISTS returns (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sale_id INTEGER REFERENCES sales(id),
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
employee_id INTEGER REFERENCES employees(id),
|
||||
total_refund NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS return_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
return_id INTEGER REFERENCES returns(id) ON DELETE CASCADE,
|
||||
sale_item_id INTEGER,
|
||||
inventory_id INTEGER REFERENCES inventory(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price NUMERIC(12,2),
|
||||
refund_amount NUMERIC(12,2)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_returns_sale_id ON returns(sale_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_returns_customer_id ON returns(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_returns_created_at ON returns(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_return_items_return_id ON return_items(return_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_return_items_sale_item_id ON return_items(sale_item_id);
|
||||
|
||||
-- Add 'partially_returned' and 'returned' to sales status if not using enum
|
||||
-- (sales.status is VARCHAR, so no ALTER TYPE needed)
|
||||
@@ -128,8 +128,16 @@
|
||||
});
|
||||
|
||||
// ─── Theme management ───
|
||||
// Persist theme in localStorage, apply on load
|
||||
var savedTheme = localStorage.getItem('pos_theme') || 'industrial';
|
||||
// Determine theme: saved preference > system preference > default 'industrial'
|
||||
var savedTheme = localStorage.getItem('pos_theme');
|
||||
if (!savedTheme) {
|
||||
// No saved preference — use system color scheme
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
savedTheme = 'industrial';
|
||||
} else {
|
||||
savedTheme = 'modern';
|
||||
}
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
// Hide all theme bars (they overlap content with position:fixed)
|
||||
@@ -146,6 +154,18 @@
|
||||
// Override any page-level setTheme functions so they use our persistent version
|
||||
window.setTheme = window.posSetTheme;
|
||||
|
||||
// Listen for system color scheme changes and auto-switch (only if user hasn't manually set a preference)
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
// Only auto-switch if user hasn't explicitly set a preference
|
||||
var userExplicit = localStorage.getItem('pos_theme');
|
||||
if (!userExplicit) {
|
||||
var autoTheme = e.matches ? 'industrial' : 'modern';
|
||||
document.documentElement.setAttribute('data-theme', autoTheme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also prevent any DOMContentLoaded theme switchers from overriding
|
||||
// by re-applying our saved theme after a tick
|
||||
setTimeout(function() {
|
||||
|
||||
@@ -1005,6 +1005,20 @@
|
||||
}
|
||||
|
||||
// ─── EXPOSE GLOBALS (for backward compat) ───
|
||||
// ─── BARCODE CAMERA SCAN ───
|
||||
function startBarcodeScan() {
|
||||
if (!window.NexusNative) {
|
||||
alert('El modulo de escaneo no esta cargado.');
|
||||
return;
|
||||
}
|
||||
window.NexusNative.scanBarcode().then(function (code) {
|
||||
if (code) {
|
||||
searchInput.value = code;
|
||||
runSearch(code);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.CatalogApp = {
|
||||
toggleCart: toggleCart,
|
||||
goToCheckout: goToCheckout,
|
||||
@@ -1018,6 +1032,7 @@
|
||||
vsModelChanged: vsModelChanged,
|
||||
vsEngineChanged: vsEngineChanged,
|
||||
vsClear: vsClearAll,
|
||||
startBarcodeScan: startBarcodeScan,
|
||||
};
|
||||
|
||||
// ─── INIT ───
|
||||
|
||||
@@ -6,21 +6,114 @@
|
||||
|
||||
window.NexusNative = {
|
||||
isNative: typeof Capacitor !== 'undefined',
|
||||
_scanStream: null,
|
||||
_scanVideo: null,
|
||||
|
||||
// Camera for barcode scanning
|
||||
// Camera barcode scanning — works in native (Capacitor) and web (BarcodeDetector / getUserMedia)
|
||||
async scanBarcode() {
|
||||
if (!this.isNative) return null;
|
||||
// Native Capacitor path
|
||||
if (this.isNative) {
|
||||
try {
|
||||
const { Camera } = await import('@capacitor/camera');
|
||||
const photo = await Camera.getPhoto({
|
||||
quality: 90,
|
||||
resultType: 'base64'
|
||||
});
|
||||
// In production, send to a barcode decode service
|
||||
return photo;
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Web path: use BarcodeDetector API (Chrome 83+)
|
||||
if (!('BarcodeDetector' in window)) {
|
||||
alert('Tu navegador no soporta escaneo de codigos de barras. Usa Chrome 83+ o un dispositivo movil.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||
});
|
||||
this._scanStream = stream;
|
||||
|
||||
// Create overlay UI
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'barcode-scan-overlay';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.style.cssText = 'width:90%;max-width:500px;border-radius:12px;border:3px solid #F5A623;';
|
||||
video.srcObject = stream;
|
||||
this._scanVideo = video;
|
||||
|
||||
const label = document.createElement('p');
|
||||
label.textContent = 'Apunta al codigo de barras...';
|
||||
label.style.cssText = 'color:#fff;font-size:16px;margin-top:16px;font-family:sans-serif;';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancelar';
|
||||
cancelBtn.style.cssText = 'margin-top:16px;padding:10px 24px;background:#F5A623;color:#000;border:none;border-radius:6px;font-size:15px;cursor:pointer;font-weight:bold;';
|
||||
cancelBtn.onclick = () => {
|
||||
this.stopScan();
|
||||
overlay.remove();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
overlay.appendChild(video);
|
||||
overlay.appendChild(label);
|
||||
overlay.appendChild(cancelBtn);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const detector = new BarcodeDetector({
|
||||
formats: ['ean_13', 'ean_8', 'code_128', 'code_39', 'qr_code', 'upc_a', 'upc_e']
|
||||
});
|
||||
|
||||
const scanFrame = async () => {
|
||||
if (!this._scanStream) return;
|
||||
try {
|
||||
const barcodes = await detector.detect(video);
|
||||
if (barcodes.length > 0) {
|
||||
const code = barcodes[0].rawValue;
|
||||
label.textContent = 'Codigo detectado: ' + code;
|
||||
label.style.color = '#4CAF50';
|
||||
// Small delay so user sees the result
|
||||
setTimeout(() => {
|
||||
this.stopScan();
|
||||
overlay.remove();
|
||||
resolve(code);
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
} catch(e) { /* frame failed, retry */ }
|
||||
requestAnimationFrame(scanFrame);
|
||||
};
|
||||
|
||||
// Wait for video to be ready
|
||||
video.onloadedmetadata = () => {
|
||||
video.play();
|
||||
requestAnimationFrame(scanFrame);
|
||||
};
|
||||
|
||||
} catch(e) {
|
||||
console.error('Camera access error:', e);
|
||||
alert('No se pudo acceder a la camara: ' + e.message);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
stopScan() {
|
||||
if (this._scanStream) {
|
||||
this._scanStream.getTracks().forEach(t => t.stop());
|
||||
this._scanStream = null;
|
||||
}
|
||||
this._scanVideo = null;
|
||||
var overlay = document.getElementById('barcode-scan-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
},
|
||||
|
||||
// Push notification registration
|
||||
@@ -34,7 +127,6 @@
|
||||
}
|
||||
PushNotifications.addListener('registration', token => {
|
||||
console.log('Push token:', token.value);
|
||||
// Send token to server for this employee
|
||||
});
|
||||
PushNotifications.addListener('pushNotificationReceived', notification => {
|
||||
console.log('Push received:', notification);
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
'use strict';
|
||||
|
||||
var DB_NAME = 'nexus_pos_offline';
|
||||
var DB_VERSION = 1;
|
||||
var DB_VERSION = 2;
|
||||
var QUEUE_STORE = 'sync_queue';
|
||||
var INVENTORY_STORE = 'inventory_cache';
|
||||
var TOP_PARTS_STORE = 'cached_parts';
|
||||
|
||||
var db = null;
|
||||
|
||||
@@ -26,6 +27,11 @@
|
||||
inv.createIndex('sku', 'sku', { unique: false });
|
||||
inv.createIndex('name', 'name', { unique: false });
|
||||
}
|
||||
if (!d.objectStoreNames.contains(TOP_PARTS_STORE)) {
|
||||
var tp = d.createObjectStore(TOP_PARTS_STORE, { keyPath: 'part_number' });
|
||||
tp.createIndex('name', 'name', { unique: false });
|
||||
tp.createIndex('category', 'category', { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = function (e) {
|
||||
db = e.target.result;
|
||||
@@ -156,6 +162,52 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Top parts cache (offline catalog) ────────────────────────
|
||||
function cacheTopParts() {
|
||||
return fetch('/pos/api/sync/top-parts').then(function (resp) {
|
||||
if (!resp.ok) throw new Error('Sync top-parts failed: ' + resp.status);
|
||||
return resp.json();
|
||||
}).then(function (data) {
|
||||
var parts = data.parts || [];
|
||||
return openDB().then(function (d) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = d.transaction(TOP_PARTS_STORE, 'readwrite');
|
||||
var store = tx.objectStore(TOP_PARTS_STORE);
|
||||
store.clear();
|
||||
parts.forEach(function (p) { store.put(p); });
|
||||
tx.oncomplete = function () {
|
||||
console.log('[SyncEngine] Cached ' + parts.length + ' top parts');
|
||||
resolve(parts.length);
|
||||
};
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function searchCachedParts(query) {
|
||||
return openDB().then(function (d) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = d.transaction(TOP_PARTS_STORE, 'readonly');
|
||||
var req = tx.objectStore(TOP_PARTS_STORE).getAll();
|
||||
req.onsuccess = function () {
|
||||
var all = req.result;
|
||||
if (!query) { resolve(all); return; }
|
||||
|
||||
var q = query.toLowerCase();
|
||||
var filtered = all.filter(function (p) {
|
||||
return (p.part_number && p.part_number.toLowerCase().indexOf(q) !== -1) ||
|
||||
(p.name && p.name.toLowerCase().indexOf(q) !== -1) ||
|
||||
(p.category && p.category.toLowerCase().indexOf(q) !== -1) ||
|
||||
(p.brand && p.brand.toLowerCase().indexOf(q) !== -1);
|
||||
});
|
||||
resolve(filtered);
|
||||
};
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Connectivity helpers ─────────────────────────────────────
|
||||
function isOnline() {
|
||||
return navigator.onLine;
|
||||
@@ -198,7 +250,9 @@
|
||||
getQueueCount: getQueueCount,
|
||||
isOnline: isOnline,
|
||||
cacheInventory: cacheInventory,
|
||||
getCachedInventory: getCachedInventory
|
||||
getCachedInventory: getCachedInventory,
|
||||
cacheTopParts: cacheTopParts,
|
||||
searchCachedParts: searchCachedParts
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -595,6 +595,9 @@
|
||||
<div class="search-bar" id="searchBar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
|
||||
<button type="button" id="btnScanBarcode" title="Escanear codigo de barras" style="background:none;border:none;cursor:pointer;padding:4px 8px;color:var(--color-text-muted);display:flex;align-items:center;" onclick="CatalogApp.startBarcodeScan()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7V5a2 2 0 012-2h2"/><path d="M17 3h2a2 2 0 012 2v2"/><path d="M21 17v2a2 2 0 01-2 2h-2"/><path d="M7 21H5a2 2 0 01-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="7" y1="8" x2="17" y2="8"/><line x1="7" y1="16" x2="17" y2="16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-dropdown" id="searchDropdown"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user