Compare commits
9 Commits
7036a18601
...
09980c1cdb
| Author | SHA1 | Date | |
|---|---|---|---|
| 09980c1cdb | |||
| b2484af0fb | |||
| 76f738652b | |||
| c66fb13c15 | |||
| d0343f8087 | |||
| 53e3548249 | |||
| 5550fe7bb0 | |||
| 2a1aee4ee4 | |||
| fc5a56ba62 |
17
pos/app.py
17
pos/app.py
@@ -16,6 +16,15 @@ def create_app():
|
|||||||
from blueprints.catalog_bp import catalog_bp
|
from blueprints.catalog_bp import catalog_bp
|
||||||
app.register_blueprint(catalog_bp)
|
app.register_blueprint(catalog_bp)
|
||||||
|
|
||||||
|
from blueprints.pos_bp import pos_bp
|
||||||
|
app.register_blueprint(pos_bp)
|
||||||
|
|
||||||
|
from blueprints.customers_bp import customers_bp
|
||||||
|
app.register_blueprint(customers_bp)
|
||||||
|
|
||||||
|
from blueprints.cashregister_bp import cashregister_bp
|
||||||
|
app.register_blueprint(cashregister_bp)
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
@@ -35,6 +44,14 @@ def create_app():
|
|||||||
def pos_inventory():
|
def pos_inventory():
|
||||||
return render_template('inventory.html')
|
return render_template('inventory.html')
|
||||||
|
|
||||||
|
@app.route('/pos/sale')
|
||||||
|
def pos_sale():
|
||||||
|
return render_template('pos.html')
|
||||||
|
|
||||||
|
@app.route('/pos/customers')
|
||||||
|
def pos_customers():
|
||||||
|
return render_template('customers.html')
|
||||||
|
|
||||||
@app.route('/pos/static/<path:filename>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
563
pos/blueprints/cashregister_bp.py
Normal file
563
pos/blueprints/cashregister_bp.py
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
# /home/Autopartes/pos/blueprints/cashregister_bp.py
|
||||||
|
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from middleware import require_auth
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
from services.audit import log_action
|
||||||
|
|
||||||
|
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/open', methods=['POST'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def open_register():
|
||||||
|
"""Open a cash register session.
|
||||||
|
|
||||||
|
Body: {register_number: int, opening_amount: float}
|
||||||
|
|
||||||
|
Business rules:
|
||||||
|
- An employee can only have one open register at a time
|
||||||
|
- Register number identifies the physical register (1, 2, 3...)
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
register_number = data.get('register_number')
|
||||||
|
opening_amount = float(data.get('opening_amount', 0))
|
||||||
|
|
||||||
|
if not register_number:
|
||||||
|
return jsonify({'error': 'register_number required'}), 400
|
||||||
|
if opening_amount < 0:
|
||||||
|
return jsonify({'error': 'opening_amount cannot be negative'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Check if employee already has an open register
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, register_number FROM cash_registers
|
||||||
|
WHERE employee_id = %s AND status = 'open'
|
||||||
|
""", (g.employee_id,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'error': f'You already have register #{existing[1]} open (id={existing[0]}). Close it first.'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check if this register number is already open at this branch
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, employee_id FROM cash_registers
|
||||||
|
WHERE branch_id = %s AND register_number = %s AND status = 'open'
|
||||||
|
""", (g.branch_id, register_number))
|
||||||
|
in_use = cur.fetchone()
|
||||||
|
if in_use:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': f'Register #{register_number} is already open by another employee'}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO cash_registers
|
||||||
|
(branch_id, employee_id, register_number, opening_amount, status)
|
||||||
|
VALUES (%s,%s,%s,%s,'open')
|
||||||
|
RETURNING id, opened_at
|
||||||
|
""", (g.branch_id, g.employee_id, register_number, opening_amount))
|
||||||
|
reg_id, opened_at = cur.fetchone()
|
||||||
|
|
||||||
|
log_action(conn, 'REGISTER_OPEN', 'cash_register', reg_id,
|
||||||
|
new_value={'register_number': register_number, 'opening_amount': opening_amount})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'id': reg_id,
|
||||||
|
'register_number': register_number,
|
||||||
|
'opening_amount': opening_amount,
|
||||||
|
'opened_at': str(opened_at),
|
||||||
|
'message': f'Register #{register_number} opened'
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/current', methods=['GET'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def current_register():
|
||||||
|
"""Get the current open register for this employee."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT cr.id, cr.register_number, cr.opening_amount, cr.opened_at,
|
||||||
|
cr.branch_id, b.name as branch_name
|
||||||
|
FROM cash_registers cr
|
||||||
|
LEFT JOIN branches b ON cr.branch_id = b.id
|
||||||
|
WHERE cr.employee_id = %s AND cr.status = 'open'
|
||||||
|
""", (g.employee_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'register': None, 'message': 'No open register'})
|
||||||
|
|
||||||
|
register = {
|
||||||
|
'id': row[0], 'register_number': row[1],
|
||||||
|
'opening_amount': float(row[2]) if row[2] else 0,
|
||||||
|
'opened_at': str(row[3]),
|
||||||
|
'branch_id': row[4], 'branch_name': row[5],
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'register': register})
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/movement', methods=['POST'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def cash_movement():
|
||||||
|
"""Record a cash in/out movement with mandatory reason.
|
||||||
|
|
||||||
|
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
mov_type = data.get('type')
|
||||||
|
amount = float(data.get('amount', 0))
|
||||||
|
reason = data.get('reason', '').strip()
|
||||||
|
|
||||||
|
if mov_type not in ('in', 'out'):
|
||||||
|
return jsonify({'error': "type must be 'in' or 'out'"}), 400
|
||||||
|
if amount <= 0:
|
||||||
|
return jsonify({'error': 'amount must be positive'}), 400
|
||||||
|
if not reason or len(reason) < 3:
|
||||||
|
return jsonify({'error': 'reason required (min 3 characters)'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Get employee's open register
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM cash_registers
|
||||||
|
WHERE employee_id = %s AND status = 'open'
|
||||||
|
""", (g.employee_id,))
|
||||||
|
reg = cur.fetchone()
|
||||||
|
if not reg:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'No open register. Open a register first.'}), 400
|
||||||
|
|
||||||
|
register_id = reg[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)
|
||||||
|
RETURNING id, created_at
|
||||||
|
""", (register_id, mov_type, amount, reason, g.employee_id))
|
||||||
|
mov_id, created_at = cur.fetchone()
|
||||||
|
|
||||||
|
log_action(conn, f'CASH_{mov_type.upper()}', 'cash_register', register_id,
|
||||||
|
new_value={'movement_id': mov_id, 'type': mov_type,
|
||||||
|
'amount': amount, 'reason': reason})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'id': mov_id,
|
||||||
|
'type': mov_type,
|
||||||
|
'amount': amount,
|
||||||
|
'reason': reason,
|
||||||
|
'created_at': str(created_at),
|
||||||
|
'message': 'Movement recorded'
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_register_summary(conn, register_id):
|
||||||
|
"""Compute the register summary for X-cut or Z-cut.
|
||||||
|
|
||||||
|
Returns a dict with totals by payment method, cash movements, and expected cash amount.
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Get register info
|
||||||
|
cur.execute("""
|
||||||
|
SELECT opening_amount, opened_at, employee_id
|
||||||
|
FROM cash_registers WHERE id = %s
|
||||||
|
""", (register_id,))
|
||||||
|
reg = cur.fetchone()
|
||||||
|
opening_amount = float(reg[0]) if reg[0] else 0
|
||||||
|
|
||||||
|
# Sales totals by payment method
|
||||||
|
cur.execute("""
|
||||||
|
SELECT payment_method, COUNT(*), COALESCE(SUM(total), 0)
|
||||||
|
FROM sales
|
||||||
|
WHERE register_id = %s AND status = 'completed'
|
||||||
|
GROUP BY payment_method
|
||||||
|
""", (register_id,))
|
||||||
|
sales_by_method = {}
|
||||||
|
total_sales = 0.0
|
||||||
|
total_sales_count = 0
|
||||||
|
for r in cur.fetchall():
|
||||||
|
method, count, amount = r[0], r[1], float(r[2])
|
||||||
|
sales_by_method[method] = {'count': count, 'amount': amount}
|
||||||
|
total_sales += amount
|
||||||
|
total_sales_count += count
|
||||||
|
|
||||||
|
# Cash sales specifically (for expected cash calculation)
|
||||||
|
cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
|
||||||
|
|
||||||
|
# Change given (cash out)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COALESCE(SUM(change_given), 0) FROM sales
|
||||||
|
WHERE register_id = %s AND status = 'completed' AND payment_method = 'efectivo'
|
||||||
|
""", (register_id,))
|
||||||
|
change_given = float(cur.fetchone()[0])
|
||||||
|
|
||||||
|
# Cash movements
|
||||||
|
cur.execute("""
|
||||||
|
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
|
||||||
|
WHERE register_id = %s GROUP BY type
|
||||||
|
""", (register_id,))
|
||||||
|
movements = {'in': 0.0, 'out': 0.0}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
movements[r[0]] = float(r[1])
|
||||||
|
|
||||||
|
# Cancelled sales
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
|
||||||
|
WHERE register_id = %s AND status = 'cancelled'
|
||||||
|
""", (register_id,))
|
||||||
|
cancelled = cur.fetchone()
|
||||||
|
cancelled_count = cancelled[0]
|
||||||
|
cancelled_amount = float(cancelled[1])
|
||||||
|
|
||||||
|
# Expected cash = opening + cash sales - change + cash_in - cash_out
|
||||||
|
expected_cash = round(
|
||||||
|
opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detail of cash movements
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, type, amount, reason, created_at
|
||||||
|
FROM cash_movements WHERE register_id = %s ORDER BY created_at
|
||||||
|
""", (register_id,))
|
||||||
|
movement_detail = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
movement_detail.append({
|
||||||
|
'id': r[0], 'type': r[1], 'amount': float(r[2]),
|
||||||
|
'reason': r[3], 'created_at': str(r[4])
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'opening_amount': opening_amount,
|
||||||
|
'sales_by_method': sales_by_method,
|
||||||
|
'total_sales': round(total_sales, 2),
|
||||||
|
'total_sales_count': total_sales_count,
|
||||||
|
'cash_from_sales': round(cash_from_sales, 2),
|
||||||
|
'change_given': round(change_given, 2),
|
||||||
|
'cash_movements_in': round(movements['in'], 2),
|
||||||
|
'cash_movements_out': round(movements['out'], 2),
|
||||||
|
'movement_detail': movement_detail,
|
||||||
|
'cancelled_count': cancelled_count,
|
||||||
|
'cancelled_amount': round(cancelled_amount, 2),
|
||||||
|
'expected_cash': expected_cash,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def cut_x():
|
||||||
|
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM cash_registers
|
||||||
|
WHERE employee_id = %s AND status = 'open'
|
||||||
|
""", (g.employee_id,))
|
||||||
|
reg = cur.fetchone()
|
||||||
|
if not reg:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'No open register'}), 400
|
||||||
|
|
||||||
|
register_id = reg[0]
|
||||||
|
summary = _compute_register_summary(conn, register_id)
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'type': 'X',
|
||||||
|
'register_id': register_id,
|
||||||
|
'status': 'open',
|
||||||
|
**summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def cut_z():
|
||||||
|
"""Final cut (corte Z): close the register.
|
||||||
|
|
||||||
|
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
closing_amount = float(data.get('closing_amount', 0))
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM cash_registers
|
||||||
|
WHERE employee_id = %s AND status = 'open'
|
||||||
|
""", (g.employee_id,))
|
||||||
|
reg = cur.fetchone()
|
||||||
|
if not reg:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'No open register'}), 400
|
||||||
|
|
||||||
|
register_id = reg[0]
|
||||||
|
summary = _compute_register_summary(conn, register_id)
|
||||||
|
|
||||||
|
expected = summary['expected_cash']
|
||||||
|
difference = round(closing_amount - expected, 2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cash_registers
|
||||||
|
SET closing_amount = %s, expected_amount = %s, difference = %s,
|
||||||
|
status = 'closed', closed_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (closing_amount, expected, difference, register_id))
|
||||||
|
|
||||||
|
log_action(conn, 'REGISTER_CLOSE', 'cash_register', register_id,
|
||||||
|
new_value={
|
||||||
|
'closing_amount': closing_amount,
|
||||||
|
'expected_amount': expected,
|
||||||
|
'difference': difference,
|
||||||
|
'total_sales': summary['total_sales'],
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'type': 'Z',
|
||||||
|
'register_id': register_id,
|
||||||
|
'status': 'closed',
|
||||||
|
'closing_amount': closing_amount,
|
||||||
|
'expected_amount': expected,
|
||||||
|
'difference': difference,
|
||||||
|
**summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/history', methods=['GET'])
|
||||||
|
@require_auth('pos.view')
|
||||||
|
def register_history():
|
||||||
|
"""List closed registers with summary.
|
||||||
|
|
||||||
|
Query params: date_from, date_to, employee_id, page, per_page
|
||||||
|
"""
|
||||||
|
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 = ["cr.status = 'closed'"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if g.branch_id:
|
||||||
|
where_clauses.append("cr.branch_id = %s")
|
||||||
|
params.append(g.branch_id)
|
||||||
|
|
||||||
|
date_from = request.args.get('date_from')
|
||||||
|
date_to = request.args.get('date_to')
|
||||||
|
employee_id = request.args.get('employee_id')
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
where_clauses.append("cr.closed_at >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
where_clauses.append("cr.closed_at < %s::date + interval '1 day'")
|
||||||
|
params.append(date_to)
|
||||||
|
if employee_id:
|
||||||
|
where_clauses.append("cr.employee_id = %s")
|
||||||
|
params.append(int(employee_id))
|
||||||
|
|
||||||
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT count(*) FROM cash_registers cr WHERE {where}", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT cr.id, cr.register_number, cr.opening_amount, cr.closing_amount,
|
||||||
|
cr.expected_amount, cr.difference, cr.opened_at, cr.closed_at,
|
||||||
|
cr.employee_id, e.name as employee_name
|
||||||
|
FROM cash_registers cr
|
||||||
|
LEFT JOIN employees e ON cr.employee_id = e.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY cr.closed_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [per_page, (page - 1) * per_page])
|
||||||
|
|
||||||
|
registers = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
registers.append({
|
||||||
|
'id': r[0], 'register_number': r[1],
|
||||||
|
'opening_amount': float(r[2]) if r[2] else 0,
|
||||||
|
'closing_amount': float(r[3]) if r[3] else 0,
|
||||||
|
'expected_amount': float(r[4]) if r[4] else 0,
|
||||||
|
'difference': float(r[5]) if r[5] else 0,
|
||||||
|
'opened_at': str(r[6]), 'closed_at': str(r[7]),
|
||||||
|
'employee_id': r[8], 'employee_name': r[9],
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
return jsonify({
|
||||||
|
'data': registers,
|
||||||
|
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@cashregister_bp.route('/daily-summary', methods=['GET'])
|
||||||
|
@require_auth('pos.view')
|
||||||
|
def daily_summary():
|
||||||
|
"""Consolidated daily summary across all registers for a given date.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
date: YYYY-MM-DD (default: today)
|
||||||
|
|
||||||
|
Returns aggregated totals: total sales, total by payment method,
|
||||||
|
total movements, and per-register breakdown.
|
||||||
|
"""
|
||||||
|
from datetime import date as date_type
|
||||||
|
target_date = request.args.get('date', date_type.today().isoformat())
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
branch_clause = ""
|
||||||
|
params = [target_date, target_date]
|
||||||
|
if g.branch_id:
|
||||||
|
branch_clause = "AND s.branch_id = %s"
|
||||||
|
params.append(g.branch_id)
|
||||||
|
|
||||||
|
# Total sales by payment method for the date
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT s.payment_method, COUNT(*), COALESCE(SUM(s.total), 0)
|
||||||
|
FROM sales s
|
||||||
|
WHERE s.created_at >= %s::date
|
||||||
|
AND s.created_at < %s::date + interval '1 day'
|
||||||
|
AND s.status = 'completed'
|
||||||
|
{branch_clause}
|
||||||
|
GROUP BY s.payment_method
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
sales_by_method = {}
|
||||||
|
total_sales = 0.0
|
||||||
|
total_sales_count = 0
|
||||||
|
for method, count, amount in cur.fetchall():
|
||||||
|
sales_by_method[method] = {'count': count, 'amount': float(amount)}
|
||||||
|
total_sales += float(amount)
|
||||||
|
total_sales_count += count
|
||||||
|
|
||||||
|
# Cancelled sales
|
||||||
|
cancel_params = [target_date, target_date]
|
||||||
|
if g.branch_id:
|
||||||
|
cancel_params.append(g.branch_id)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(s.total), 0)
|
||||||
|
FROM sales s
|
||||||
|
WHERE s.created_at >= %s::date
|
||||||
|
AND s.created_at < %s::date + interval '1 day'
|
||||||
|
AND s.status = 'cancelled'
|
||||||
|
{branch_clause}
|
||||||
|
""", cancel_params)
|
||||||
|
cancelled = cur.fetchone()
|
||||||
|
cancelled_count = cancelled[0]
|
||||||
|
cancelled_amount = float(cancelled[1])
|
||||||
|
|
||||||
|
# Cash movements for the date
|
||||||
|
mov_params = [target_date, target_date]
|
||||||
|
mov_branch_clause = ""
|
||||||
|
if g.branch_id:
|
||||||
|
mov_branch_clause = "AND cr.branch_id = %s"
|
||||||
|
mov_params.append(g.branch_id)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT cm.type, COUNT(*), COALESCE(SUM(cm.amount), 0)
|
||||||
|
FROM cash_movements cm
|
||||||
|
JOIN cash_registers cr ON cm.register_id = cr.id
|
||||||
|
WHERE cm.created_at >= %s::date
|
||||||
|
AND cm.created_at < %s::date + interval '1 day'
|
||||||
|
{mov_branch_clause}
|
||||||
|
GROUP BY cm.type
|
||||||
|
""", mov_params)
|
||||||
|
movements = {'in': {'count': 0, 'amount': 0.0}, 'out': {'count': 0, 'amount': 0.0}}
|
||||||
|
for mov_type, count, amount in cur.fetchall():
|
||||||
|
movements[mov_type] = {'count': count, 'amount': float(amount)}
|
||||||
|
|
||||||
|
# Per-register breakdown
|
||||||
|
reg_params = [target_date, target_date]
|
||||||
|
reg_branch_clause = ""
|
||||||
|
if g.branch_id:
|
||||||
|
reg_branch_clause = "AND cr.branch_id = %s"
|
||||||
|
reg_params.append(g.branch_id)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT cr.id, cr.register_number, cr.status,
|
||||||
|
cr.opening_amount, cr.closing_amount, cr.expected_amount,
|
||||||
|
cr.difference, cr.opened_at, cr.closed_at,
|
||||||
|
e.name as employee_name,
|
||||||
|
(SELECT COUNT(*) FROM sales s WHERE s.register_id = cr.id AND s.status = 'completed') as sale_count,
|
||||||
|
(SELECT COALESCE(SUM(s.total), 0) FROM sales s WHERE s.register_id = cr.id AND s.status = 'completed') as sale_total
|
||||||
|
FROM cash_registers cr
|
||||||
|
LEFT JOIN employees e ON cr.employee_id = e.id
|
||||||
|
WHERE cr.opened_at >= %s::date
|
||||||
|
AND cr.opened_at < %s::date + interval '1 day'
|
||||||
|
{reg_branch_clause}
|
||||||
|
ORDER BY cr.opened_at
|
||||||
|
""", reg_params)
|
||||||
|
|
||||||
|
registers = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
registers.append({
|
||||||
|
'id': r[0], 'register_number': r[1], 'status': r[2],
|
||||||
|
'opening_amount': float(r[3]) if r[3] else 0,
|
||||||
|
'closing_amount': float(r[4]) if r[4] else 0,
|
||||||
|
'expected_amount': float(r[5]) if r[5] else 0,
|
||||||
|
'difference': float(r[6]) if r[6] else 0,
|
||||||
|
'opened_at': str(r[7]) if r[7] else None,
|
||||||
|
'closed_at': str(r[8]) if r[8] else None,
|
||||||
|
'employee_name': r[9],
|
||||||
|
'sale_count': r[10], 'sale_total': float(r[11]),
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'date': target_date,
|
||||||
|
'total_sales': round(total_sales, 2),
|
||||||
|
'total_sales_count': total_sales_count,
|
||||||
|
'sales_by_method': sales_by_method,
|
||||||
|
'cancelled_count': cancelled_count,
|
||||||
|
'cancelled_amount': round(cancelled_amount, 2),
|
||||||
|
'movements_in': movements['in'],
|
||||||
|
'movements_out': movements['out'],
|
||||||
|
'registers': registers,
|
||||||
|
})
|
||||||
465
pos/blueprints/customers_bp.py
Normal file
465
pos/blueprints/customers_bp.py
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
# /home/Autopartes/pos/blueprints/customers_bp.py
|
||||||
|
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from middleware import require_auth, has_permission
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
from services.audit import log_action
|
||||||
|
|
||||||
|
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Customer CRUD ─────────────────────────────
|
||||||
|
|
||||||
|
@customers_bp.route('', methods=['GET'])
|
||||||
|
@require_auth('customers.view')
|
||||||
|
def list_customers():
|
||||||
|
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
q: search string (matches name, RFC, phone via ILIKE)
|
||||||
|
page: page number (default 1)
|
||||||
|
per_page: items per page (default 50, max 200)
|
||||||
|
branch_id: filter by branch (default: current user's branch)
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
search = request.args.get('q', '').strip()
|
||||||
|
branch_id = request.args.get('branch_id')
|
||||||
|
|
||||||
|
where_clauses = ["c.is_active = true"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if branch_id:
|
||||||
|
where_clauses.append("c.branch_id = %s")
|
||||||
|
params.append(int(branch_id))
|
||||||
|
if search:
|
||||||
|
where_clauses.append(
|
||||||
|
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
|
||||||
|
)
|
||||||
|
params.extend([f'%{search}%'] * 4)
|
||||||
|
|
||||||
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
# Count
|
||||||
|
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Fetch
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
||||||
|
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
||||||
|
c.branch_id
|
||||||
|
FROM customers c
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY c.name
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [per_page, (page - 1) * per_page])
|
||||||
|
|
||||||
|
customers = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
customers.append({
|
||||||
|
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
|
||||||
|
'phone': r[4], 'email': r[5], 'price_tier': r[6],
|
||||||
|
'credit_limit': float(r[7]) if r[7] else 0,
|
||||||
|
'credit_balance': float(r[8]) if r[8] else 0,
|
||||||
|
'vehicle_info': r[9],
|
||||||
|
'branch_id': r[10],
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
return jsonify({
|
||||||
|
'data': customers,
|
||||||
|
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
||||||
|
@require_auth('customers.view')
|
||||||
|
def get_customer(customer_id):
|
||||||
|
"""Get customer details with credit info, vehicle history, and recent purchases."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||||
|
cp, email, phone, address, price_tier, credit_limit, credit_balance,
|
||||||
|
is_active, vehicle_info, created_at
|
||||||
|
FROM customers WHERE id = %s
|
||||||
|
""", (customer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Customer not found'}), 404
|
||||||
|
|
||||||
|
cols = [desc[0] for desc in cur.description]
|
||||||
|
customer = dict(zip(cols, row))
|
||||||
|
|
||||||
|
# Convert Decimal to float
|
||||||
|
for k in ('credit_limit', 'credit_balance'):
|
||||||
|
if customer.get(k) is not None:
|
||||||
|
customer[k] = float(customer[k])
|
||||||
|
|
||||||
|
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
|
||||||
|
|
||||||
|
# Recent purchases (last 20)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
||||||
|
e.name as employee_name
|
||||||
|
FROM sales s
|
||||||
|
LEFT JOIN employees e ON s.employee_id = e.id
|
||||||
|
WHERE s.customer_id = %s AND s.status != 'cancelled'
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
""", (customer_id,))
|
||||||
|
customer['recent_purchases'] = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
customer['recent_purchases'].append({
|
||||||
|
'id': r[0], 'total': float(r[1]) if r[1] else 0,
|
||||||
|
'payment_method': r[2], 'sale_type': r[3],
|
||||||
|
'status': r[4], 'created_at': str(r[5]),
|
||||||
|
'employee_name': r[6],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Credit summary
|
||||||
|
customer['credit_available'] = round(
|
||||||
|
float(customer['credit_limit']) - float(customer['credit_balance']), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(customer)
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route('', methods=['POST'])
|
||||||
|
@require_auth('customers.create')
|
||||||
|
def create_customer():
|
||||||
|
"""Create a new customer.
|
||||||
|
|
||||||
|
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||||
|
phone, address, price_tier, credit_limit, vehicle_info}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if not data.get('name'):
|
||||||
|
return jsonify({'error': 'name is required'}), 400
|
||||||
|
|
||||||
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO customers
|
||||||
|
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||||
|
cp, email, phone, address, price_tier, credit_limit, vehicle_info)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING id
|
||||||
|
""", (
|
||||||
|
branch_id, data['name'], data.get('rfc'), data.get('razon_social'),
|
||||||
|
data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
|
||||||
|
data.get('cp'), data.get('email'), data.get('phone'),
|
||||||
|
data.get('address'), data.get('price_tier', 1),
|
||||||
|
data.get('credit_limit', 0),
|
||||||
|
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
|
||||||
|
))
|
||||||
|
customer_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
log_action(conn, 'CUSTOMER_CREATE', 'customer', customer_id,
|
||||||
|
new_value={'name': data['name'], 'rfc': data.get('rfc')})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'id': customer_id, 'message': 'Customer created'}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route('/<int:customer_id>', methods=['PUT'])
|
||||||
|
@require_auth('customers.edit')
|
||||||
|
def update_customer(customer_id):
|
||||||
|
"""Update customer fields. Credit limit changes require customers.edit_credit permission."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Verify customer exists
|
||||||
|
cur.execute("SELECT id, credit_limit FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if not existing:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Customer not found'}), 404
|
||||||
|
|
||||||
|
# Credit limit change requires special permission
|
||||||
|
if 'credit_limit' in data and float(data['credit_limit']) != float(existing[1] or 0):
|
||||||
|
if not has_permission('customers.edit_credit'):
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Permission customers.edit_credit required to change credit limit'}), 403
|
||||||
|
|
||||||
|
log_action(conn, 'CREDIT_CHANGE', 'customer', customer_id,
|
||||||
|
old_value={'credit_limit': float(existing[1] or 0)},
|
||||||
|
new_value={'credit_limit': float(data['credit_limit'])})
|
||||||
|
|
||||||
|
# Build dynamic update
|
||||||
|
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
|
||||||
|
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
|
||||||
|
'vehicle_info', 'is_active', 'branch_id']
|
||||||
|
sets = []
|
||||||
|
vals = []
|
||||||
|
for field in allowed:
|
||||||
|
if field in data:
|
||||||
|
val = data[field]
|
||||||
|
if field == 'vehicle_info' and isinstance(val, (dict, list)):
|
||||||
|
val = json.dumps(val)
|
||||||
|
sets.append(f"{field} = %s")
|
||||||
|
vals.append(val)
|
||||||
|
|
||||||
|
if not sets:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'No fields to update'}), 400
|
||||||
|
|
||||||
|
vals.append(customer_id)
|
||||||
|
cur.execute(f"UPDATE customers SET {', '.join(sets)} WHERE id = %s", vals)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'message': 'Customer updated'})
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
|
||||||
|
@require_auth('customers.view')
|
||||||
|
def customer_statement(customer_id):
|
||||||
|
"""Account statement: sales (invoices), payments, running balance.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
from_date: start date (YYYY-MM-DD), default 30 days ago
|
||||||
|
to_date: end date (YYYY-MM-DD), default today
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
from_date = request.args.get('from_date')
|
||||||
|
to_date = request.args.get('to_date')
|
||||||
|
|
||||||
|
# Verify customer exists
|
||||||
|
cur.execute("SELECT name, credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
cust = cur.fetchone()
|
||||||
|
if not cust:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Customer not found'}), 404
|
||||||
|
|
||||||
|
where_date = ""
|
||||||
|
params = [customer_id]
|
||||||
|
if from_date:
|
||||||
|
where_date += " AND s.created_at >= %s"
|
||||||
|
params.append(from_date)
|
||||||
|
if to_date:
|
||||||
|
where_date += " AND s.created_at < %s::date + interval '1 day'"
|
||||||
|
params.append(to_date)
|
||||||
|
|
||||||
|
# Get credit sales (charges / cargos)
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT s.id, 'charge' as type, s.total as amount, s.created_at,
|
||||||
|
'Venta #' || s.id as description, s.status
|
||||||
|
FROM sales s
|
||||||
|
WHERE s.customer_id = %s AND s.sale_type = 'credit' {where_date}
|
||||||
|
ORDER BY s.created_at
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
entries.append({
|
||||||
|
'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
|
||||||
|
'date': str(r[3]), 'description': r[4], 'status': r[5]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get customer payments (abonos) from the customer_payments table
|
||||||
|
pay_params = [customer_id]
|
||||||
|
pay_where = ""
|
||||||
|
if from_date:
|
||||||
|
pay_where += " AND cp.created_at >= %s"
|
||||||
|
pay_params.append(from_date)
|
||||||
|
if to_date:
|
||||||
|
pay_where += " AND cp.created_at < %s::date + interval '1 day'"
|
||||||
|
pay_params.append(to_date)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT cp.id, 'payment' as type, cp.amount, cp.created_at,
|
||||||
|
'Abono - ' || cp.payment_method as description, 'completed' as status
|
||||||
|
FROM customer_payments cp
|
||||||
|
WHERE cp.customer_id = %s {pay_where}
|
||||||
|
ORDER BY cp.created_at
|
||||||
|
""", pay_params)
|
||||||
|
|
||||||
|
for r in cur.fetchall():
|
||||||
|
entries.append({
|
||||||
|
'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
|
||||||
|
'date': str(r[3]), 'description': r[4], 'status': r[5]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort all entries by date for correct running balance
|
||||||
|
entries.sort(key=lambda e: e['date'])
|
||||||
|
|
||||||
|
# Compute running balance
|
||||||
|
balance = 0.0
|
||||||
|
for entry in entries:
|
||||||
|
if entry['status'] == 'cancelled':
|
||||||
|
continue
|
||||||
|
if entry['type'] == 'charge':
|
||||||
|
balance += entry['amount']
|
||||||
|
elif entry['type'] == 'payment':
|
||||||
|
balance -= entry['amount']
|
||||||
|
entry['running_balance'] = round(balance, 2)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'customer': {
|
||||||
|
'id': customer_id,
|
||||||
|
'name': cust[0],
|
||||||
|
'credit_limit': float(cust[1]) if cust[1] else 0,
|
||||||
|
'credit_balance': float(cust[2]) if cust[2] else 0,
|
||||||
|
},
|
||||||
|
'entries': entries,
|
||||||
|
'balance': round(balance, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
||||||
|
@require_auth('customers.view')
|
||||||
|
def customer_vehicles(customer_id):
|
||||||
|
"""Get customer's vehicle list with last purchases per vehicle.
|
||||||
|
|
||||||
|
Vehicle info is stored as JSONB in customers.vehicle_info:
|
||||||
|
[{make, model, year, vin, plates}, ...]
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT vehicle_info FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Customer not found'}), 404
|
||||||
|
|
||||||
|
vehicles = row[0] or []
|
||||||
|
|
||||||
|
# Get recent purchases for this customer to match with vehicles
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.total, s.created_at, s.notes,
|
||||||
|
array_agg(si.name ORDER BY si.id) as items
|
||||||
|
FROM sales s
|
||||||
|
JOIN sale_items si ON si.sale_id = s.id
|
||||||
|
WHERE s.customer_id = %s AND s.status = 'completed'
|
||||||
|
GROUP BY s.id, s.total, s.created_at, s.notes
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
""", (customer_id,))
|
||||||
|
|
||||||
|
recent_sales = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
recent_sales.append({
|
||||||
|
'sale_id': r[0], 'total': float(r[1]) if r[1] else 0,
|
||||||
|
'date': str(r[2]), 'notes': r[3], 'items': r[4]
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'vehicles': vehicles,
|
||||||
|
'recent_sales': recent_sales,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route('/<int:customer_id>/payment', methods=['POST'])
|
||||||
|
@require_auth('customers.edit')
|
||||||
|
def record_customer_payment(customer_id):
|
||||||
|
"""Record a payment against a customer's credit balance (abono).
|
||||||
|
|
||||||
|
Body: {amount: float, payment_method: str, reference: str, register_id: int}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
amount = float(data.get('amount', 0))
|
||||||
|
payment_method = data.get('payment_method', 'efectivo')
|
||||||
|
reference = data.get('reference', '')
|
||||||
|
register_id = data.get('register_id')
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
return jsonify({'error': 'Amount must be greater than 0'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Verify customer exists and has a balance
|
||||||
|
cur.execute("SELECT name, credit_balance FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
cust = cur.fetchone()
|
||||||
|
if not cust:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Customer not found'}), 404
|
||||||
|
|
||||||
|
credit_balance = float(cust[1] or 0)
|
||||||
|
if amount > credit_balance:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Payment ${amount:.2f} exceeds current balance ${credit_balance:.2f}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Record the payment
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO customer_payments
|
||||||
|
(customer_id, amount, payment_method, reference, employee_id, register_id)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING id, created_at
|
||||||
|
""", (
|
||||||
|
customer_id, amount, payment_method, reference,
|
||||||
|
getattr(g, 'employee_id', None), register_id
|
||||||
|
))
|
||||||
|
payment_id, created_at = cur.fetchone()
|
||||||
|
|
||||||
|
# Reduce customer credit balance
|
||||||
|
new_balance = round(credit_balance - amount, 2)
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE customers SET credit_balance = %s WHERE id = %s
|
||||||
|
""", (new_balance, customer_id))
|
||||||
|
|
||||||
|
# Record cash movement on register if cash payment
|
||||||
|
if register_id and payment_method == 'efectivo':
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
|
||||||
|
VALUES (%s, 'in', %s, %s, %s)
|
||||||
|
""", (register_id, amount, f'Abono cliente #{customer_id} - {cust[0]}',
|
||||||
|
getattr(g, 'employee_id', None)))
|
||||||
|
|
||||||
|
log_action(conn, 'CUSTOMER_PAYMENT', 'customer', customer_id,
|
||||||
|
old_value={'credit_balance': credit_balance},
|
||||||
|
new_value={'credit_balance': new_balance, 'payment': amount})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'payment_id': payment_id,
|
||||||
|
'amount': amount,
|
||||||
|
'previous_balance': credit_balance,
|
||||||
|
'new_balance': new_balance,
|
||||||
|
'created_at': str(created_at),
|
||||||
|
'message': 'Payment recorded'
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
1238
pos/blueprints/pos_bp.py
Normal file
1238
pos/blueprints/pos_bp.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||||||
# Migration registry: version -> filename
|
# Migration registry: version -> filename
|
||||||
MIGRATIONS = {
|
MIGRATIONS = {
|
||||||
'v1.0': 'v1.0_initial.sql',
|
'v1.0': 'v1.0_initial.sql',
|
||||||
# Future: 'v1.1': 'v1.1_add_xyz.sql',
|
'v1.1': 'v1.1_pos_tables.sql',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
55
pos/migrations/v1.1_pos_tables.sql
Normal file
55
pos/migrations/v1.1_pos_tables.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- /home/Autopartes/pos/migrations/v1.1_pos_tables.sql
|
||||||
|
-- POS Plan 3: Additional tables for sale payments and layaway items.
|
||||||
|
-- Run against each tenant DB.
|
||||||
|
|
||||||
|
-- Sale payments: tracks individual payment methods for a sale (especially mixed payments)
|
||||||
|
CREATE TABLE IF NOT EXISTS sale_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sale_id INTEGER REFERENCES sales(id),
|
||||||
|
register_id INTEGER REFERENCES cash_registers(id),
|
||||||
|
method VARCHAR(20) NOT NULL, -- efectivo, transferencia, tarjeta
|
||||||
|
amount NUMERIC(12,2) NOT NULL,
|
||||||
|
reference VARCHAR(100), -- transaction ref for non-cash
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sale_payments_sale ON sale_payments(sale_id);
|
||||||
|
|
||||||
|
-- Customer payments: tracks credit payments (abonos) against customer balance
|
||||||
|
CREATE TABLE IF NOT EXISTS customer_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER REFERENCES customers(id),
|
||||||
|
amount NUMERIC(12,2) NOT NULL,
|
||||||
|
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
|
||||||
|
reference VARCHAR(100),
|
||||||
|
employee_id INTEGER REFERENCES employees(id),
|
||||||
|
register_id INTEGER REFERENCES cash_registers(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_payments_customer ON customer_payments(customer_id);
|
||||||
|
|
||||||
|
-- Layaway items: line items for a layaway (mirrors quotation_items structure)
|
||||||
|
CREATE TABLE IF NOT EXISTS layaway_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
layaway_id INTEGER REFERENCES layaways(id),
|
||||||
|
inventory_id INTEGER REFERENCES inventory(id),
|
||||||
|
part_number VARCHAR(100),
|
||||||
|
name VARCHAR(300),
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
unit_price NUMERIC(12,2) NOT NULL,
|
||||||
|
discount_pct NUMERIC(5,2) DEFAULT 0,
|
||||||
|
tax_rate NUMERIC(5,4) DEFAULT 0.16,
|
||||||
|
subtotal NUMERIC(12,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_layaway_items_layaway ON layaway_items(layaway_id);
|
||||||
|
|
||||||
|
-- Additional indexes for POS query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_register ON sales(register_id) WHERE status = 'completed';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_customer ON sales(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_created ON sales(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_branch_date ON sales(branch_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_customer ON quotations(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_layaways_customer ON layaways(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cash_registers_employee ON cash_registers(employee_id) WHERE status = 'open';
|
||||||
488
pos/services/pos_engine.py
Normal file
488
pos/services/pos_engine.py
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
# /home/Autopartes/pos/services/pos_engine.py
|
||||||
|
"""POS engine: sale processing, totals calculation, pricing, cancellation.
|
||||||
|
|
||||||
|
All sale operations go through this service. Stock deductions are delegated
|
||||||
|
to inventory_engine.record_sale() — this service NEVER creates inventory
|
||||||
|
operations directly.
|
||||||
|
|
||||||
|
Monetary amounts: NUMERIC(12,2) in DB, float in Python.
|
||||||
|
Tax: 16% IVA per item (from item.tax_rate field).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from flask import g
|
||||||
|
from services.audit import log_action
|
||||||
|
from services.inventory_engine import (
|
||||||
|
record_sale as inventory_record_sale,
|
||||||
|
record_operation,
|
||||||
|
get_stock,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dec(val):
|
||||||
|
"""Convert a value to Decimal for precise arithmetic."""
|
||||||
|
return Decimal(str(val))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_totals(items):
|
||||||
|
"""Compute subtotal, discount amounts, tax, and total for a list of items.
|
||||||
|
|
||||||
|
Uses Python Decimal for all intermediate calculations to avoid
|
||||||
|
floating-point accumulation errors. Each line item is rounded
|
||||||
|
individually before summing. Converts back to float only at
|
||||||
|
the end for JSON serialization.
|
||||||
|
|
||||||
|
Each item dict must have: unit_price, quantity, discount_pct, tax_rate.
|
||||||
|
Returns dict with computed values and enriched items list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: list of dicts with keys:
|
||||||
|
- unit_price (float): price per unit
|
||||||
|
- quantity (int): number of units
|
||||||
|
- discount_pct (float): discount percentage (0-100)
|
||||||
|
- tax_rate (float): tax rate as decimal (e.g., 0.16 for 16%)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {subtotal, discount_total, tax_total, total, items: [...enriched...]}
|
||||||
|
"""
|
||||||
|
subtotal = Decimal('0')
|
||||||
|
discount_total = Decimal('0')
|
||||||
|
tax_total = Decimal('0')
|
||||||
|
enriched_items = []
|
||||||
|
TWO = Decimal('0.01')
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
qty = int(item['quantity'])
|
||||||
|
price = _to_dec(item['unit_price'])
|
||||||
|
discount_pct = _to_dec(item.get('discount_pct', 0))
|
||||||
|
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
||||||
|
|
||||||
|
line_gross = (price * qty).quantize(TWO, rounding=ROUND_HALF_UP)
|
||||||
|
line_discount = (line_gross * discount_pct / Decimal('100')).quantize(TWO, rounding=ROUND_HALF_UP)
|
||||||
|
line_after_discount = (line_gross - line_discount).quantize(TWO, rounding=ROUND_HALF_UP)
|
||||||
|
line_tax = (line_after_discount * tax_rate).quantize(TWO, rounding=ROUND_HALF_UP)
|
||||||
|
line_subtotal = (line_after_discount + line_tax).quantize(TWO, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
subtotal += line_after_discount
|
||||||
|
discount_total += line_discount
|
||||||
|
tax_total += line_tax
|
||||||
|
|
||||||
|
enriched_items.append({
|
||||||
|
**item,
|
||||||
|
'line_gross': float(line_gross),
|
||||||
|
'discount_amount': float(line_discount),
|
||||||
|
'tax_amount': float(line_tax),
|
||||||
|
'subtotal': float(line_subtotal),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'subtotal': float(subtotal.quantize(TWO, rounding=ROUND_HALF_UP)),
|
||||||
|
'discount_total': float(discount_total.quantize(TWO, rounding=ROUND_HALF_UP)),
|
||||||
|
'tax_total': float(tax_total.quantize(TWO, rounding=ROUND_HALF_UP)),
|
||||||
|
'total': float((subtotal + tax_total).quantize(TWO, rounding=ROUND_HALF_UP)),
|
||||||
|
'items': enriched_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_price_for_customer(inventory_item, customer):
|
||||||
|
"""Return the correct price based on the customer's price tier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
inventory_item: dict with price_1, price_2, price_3
|
||||||
|
customer: dict with price_tier (1, 2, or 3), or None for publico general
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: the applicable price
|
||||||
|
"""
|
||||||
|
if customer is None:
|
||||||
|
return float(inventory_item.get('price_1', 0))
|
||||||
|
|
||||||
|
tier = customer.get('price_tier', 1)
|
||||||
|
if tier == 3:
|
||||||
|
price = inventory_item.get('price_3', 0)
|
||||||
|
elif tier == 2:
|
||||||
|
price = inventory_item.get('price_2', 0)
|
||||||
|
else:
|
||||||
|
price = inventory_item.get('price_1', 0)
|
||||||
|
|
||||||
|
return float(price) if price else float(inventory_item.get('price_1', 0))
|
||||||
|
|
||||||
|
|
||||||
|
def get_margin_info(inventory_item, selling_price=None, discount_pct=0):
|
||||||
|
"""Return cost, price, margin %, and max discount without losing margin.
|
||||||
|
|
||||||
|
Only meaningful for employees with pos.view_cost permission (checked by caller).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
inventory_item: dict with cost, price_1 (or selling_price override)
|
||||||
|
selling_price: override price (if None, uses price_1)
|
||||||
|
discount_pct: current discount percentage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {cost, price, margin_pct, max_discount_pct}
|
||||||
|
"""
|
||||||
|
cost = float(inventory_item.get('cost', 0))
|
||||||
|
price = float(selling_price) if selling_price else float(inventory_item.get('price_1', 0))
|
||||||
|
|
||||||
|
if price <= 0:
|
||||||
|
return {'cost': cost, 'price': price, 'margin_pct': 0.0, 'max_discount_pct': 0.0}
|
||||||
|
|
||||||
|
effective_price = price * (1 - discount_pct / 100)
|
||||||
|
margin_pct = ((effective_price - cost) / effective_price * 100) if effective_price > 0 else 0.0
|
||||||
|
|
||||||
|
# Max discount before margin hits zero: price * (1 - d/100) = cost => d = (1 - cost/price) * 100
|
||||||
|
max_discount_pct = ((1 - cost / price) * 100) if price > cost else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'cost': round(cost, 2),
|
||||||
|
'price': round(price, 2),
|
||||||
|
'margin_pct': round(margin_pct, 2),
|
||||||
|
'max_discount_pct': round(max_discount_pct, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_sale(conn, sale_data):
|
||||||
|
"""Process a complete sale: validate, create records, deduct inventory, record payment.
|
||||||
|
|
||||||
|
This is the main entry point for creating a sale. It handles the full transaction:
|
||||||
|
1. Validate all items exist and have sufficient stock
|
||||||
|
2. Calculate totals
|
||||||
|
3. Create sale + sale_items records
|
||||||
|
4. Call inventory_engine.record_sale() for each item (stock deduction)
|
||||||
|
5. Record payment on cash register
|
||||||
|
6. Update customer credit balance (if credit sale)
|
||||||
|
7. Create audit log entry
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection to tenant DB (caller controls commit)
|
||||||
|
sale_data: dict with keys:
|
||||||
|
- items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}]
|
||||||
|
- customer_id: int or None (publico general)
|
||||||
|
- payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto'
|
||||||
|
- sale_type: 'cash' | 'credit' | 'mixed'
|
||||||
|
- register_id: int (cash register session ID)
|
||||||
|
- amount_paid: float
|
||||||
|
- payment_details: [{method, amount, reference}] (for mixed payments)
|
||||||
|
- notes: str (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: complete sale object with id, items, totals, change
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: on validation errors (insufficient stock, invalid items, etc.)
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
items = sale_data.get('items', [])
|
||||||
|
customer_id = sale_data.get('customer_id')
|
||||||
|
payment_method = sale_data.get('payment_method', 'efectivo')
|
||||||
|
sale_type = sale_data.get('sale_type', 'cash')
|
||||||
|
register_id = sale_data.get('register_id')
|
||||||
|
amount_paid = float(sale_data.get('amount_paid', 0))
|
||||||
|
payment_details = sale_data.get('payment_details', [])
|
||||||
|
notes = sale_data.get('notes')
|
||||||
|
branch_id = getattr(g, 'branch_id', None)
|
||||||
|
employee_id = getattr(g, 'employee_id', None)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
raise ValueError("No items in sale")
|
||||||
|
|
||||||
|
# Validate register is open
|
||||||
|
if register_id:
|
||||||
|
cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
|
||||||
|
reg = cur.fetchone()
|
||||||
|
if not reg or reg[0] != 'open':
|
||||||
|
raise ValueError("Cash register is not open")
|
||||||
|
|
||||||
|
# Validate and enrich items from inventory
|
||||||
|
enriched_items = []
|
||||||
|
for item in items:
|
||||||
|
inv_id = item.get('inventory_id')
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
if qty <= 0:
|
||||||
|
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||||
|
tax_rate, branch_id
|
||||||
|
FROM inventory WHERE id = %s AND is_active = true
|
||||||
|
""", (inv_id,))
|
||||||
|
inv = cur.fetchone()
|
||||||
|
if not inv:
|
||||||
|
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||||
|
|
||||||
|
# Check stock (allow negative stock for offline tolerance, but warn)
|
||||||
|
current_stock = get_stock(conn, inv_id, inv[8]) # inv[8] = branch_id
|
||||||
|
|
||||||
|
# Use provided price or fetch from inventory
|
||||||
|
unit_price = float(item.get('unit_price', inv[4])) # default to price_1
|
||||||
|
discount_pct = float(item.get('discount_pct', 0))
|
||||||
|
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
|
||||||
|
unit_cost = float(inv[3]) if inv[3] else 0
|
||||||
|
|
||||||
|
# Validate discount against employee max
|
||||||
|
max_discount = float(getattr(g, 'max_discount_pct', 100) or 100)
|
||||||
|
if g.employee_role not in ('owner', 'admin') and discount_pct > max_discount:
|
||||||
|
raise ValueError(
|
||||||
|
f"Discount {discount_pct}% exceeds your maximum allowed {max_discount}% "
|
||||||
|
f"for item {inv[2]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched_items.append({
|
||||||
|
'inventory_id': inv_id,
|
||||||
|
'part_number': inv[1],
|
||||||
|
'name': inv[2],
|
||||||
|
'quantity': qty,
|
||||||
|
'unit_price': unit_price,
|
||||||
|
'unit_cost': unit_cost,
|
||||||
|
'discount_pct': discount_pct,
|
||||||
|
'tax_rate': tax_rate,
|
||||||
|
'branch_id': inv[8],
|
||||||
|
'stock_before': current_stock,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
totals = calculate_totals(enriched_items)
|
||||||
|
|
||||||
|
# Validate credit sale
|
||||||
|
if sale_type == 'credit' and customer_id:
|
||||||
|
cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
cust = cur.fetchone()
|
||||||
|
if cust:
|
||||||
|
credit_limit = float(cust[0] or 0)
|
||||||
|
credit_balance = float(cust[1] or 0)
|
||||||
|
credit_available = credit_limit - credit_balance
|
||||||
|
if totals['total'] > credit_available and credit_limit > 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
||||||
|
f"Required: ${totals['total']:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate change
|
||||||
|
change_given = 0.0
|
||||||
|
if sale_type == 'cash' and payment_method == 'efectivo':
|
||||||
|
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||||
|
|
||||||
|
# SAT payment method codes
|
||||||
|
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
|
||||||
|
forma_pago_map = {
|
||||||
|
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
||||||
|
}
|
||||||
|
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||||
|
|
||||||
|
# Create sale record
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sales
|
||||||
|
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||||
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
|
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||||
|
status, device_id, notes)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s)
|
||||||
|
RETURNING id, created_at
|
||||||
|
""", (
|
||||||
|
branch_id, customer_id, employee_id, register_id, sale_type,
|
||||||
|
payment_method, totals['subtotal'], totals['discount_total'],
|
||||||
|
totals['tax_total'], totals['total'], amount_paid, change_given,
|
||||||
|
metodo_pago_sat, forma_pago_sat,
|
||||||
|
getattr(g, 'device_id', None), notes
|
||||||
|
))
|
||||||
|
sale_id, created_at = cur.fetchone()
|
||||||
|
|
||||||
|
# Create sale items and deduct inventory
|
||||||
|
sale_items = []
|
||||||
|
for idx, item in enumerate(totals['items']):
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sale_items
|
||||||
|
(sale_id, inventory_id, part_number, name, quantity,
|
||||||
|
unit_price, unit_cost, discount_pct, discount_amount,
|
||||||
|
tax_rate, tax_amount, subtotal)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING id
|
||||||
|
""", (
|
||||||
|
sale_id, item['inventory_id'], item['part_number'], item['name'],
|
||||||
|
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
|
||||||
|
item['discount_pct'], item['discount_amount'],
|
||||||
|
item['tax_rate'], item['tax_amount'], item['subtotal']
|
||||||
|
))
|
||||||
|
sale_item_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Deduct inventory via inventory_engine (NEVER create operations directly)
|
||||||
|
inventory_record_sale(
|
||||||
|
conn,
|
||||||
|
item['inventory_id'],
|
||||||
|
item.get('branch_id', branch_id),
|
||||||
|
item['quantity'],
|
||||||
|
sale_id=sale_id,
|
||||||
|
cost_at_time=item.get('unit_cost')
|
||||||
|
)
|
||||||
|
|
||||||
|
sale_items.append({
|
||||||
|
'id': sale_item_id,
|
||||||
|
'inventory_id': item['inventory_id'],
|
||||||
|
'part_number': item['part_number'],
|
||||||
|
'name': item['name'],
|
||||||
|
'quantity': item['quantity'],
|
||||||
|
'unit_price': item['unit_price'],
|
||||||
|
'unit_cost': item.get('unit_cost', 0),
|
||||||
|
'discount_pct': item['discount_pct'],
|
||||||
|
'discount_amount': item['discount_amount'],
|
||||||
|
'tax_rate': item['tax_rate'],
|
||||||
|
'tax_amount': item['tax_amount'],
|
||||||
|
'subtotal': item['subtotal'],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Record payment on cash register (cash movements for efectivo)
|
||||||
|
if register_id and payment_details:
|
||||||
|
for pd in payment_details:
|
||||||
|
method = pd.get('method', payment_method)
|
||||||
|
amt = float(pd.get('amount', 0))
|
||||||
|
ref = pd.get('reference', '')
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sale_payments
|
||||||
|
(sale_id, register_id, method, amount, reference)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)
|
||||||
|
""", (sale_id, register_id, method, amt, ref))
|
||||||
|
elif register_id:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sale_payments
|
||||||
|
(sale_id, register_id, method, amount, reference)
|
||||||
|
VALUES (%s,%s,%s,%s,%s)
|
||||||
|
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', '')))
|
||||||
|
|
||||||
|
# Update customer credit balance if credit sale
|
||||||
|
if sale_type == 'credit' and customer_id:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE customers
|
||||||
|
SET credit_balance = credit_balance + %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (totals['total'], customer_id))
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
log_action(conn, 'SALE', 'sale', sale_id,
|
||||||
|
new_value={
|
||||||
|
'total': totals['total'],
|
||||||
|
'items_count': len(sale_items),
|
||||||
|
'payment_method': payment_method,
|
||||||
|
'sale_type': sale_type,
|
||||||
|
'customer_id': customer_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': sale_id,
|
||||||
|
'branch_id': branch_id,
|
||||||
|
'customer_id': customer_id,
|
||||||
|
'employee_id': employee_id,
|
||||||
|
'register_id': register_id,
|
||||||
|
'sale_type': sale_type,
|
||||||
|
'payment_method': payment_method,
|
||||||
|
'subtotal': totals['subtotal'],
|
||||||
|
'discount_total': totals['discount_total'],
|
||||||
|
'tax_total': totals['tax_total'],
|
||||||
|
'total': totals['total'],
|
||||||
|
'amount_paid': amount_paid,
|
||||||
|
'change_given': change_given,
|
||||||
|
'metodo_pago_sat': metodo_pago_sat,
|
||||||
|
'forma_pago_sat': forma_pago_sat,
|
||||||
|
'status': 'completed',
|
||||||
|
'items': sale_items,
|
||||||
|
'created_at': str(created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_sale(conn, sale_id, reason):
|
||||||
|
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
||||||
|
|
||||||
|
Business rules:
|
||||||
|
- Cashiers can only cancel their own sales within 30 minutes
|
||||||
|
- Admins and owners can cancel any sale
|
||||||
|
- Cancelled sales are not deleted, just marked as 'cancelled'
|
||||||
|
- Inventory is restored via RETURN operations
|
||||||
|
- Customer credit balance is adjusted back
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
sale_id: int
|
||||||
|
reason: str (mandatory, min 3 chars)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: cancellation result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: on validation errors
|
||||||
|
"""
|
||||||
|
if not reason or len(reason.strip()) < 3:
|
||||||
|
raise ValueError("Cancellation reason is mandatory (min 3 characters)")
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Get sale details
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, employee_id, customer_id, sale_type, total, status, created_at,
|
||||||
|
branch_id, register_id
|
||||||
|
FROM sales WHERE id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
sale = cur.fetchone()
|
||||||
|
if not sale:
|
||||||
|
raise ValueError("Sale not found")
|
||||||
|
|
||||||
|
s_id, s_emp_id, s_cust_id, s_type, s_total, s_status, s_created, s_branch, s_register = sale
|
||||||
|
|
||||||
|
if s_status == 'cancelled':
|
||||||
|
raise ValueError("Sale is already cancelled")
|
||||||
|
|
||||||
|
# Permission check: cashiers can only cancel own sales within 30 min
|
||||||
|
role = getattr(g, 'employee_role', 'cashier')
|
||||||
|
emp_id = getattr(g, 'employee_id', None)
|
||||||
|
|
||||||
|
if role == 'cashier':
|
||||||
|
if s_emp_id != emp_id:
|
||||||
|
raise ValueError("Cashiers can only cancel their own sales")
|
||||||
|
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
||||||
|
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
|
||||||
|
|
||||||
|
# Get sale items for inventory reversal
|
||||||
|
cur.execute("""
|
||||||
|
SELECT inventory_id, quantity, unit_cost
|
||||||
|
FROM sale_items WHERE sale_id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
sale_items = cur.fetchall()
|
||||||
|
|
||||||
|
# Reverse inventory: create RETURN operations (positive quantity)
|
||||||
|
from services.inventory_engine import record_return
|
||||||
|
for inv_id, qty, cost in sale_items:
|
||||||
|
record_return(
|
||||||
|
conn, inv_id, s_branch, qty,
|
||||||
|
sale_id=sale_id,
|
||||||
|
notes=f"Cancelacion venta #{sale_id}: {reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update sale status
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE sales SET status = 'cancelled', notes = COALESCE(notes || ' | ', '') || %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (f"CANCELADA: {reason}", sale_id))
|
||||||
|
|
||||||
|
# Reverse customer credit balance if credit sale
|
||||||
|
if s_type == 'credit' and s_cust_id:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE customers
|
||||||
|
SET credit_balance = credit_balance - %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (float(s_total), s_cust_id))
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
log_action(conn, 'CANCEL', 'sale', sale_id,
|
||||||
|
old_value={'status': 'completed', 'total': float(s_total)},
|
||||||
|
new_value={'status': 'cancelled', 'reason': reason})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'sale_id': sale_id,
|
||||||
|
'status': 'cancelled',
|
||||||
|
'reason': reason,
|
||||||
|
'items_reversed': len(sale_items),
|
||||||
|
'total_reversed': float(s_total),
|
||||||
|
}
|
||||||
307
pos/static/js/customers.js
Normal file
307
pos/static/js/customers.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
// /home/Autopartes/pos/static/js/customers.js
|
||||||
|
/**
|
||||||
|
* Customers management frontend.
|
||||||
|
* Communicates with /pos/api/customers (customers_bp).
|
||||||
|
*/
|
||||||
|
const Customers = (() => {
|
||||||
|
let token = localStorage.getItem('pos_token') || '';
|
||||||
|
let currentPage = 1;
|
||||||
|
let currentCustomer = null;
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
|
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
|
||||||
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(url, options = {}) {
|
||||||
|
options.headers = headers();
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List ────────────────────────────
|
||||||
|
async function loadCustomers(page, q) {
|
||||||
|
page = page || currentPage;
|
||||||
|
q = q !== undefined ? q : (document.getElementById('searchInput').value || '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page, per_page: 50 });
|
||||||
|
if (q) params.append('q', q);
|
||||||
|
|
||||||
|
const data = await api(`/pos/api/customers?${params}`);
|
||||||
|
renderTable(data.data);
|
||||||
|
renderPagination(data.pagination);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Load customers failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(customers) {
|
||||||
|
const tbody = document.getElementById('customersBody');
|
||||||
|
const tiers = { 1: ['Mostrador', 'tier-1'], 2: ['Taller', 'tier-2'], 3: ['Mayoreo', 'tier-3'] };
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
customers.forEach(c => {
|
||||||
|
const [tierName, tierClass] = tiers[c.price_tier] || ['P1', 'tier-1'];
|
||||||
|
const limit = c.credit_limit || 0;
|
||||||
|
const balance = c.credit_balance || 0;
|
||||||
|
const usagePct = limit > 0 ? Math.min(100, (balance / limit) * 100) : 0;
|
||||||
|
const fillClass = usagePct > 90 ? 'danger' : usagePct > 70 ? 'warning' : '';
|
||||||
|
|
||||||
|
html += `<tr onclick="Customers.showDetail(${c.id})">
|
||||||
|
<td><strong>${c.name}</strong></td>
|
||||||
|
<td>${c.rfc || '-'}</td>
|
||||||
|
<td>${c.phone || '-'}</td>
|
||||||
|
<td><span class="tier-badge ${tierClass}">${tierName}</span></td>
|
||||||
|
<td>${fmt(limit)}
|
||||||
|
${limit > 0 ? `<div class="credit-bar"><div class="credit-fill ${fillClass}" style="width:${usagePct}%"></div></div>` : ''}
|
||||||
|
</td>
|
||||||
|
<td>${balance > 0 ? fmt(balance) : '-'}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (customers.length === 0) {
|
||||||
|
html = '<tr><td colspan="6" style="text-align:center;color:#999;padding:20px;">Sin resultados</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(pag) {
|
||||||
|
const container = document.getElementById('pagination');
|
||||||
|
if (pag.total_pages <= 1) { container.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (pag.page > 1) {
|
||||||
|
html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page - 1})">Anterior</button>`;
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= Math.min(pag.total_pages, 10); i++) {
|
||||||
|
html += `<button class="btn ${i === pag.page ? 'active' : 'btn-secondary'}" onclick="Customers.goToPage(${i})">${i}</button>`;
|
||||||
|
}
|
||||||
|
if (pag.page < pag.total_pages) {
|
||||||
|
html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page + 1})">Siguiente</button>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
currentPage = page;
|
||||||
|
loadCustomers(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentPage = 1;
|
||||||
|
loadCustomers(1);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Detail ──────────────────────────
|
||||||
|
async function showDetail(id) {
|
||||||
|
try {
|
||||||
|
const c = await api(`/pos/api/customers/${id}`);
|
||||||
|
currentCustomer = c;
|
||||||
|
|
||||||
|
document.getElementById('detailName').textContent = c.name;
|
||||||
|
|
||||||
|
// Credit
|
||||||
|
const available = (c.credit_limit || 0) - (c.credit_balance || 0);
|
||||||
|
document.getElementById('detailCreditAvailable').textContent = fmt(available);
|
||||||
|
document.getElementById('detailCreditLimit').textContent = fmt(c.credit_limit);
|
||||||
|
document.getElementById('detailCreditBalance').textContent = fmt(c.credit_balance);
|
||||||
|
|
||||||
|
// Fiscal
|
||||||
|
let fiscalHtml = '';
|
||||||
|
fiscalHtml += `<div class="detail-field"><span class="label">RFC</span><span>${c.rfc || '-'}</span></div>`;
|
||||||
|
fiscalHtml += `<div class="detail-field"><span class="label">Razon Social</span><span>${c.razon_social || '-'}</span></div>`;
|
||||||
|
fiscalHtml += `<div class="detail-field"><span class="label">Regimen</span><span>${c.regimen_fiscal || '-'}</span></div>`;
|
||||||
|
fiscalHtml += `<div class="detail-field"><span class="label">Uso CFDI</span><span>${c.uso_cfdi || '-'}</span></div>`;
|
||||||
|
fiscalHtml += `<div class="detail-field"><span class="label">CP</span><span>${c.cp || '-'}</span></div>`;
|
||||||
|
document.getElementById('detailFiscal').innerHTML = fiscalHtml;
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
let contactHtml = '';
|
||||||
|
contactHtml += `<div class="detail-field"><span class="label">Telefono</span><span>${c.phone || '-'}</span></div>`;
|
||||||
|
contactHtml += `<div class="detail-field"><span class="label">Email</span><span>${c.email || '-'}</span></div>`;
|
||||||
|
contactHtml += `<div class="detail-field"><span class="label">Direccion</span><span>${c.address || '-'}</span></div>`;
|
||||||
|
document.getElementById('detailContact').innerHTML = contactHtml;
|
||||||
|
|
||||||
|
// Vehicles
|
||||||
|
const vehicles = c.vehicle_info || [];
|
||||||
|
if (vehicles.length === 0) {
|
||||||
|
document.getElementById('detailVehicles').innerHTML = '<div style="color:#999;font-size:13px;">Sin vehiculos registrados</div>';
|
||||||
|
} else {
|
||||||
|
document.getElementById('detailVehicles').innerHTML = vehicles.map(v =>
|
||||||
|
`<div class="vehicle-card">
|
||||||
|
<strong>${v.make || ''} ${v.model || ''} ${v.year || ''}</strong>
|
||||||
|
${v.plates ? `<span style="margin-left:8px;color:#666;">Placas: ${v.plates}</span>` : ''}
|
||||||
|
${v.vin ? `<div style="font-size:11px;color:#999;">VIN: ${v.vin}</div>` : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent purchases
|
||||||
|
const purchases = c.recent_purchases || [];
|
||||||
|
if (purchases.length === 0) {
|
||||||
|
document.getElementById('detailPurchases').innerHTML = '<div style="color:#999;font-size:13px;">Sin compras recientes</div>';
|
||||||
|
} else {
|
||||||
|
document.getElementById('detailPurchases').innerHTML = purchases.map(p =>
|
||||||
|
`<div class="purchase-item">
|
||||||
|
<span>Venta #${p.id} - ${new Date(p.created_at).toLocaleDateString('es-MX')}</span>
|
||||||
|
<span>${fmt(p.total)}</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detailPanel').classList.add('active');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
document.getElementById('detailPanel').classList.remove('active');
|
||||||
|
currentCustomer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create/Edit Modal ───────────────
|
||||||
|
function showCreateModal() {
|
||||||
|
document.getElementById('modalTitle').textContent = 'Nuevo Cliente';
|
||||||
|
document.getElementById('editId').value = '';
|
||||||
|
document.getElementById('fName').value = '';
|
||||||
|
document.getElementById('fRfc').value = '';
|
||||||
|
document.getElementById('fRazonSocial').value = '';
|
||||||
|
document.getElementById('fRegimenFiscal').value = '';
|
||||||
|
document.getElementById('fUsoCfdi').value = 'G03';
|
||||||
|
document.getElementById('fCp').value = '';
|
||||||
|
document.getElementById('fPhone').value = '';
|
||||||
|
document.getElementById('fEmail').value = '';
|
||||||
|
document.getElementById('fAddress').value = '';
|
||||||
|
document.getElementById('fPriceTier').value = '1';
|
||||||
|
document.getElementById('fCreditLimit').value = '0';
|
||||||
|
document.getElementById('customerModal').classList.add('active');
|
||||||
|
document.getElementById('fName').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCurrent() {
|
||||||
|
if (!currentCustomer) return;
|
||||||
|
const c = currentCustomer;
|
||||||
|
document.getElementById('modalTitle').textContent = 'Editar Cliente';
|
||||||
|
document.getElementById('editId').value = c.id;
|
||||||
|
document.getElementById('fName').value = c.name || '';
|
||||||
|
document.getElementById('fRfc').value = c.rfc || '';
|
||||||
|
document.getElementById('fRazonSocial').value = c.razon_social || '';
|
||||||
|
document.getElementById('fRegimenFiscal').value = c.regimen_fiscal || '';
|
||||||
|
document.getElementById('fUsoCfdi').value = c.uso_cfdi || 'G03';
|
||||||
|
document.getElementById('fCp').value = c.cp || '';
|
||||||
|
document.getElementById('fPhone').value = c.phone || '';
|
||||||
|
document.getElementById('fEmail').value = c.email || '';
|
||||||
|
document.getElementById('fAddress').value = c.address || '';
|
||||||
|
document.getElementById('fPriceTier').value = c.price_tier || '1';
|
||||||
|
document.getElementById('fCreditLimit').value = c.credit_limit || 0;
|
||||||
|
document.getElementById('customerModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('customerModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const name = document.getElementById('fName').value.trim();
|
||||||
|
if (!name) { alert('Nombre es requerido'); return; }
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: name,
|
||||||
|
rfc: document.getElementById('fRfc').value.trim() || null,
|
||||||
|
razon_social: document.getElementById('fRazonSocial').value.trim() || null,
|
||||||
|
regimen_fiscal: document.getElementById('fRegimenFiscal').value || null,
|
||||||
|
uso_cfdi: document.getElementById('fUsoCfdi').value || 'G03',
|
||||||
|
cp: document.getElementById('fCp').value.trim() || null,
|
||||||
|
phone: document.getElementById('fPhone').value.trim() || null,
|
||||||
|
email: document.getElementById('fEmail').value.trim() || null,
|
||||||
|
address: document.getElementById('fAddress').value.trim() || null,
|
||||||
|
price_tier: parseInt(document.getElementById('fPriceTier').value) || 1,
|
||||||
|
credit_limit: parseFloat(document.getElementById('fCreditLimit').value) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const editId = document.getElementById('editId').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api(`/pos/api/customers/${editId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api('/pos/api/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
loadCustomers();
|
||||||
|
if (editId && currentCustomer) {
|
||||||
|
showDetail(editId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Statement ───────────────────────
|
||||||
|
async function showStatement() {
|
||||||
|
if (!currentCustomer) return;
|
||||||
|
document.getElementById('statementName').textContent = currentCustomer.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api(`/pos/api/customers/${currentCustomer.id}/statement`);
|
||||||
|
|
||||||
|
let html = `<div style="margin-bottom:12px;font-size:14px;">
|
||||||
|
<strong>Saldo actual: ${fmt(data.balance)}</strong> |
|
||||||
|
Limite: ${fmt(data.customer.credit_limit)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (data.entries.length === 0) {
|
||||||
|
html += '<div style="color:#999;padding:20px;text-align:center;">Sin movimientos</div>';
|
||||||
|
} else {
|
||||||
|
html += '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
|
||||||
|
html += '<tr style="background:#f5f5f5;"><th style="padding:8px;text-align:left;">Fecha</th><th style="text-align:left;">Concepto</th><th style="text-align:right;padding:8px;">Cargo</th><th style="text-align:right;padding:8px;">Abono</th><th style="text-align:right;padding:8px;">Saldo</th></tr>';
|
||||||
|
|
||||||
|
data.entries.forEach(e => {
|
||||||
|
const dateStr = new Date(e.date).toLocaleDateString('es-MX');
|
||||||
|
html += `<tr style="border-bottom:1px solid #eee;">
|
||||||
|
<td style="padding:6px 8px;">${dateStr}</td>
|
||||||
|
<td>${e.description}</td>
|
||||||
|
<td style="text-align:right;padding:6px 8px;">${e.type === 'charge' ? fmt(e.amount) : ''}</td>
|
||||||
|
<td style="text-align:right;padding:6px 8px;">${e.type === 'payment' ? fmt(e.amount) : ''}</td>
|
||||||
|
<td style="text-align:right;padding:6px 8px;">${fmt(e.running_balance)}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('statementContent').innerHTML = html;
|
||||||
|
document.getElementById('statementModal').classList.add('active');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ────────────────────────────
|
||||||
|
loadCustomers();
|
||||||
|
|
||||||
|
return {
|
||||||
|
search, goToPage, loadCustomers,
|
||||||
|
showDetail, closeDetail,
|
||||||
|
showCreateModal, editCurrent, closeModal, save,
|
||||||
|
showStatement,
|
||||||
|
};
|
||||||
|
})();
|
||||||
928
pos/static/js/pos.js
Normal file
928
pos/static/js/pos.js
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
// /home/Autopartes/pos/static/js/pos.js
|
||||||
|
/**
|
||||||
|
* POS Frontend: sale processing, F-key shortcuts, payment modal, ticket printing.
|
||||||
|
*
|
||||||
|
* Communicates with:
|
||||||
|
* - /pos/api/sales (pos_bp)
|
||||||
|
* - /pos/api/quotations (pos_bp)
|
||||||
|
* - /pos/api/layaways (pos_bp)
|
||||||
|
* - /pos/api/customers (customers_bp)
|
||||||
|
* - /pos/api/register (cashregister_bp)
|
||||||
|
* - /pos/api/inventory/items (inventory_bp) — for item search
|
||||||
|
* - /pos/api/catalog/search (catalog_bp) — for catalog search
|
||||||
|
*/
|
||||||
|
const POS = (() => {
|
||||||
|
// ─── State ───────────────────────────
|
||||||
|
let token = localStorage.getItem('pos_token') || '';
|
||||||
|
let cart = [];
|
||||||
|
let selectedRow = -1;
|
||||||
|
let currentCustomer = null;
|
||||||
|
let currentRegister = null;
|
||||||
|
let paymentMethod = 'efectivo';
|
||||||
|
let canViewCost = false;
|
||||||
|
let employeeMaxDiscount = 100;
|
||||||
|
let lastSaleId = null;
|
||||||
|
let searchTimeout = null;
|
||||||
|
let customerSearchTimeout = null;
|
||||||
|
|
||||||
|
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(url, options = {}) {
|
||||||
|
options.headers = headers();
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
// Parse JWT to get employee info
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
||||||
|
document.getElementById('branchName').textContent = payload.branch_name || '';
|
||||||
|
canViewCost = (payload.permissions || []).includes('pos.view_cost');
|
||||||
|
employeeMaxDiscount = payload.max_discount_pct || 100;
|
||||||
|
|
||||||
|
// Show cost/margin columns if permission
|
||||||
|
if (canViewCost) {
|
||||||
|
document.getElementById('thCost').style.display = '';
|
||||||
|
document.getElementById('thMargin').style.display = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not parse token:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load cart from localStorage (from catalog)
|
||||||
|
const catalogCart = localStorage.getItem('pos_cart');
|
||||||
|
if (catalogCart) {
|
||||||
|
try {
|
||||||
|
const items = JSON.parse(catalogCart);
|
||||||
|
for (const item of items) {
|
||||||
|
addToCart(item);
|
||||||
|
}
|
||||||
|
localStorage.removeItem('pos_cart');
|
||||||
|
} catch (e) { console.warn('Could not load catalog cart:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current register
|
||||||
|
await loadRegister();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
setupKeyboard();
|
||||||
|
setupSearch();
|
||||||
|
setupCustomerSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Register ────────────────────────
|
||||||
|
async function loadRegister() {
|
||||||
|
try {
|
||||||
|
const data = await api('/pos/api/register/current');
|
||||||
|
if (data.register) {
|
||||||
|
currentRegister = data.register;
|
||||||
|
document.getElementById('registerInfo').innerHTML =
|
||||||
|
`<span>Caja #${data.register.register_number}</span>`;
|
||||||
|
document.getElementById('registerInfo').classList.remove('no-register');
|
||||||
|
} else {
|
||||||
|
document.getElementById('registerInfo').innerHTML =
|
||||||
|
'<span>Sin caja abierta</span>';
|
||||||
|
document.getElementById('registerInfo').classList.add('no-register');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Register check failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cart ────────────────────────────
|
||||||
|
function addToCart(item) {
|
||||||
|
// Check if item already in cart
|
||||||
|
const existing = cart.find(c => c.inventory_id === item.inventory_id);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += (item.quantity || 1);
|
||||||
|
renderCart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.push({
|
||||||
|
inventory_id: item.inventory_id || item.id,
|
||||||
|
part_number: item.part_number || '',
|
||||||
|
name: item.name || '',
|
||||||
|
quantity: item.quantity || 1,
|
||||||
|
unit_price: parseFloat(item.unit_price || item.price_1 || 0),
|
||||||
|
unit_cost: parseFloat(item.cost || 0),
|
||||||
|
discount_pct: parseFloat(item.discount_pct || 0),
|
||||||
|
tax_rate: parseFloat(item.tax_rate || 0.16),
|
||||||
|
stock: item.stock || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromCart(index) {
|
||||||
|
cart.splice(index, 1);
|
||||||
|
if (selectedRow >= cart.length) selectedRow = cart.length - 1;
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCart() {
|
||||||
|
const tbody = document.getElementById('cartBody');
|
||||||
|
const table = document.getElementById('cartTable');
|
||||||
|
const empty = document.getElementById('cartEmpty');
|
||||||
|
|
||||||
|
if (cart.length === 0) {
|
||||||
|
table.style.display = 'none';
|
||||||
|
empty.style.display = 'flex';
|
||||||
|
updateTotals();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.style.display = '';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
cart.forEach((item, i) => {
|
||||||
|
const lineGross = item.unit_price * item.quantity;
|
||||||
|
const lineDiscount = lineGross * item.discount_pct / 100;
|
||||||
|
const lineSubtotal = lineGross - lineDiscount;
|
||||||
|
|
||||||
|
const costHtml = canViewCost ? `<td class="num">${fmt(item.unit_cost)}</td>` : '';
|
||||||
|
let marginHtml = '';
|
||||||
|
if (canViewCost) {
|
||||||
|
const effectivePrice = item.unit_price * (1 - item.discount_pct / 100);
|
||||||
|
const marginPct = effectivePrice > 0
|
||||||
|
? ((effectivePrice - item.unit_cost) / effectivePrice * 100).toFixed(1)
|
||||||
|
: '0.0';
|
||||||
|
const cls = parseFloat(marginPct) < 5 ? 'margin-warning' : 'margin-info';
|
||||||
|
marginHtml = `<td class="num"><span class="${cls}">${marginPct}%</span></td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<tr class="${i === selectedRow ? 'selected' : ''}" onclick="POS.selectRow(${i})">
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<div class="part-name">${item.name}</div>
|
||||||
|
<div class="part-number">${item.part_number} | Stock: ${item.stock}</div>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" class="qty-input" value="${item.quantity}" min="1"
|
||||||
|
onchange="POS.updateQty(${i}, this.value)" onclick="event.stopPropagation()"></td>
|
||||||
|
<td class="num">${fmt(item.unit_price)}</td>
|
||||||
|
<td><input type="number" class="discount-input" value="${item.discount_pct}" min="0" max="100" step="0.5"
|
||||||
|
onchange="POS.updateDiscount(${i}, this.value)" onclick="event.stopPropagation()">%</td>
|
||||||
|
<td class="num">${fmt(lineSubtotal)}</td>
|
||||||
|
${costHtml}
|
||||||
|
${marginHtml}
|
||||||
|
<td><button class="btn-remove" onclick="event.stopPropagation(); POS.removeFromCart(${i})">×</button></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
updateTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQty(index, val) {
|
||||||
|
const qty = Math.max(1, parseInt(val) || 1);
|
||||||
|
cart[index].quantity = qty;
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDiscount(index, val) {
|
||||||
|
let disc = Math.max(0, Math.min(100, parseFloat(val) || 0));
|
||||||
|
if (disc > employeeMaxDiscount) {
|
||||||
|
alert(`Descuento maximo permitido: ${employeeMaxDiscount}%`);
|
||||||
|
disc = employeeMaxDiscount;
|
||||||
|
}
|
||||||
|
cart[index].discount_pct = disc;
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRow(index) {
|
||||||
|
selectedRow = index;
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotals() {
|
||||||
|
let subtotal = 0, discountTotal = 0, taxTotal = 0;
|
||||||
|
|
||||||
|
cart.forEach(item => {
|
||||||
|
const lineGross = item.unit_price * item.quantity;
|
||||||
|
const lineDiscount = lineGross * item.discount_pct / 100;
|
||||||
|
const lineAfterDiscount = lineGross - lineDiscount;
|
||||||
|
const lineTax = lineAfterDiscount * item.tax_rate;
|
||||||
|
subtotal += lineAfterDiscount;
|
||||||
|
discountTotal += lineDiscount;
|
||||||
|
taxTotal += lineTax;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = subtotal + taxTotal;
|
||||||
|
|
||||||
|
document.getElementById('dispSubtotal').textContent = fmt(subtotal);
|
||||||
|
document.getElementById('dispTax').textContent = fmt(taxTotal);
|
||||||
|
document.getElementById('dispTotal').textContent = fmt(total);
|
||||||
|
|
||||||
|
if (discountTotal > 0) {
|
||||||
|
document.getElementById('discountRow').style.display = '';
|
||||||
|
document.getElementById('dispDiscount').textContent = '-' + fmt(discountTotal);
|
||||||
|
} else {
|
||||||
|
document.getElementById('discountRow').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotal() {
|
||||||
|
let subtotal = 0, taxTotal = 0;
|
||||||
|
cart.forEach(item => {
|
||||||
|
const lineGross = item.unit_price * item.quantity;
|
||||||
|
const lineDiscount = lineGross * item.discount_pct / 100;
|
||||||
|
const lineAfterDiscount = lineGross - lineDiscount;
|
||||||
|
const lineTax = lineAfterDiscount * item.tax_rate;
|
||||||
|
subtotal += lineAfterDiscount;
|
||||||
|
taxTotal += lineTax;
|
||||||
|
});
|
||||||
|
return Math.round((subtotal + taxTotal) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search ──────────────────────────
|
||||||
|
function setupSearch() {
|
||||||
|
const input = document.getElementById('itemSearch');
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchItems(input.value.trim());
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
input.value = '';
|
||||||
|
hideSearchResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchItems(q) {
|
||||||
|
if (!q || q.length < 2) { hideSearchResults(); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api(`/pos/api/inventory/items?q=${encodeURIComponent(q)}&per_page=20`);
|
||||||
|
const container = document.getElementById('searchResults');
|
||||||
|
const totals = document.getElementById('totalsPanel');
|
||||||
|
|
||||||
|
if (data.data.length === 0) {
|
||||||
|
container.innerHTML = '<div style="padding:20px;text-align:center;color:#999;">Sin resultados</div>';
|
||||||
|
} else {
|
||||||
|
let html = '';
|
||||||
|
data.data.forEach(item => {
|
||||||
|
// Determine price for current customer
|
||||||
|
let price = item.price_1;
|
||||||
|
if (currentCustomer) {
|
||||||
|
const tier = currentCustomer.price_tier || 1;
|
||||||
|
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||||
|
}
|
||||||
|
const stockClass = item.stock <= 0 ? 'zero' : '';
|
||||||
|
html += `<div class="search-result-item" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||||
|
<div>
|
||||||
|
<div class="sr-name">${item.name}</div>
|
||||||
|
<div class="sr-pn">${item.part_number} | ${item.brand || ''}</div>
|
||||||
|
<div class="sr-stock ${stockClass}">Stock: ${item.stock}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sr-price">${fmt(price)}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.add('active');
|
||||||
|
totals.classList.add('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Search error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFromSearch(item, price) {
|
||||||
|
addToCart({
|
||||||
|
inventory_id: item.id,
|
||||||
|
part_number: item.part_number,
|
||||||
|
name: item.name,
|
||||||
|
unit_price: price,
|
||||||
|
cost: item.cost,
|
||||||
|
tax_rate: item.tax_rate,
|
||||||
|
stock: item.stock,
|
||||||
|
});
|
||||||
|
hideSearchResults();
|
||||||
|
document.getElementById('itemSearch').value = '';
|
||||||
|
document.getElementById('itemSearch').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSearchResults() {
|
||||||
|
document.getElementById('searchResults').classList.remove('active');
|
||||||
|
document.getElementById('totalsPanel').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Customer Search ─────────────────
|
||||||
|
function setupCustomerSearch() {
|
||||||
|
const input = document.getElementById('customerSearch');
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(customerSearchTimeout);
|
||||||
|
customerSearchTimeout = setTimeout(() => searchCustomers(input.value.trim()), 300);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
input.value = '';
|
||||||
|
document.getElementById('customerAutocomplete').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCustomers(q) {
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
document.getElementById('customerAutocomplete').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api(`/pos/api/customers?q=${encodeURIComponent(q)}&per_page=10`);
|
||||||
|
const ac = document.getElementById('customerAutocomplete');
|
||||||
|
|
||||||
|
if (data.data.length === 0) {
|
||||||
|
ac.innerHTML = '<div class="ac-item" style="color:#999;">Sin resultados</div>';
|
||||||
|
} else {
|
||||||
|
let html = '';
|
||||||
|
data.data.forEach(c => {
|
||||||
|
const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' };
|
||||||
|
html += `<div class="ac-item" onclick='POS.selectCustomer(${JSON.stringify(c).replace(/'/g, "'")})'>
|
||||||
|
<div>${c.name}</div>
|
||||||
|
<div class="ac-meta">${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
ac.innerHTML = html;
|
||||||
|
}
|
||||||
|
ac.style.display = 'block';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Customer search error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCustomer(customer) {
|
||||||
|
currentCustomer = customer;
|
||||||
|
document.getElementById('customerAutocomplete').style.display = 'none';
|
||||||
|
document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none';
|
||||||
|
|
||||||
|
const tiers = { 1: 'P1 Mostrador', 2: 'P2 Taller', 3: 'P3 Mayoreo' };
|
||||||
|
document.getElementById('customerName').textContent = customer.name;
|
||||||
|
document.getElementById('customerTier').textContent = tiers[customer.price_tier] || 'P1';
|
||||||
|
document.getElementById('customerCredit').textContent =
|
||||||
|
`Credito: ${fmt(customer.credit_balance)} / ${fmt(customer.credit_limit)}`;
|
||||||
|
document.getElementById('customerSelected').style.display = '';
|
||||||
|
|
||||||
|
// Show vehicle info
|
||||||
|
if (customer.vehicle_info && customer.vehicle_info.length > 0) {
|
||||||
|
const v = customer.vehicle_info[0];
|
||||||
|
document.getElementById('vehicleInfo').textContent =
|
||||||
|
`${v.make || ''} ${v.model || ''} ${v.year || ''} ${v.plates ? '(' + v.plates + ')' : ''}`;
|
||||||
|
document.getElementById('vehicleBanner').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full customer detail to get recent purchase info
|
||||||
|
try {
|
||||||
|
const detail = await api(`/pos/api/customers/${customer.id}`);
|
||||||
|
if (detail.recent_purchases && detail.recent_purchases.length > 0) {
|
||||||
|
const last = detail.recent_purchases[0];
|
||||||
|
const daysAgo = Math.floor((Date.now() - new Date(last.created_at).getTime()) / 86400000);
|
||||||
|
const daysText = daysAgo === 0 ? 'hoy' : daysAgo === 1 ? 'hace 1 dia' : `hace ${daysAgo} dias`;
|
||||||
|
document.getElementById('lastPurchaseInfo').textContent =
|
||||||
|
`Ultima compra: ${fmt(last.total)} ${daysText}`;
|
||||||
|
document.getElementById('vehicleBanner').classList.add('visible');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not fetch customer detail:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply prices based on customer tier
|
||||||
|
cart.forEach(item => {
|
||||||
|
// Fetch updated price for this customer tier (would need to re-query)
|
||||||
|
// For now, prices stay as-is (they were set at add time)
|
||||||
|
});
|
||||||
|
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCustomer() {
|
||||||
|
currentCustomer = null;
|
||||||
|
document.getElementById('customerSelected').style.display = 'none';
|
||||||
|
document.getElementById('customerSearchWrap').querySelector('input').style.display = '';
|
||||||
|
document.getElementById('customerSearchWrap').querySelector('input').value = '';
|
||||||
|
document.getElementById('vehicleBanner').classList.remove('visible');
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── New Customer Modal ──────────────
|
||||||
|
function showNewCustomerModal() {
|
||||||
|
document.getElementById('newCustomerModal').classList.add('active');
|
||||||
|
document.getElementById('ncName').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNewCustomerModal() {
|
||||||
|
document.getElementById('newCustomerModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNewCustomer() {
|
||||||
|
const name = document.getElementById('ncName').value.trim();
|
||||||
|
if (!name) { alert('Nombre es requerido'); return; }
|
||||||
|
|
||||||
|
const vehicle_info = [];
|
||||||
|
const make = document.getElementById('ncVehMake').value.trim();
|
||||||
|
if (make) {
|
||||||
|
vehicle_info.push({
|
||||||
|
make: make,
|
||||||
|
model: document.getElementById('ncVehModel').value.trim(),
|
||||||
|
year: document.getElementById('ncVehYear').value.trim(),
|
||||||
|
plates: document.getElementById('ncVehPlates').value.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: name,
|
||||||
|
rfc: document.getElementById('ncRfc').value.trim() || null,
|
||||||
|
razon_social: document.getElementById('ncRazonSocial').value.trim() || null,
|
||||||
|
regimen_fiscal: document.getElementById('ncRegimenFiscal').value || null,
|
||||||
|
uso_cfdi: document.getElementById('ncUsoCfdi').value || 'G03',
|
||||||
|
phone: document.getElementById('ncPhone').value.trim() || null,
|
||||||
|
email: document.getElementById('ncEmail').value.trim() || null,
|
||||||
|
price_tier: parseInt(document.getElementById('ncPriceTier').value) || 1,
|
||||||
|
credit_limit: parseFloat(document.getElementById('ncCreditLimit').value) || 0,
|
||||||
|
vehicle_info: vehicle_info.length > 0 ? vehicle_info : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/pos/api/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select the new customer
|
||||||
|
selectCustomer({
|
||||||
|
id: result.id,
|
||||||
|
name: body.name,
|
||||||
|
rfc: body.rfc,
|
||||||
|
phone: body.phone,
|
||||||
|
price_tier: body.price_tier,
|
||||||
|
credit_limit: body.credit_limit,
|
||||||
|
credit_balance: 0,
|
||||||
|
vehicle_info: body.vehicle_info,
|
||||||
|
});
|
||||||
|
|
||||||
|
closeNewCustomerModal();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error al crear cliente: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Payment ─────────────────────────
|
||||||
|
function checkout() {
|
||||||
|
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||||
|
if (!currentRegister) { alert('No hay caja abierta. Abra una caja primero.'); return; }
|
||||||
|
|
||||||
|
paymentMethod = 'efectivo';
|
||||||
|
const total = getTotal();
|
||||||
|
|
||||||
|
document.getElementById('modalTotal').textContent = fmt(total);
|
||||||
|
document.getElementById('cashReceived').value = '';
|
||||||
|
document.getElementById('changeDisplay').textContent = 'Cambio: $0.00';
|
||||||
|
document.getElementById('changeDisplay').className = 'change-display positive';
|
||||||
|
document.getElementById('paymentRef').value = '';
|
||||||
|
|
||||||
|
// Reset payment method buttons
|
||||||
|
document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelector('.pm-btn[data-method="efectivo"]').classList.add('active');
|
||||||
|
document.getElementById('cashPayment').style.display = '';
|
||||||
|
document.getElementById('refPayment').style.display = 'none';
|
||||||
|
document.getElementById('mixedPayment').style.display = 'none';
|
||||||
|
|
||||||
|
document.getElementById('paymentModal').classList.add('active');
|
||||||
|
setTimeout(() => document.getElementById('cashReceived').focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPaymentMethod(method, btn) {
|
||||||
|
paymentMethod = method;
|
||||||
|
document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('cashPayment').style.display = method === 'efectivo' ? '' : 'none';
|
||||||
|
document.getElementById('refPayment').style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
|
||||||
|
document.getElementById('mixedPayment').style.display = method === 'mixto' ? '' : 'none';
|
||||||
|
|
||||||
|
if (method === 'efectivo') document.getElementById('cashReceived').focus();
|
||||||
|
if (method === 'transferencia' || method === 'tarjeta') document.getElementById('paymentRef').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChange() {
|
||||||
|
const total = getTotal();
|
||||||
|
const received = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||||
|
const change = received - total;
|
||||||
|
const el = document.getElementById('changeDisplay');
|
||||||
|
el.textContent = `Cambio: ${fmt(Math.abs(change))}`;
|
||||||
|
el.className = 'change-display ' + (change >= 0 ? 'positive' : 'negative');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMixedTotal() {
|
||||||
|
const total = getTotal();
|
||||||
|
let sum = 0;
|
||||||
|
document.querySelectorAll('.mixed-amount').forEach(input => {
|
||||||
|
sum += parseFloat(input.value) || 0;
|
||||||
|
});
|
||||||
|
const remaining = total - sum;
|
||||||
|
document.getElementById('mixedRemaining').textContent =
|
||||||
|
remaining > 0 ? `Faltante: ${fmt(remaining)}` : `Cubierto (${fmt(sum)})`;
|
||||||
|
document.getElementById('mixedRemaining').style.color = remaining > 0 ? '#c62828' : '#2e7d32';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePaymentModal() {
|
||||||
|
document.getElementById('paymentModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPayment() {
|
||||||
|
const total = getTotal();
|
||||||
|
let amountPaid = 0;
|
||||||
|
let paymentDetails = [];
|
||||||
|
let reference = '';
|
||||||
|
|
||||||
|
if (paymentMethod === 'efectivo') {
|
||||||
|
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||||
|
if (amountPaid < total) { alert(`Monto insuficiente. Total: ${fmt(total)}`); return; }
|
||||||
|
} else if (paymentMethod === 'transferencia' || paymentMethod === 'tarjeta') {
|
||||||
|
amountPaid = total;
|
||||||
|
reference = document.getElementById('paymentRef').value.trim();
|
||||||
|
} else if (paymentMethod === 'mixto') {
|
||||||
|
const rows = document.querySelectorAll('.mixed-row');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const method = row.querySelector('select').value;
|
||||||
|
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
|
||||||
|
const ref = row.querySelectorAll('input')[1]?.value || '';
|
||||||
|
if (amount > 0) {
|
||||||
|
paymentDetails.push({ method, amount, reference: ref });
|
||||||
|
amountPaid += amount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saleData = {
|
||||||
|
items: cart.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
discount_pct: item.discount_pct,
|
||||||
|
tax_rate: item.tax_rate,
|
||||||
|
})),
|
||||||
|
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
sale_type: 'cash',
|
||||||
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
|
amount_paid: amountPaid,
|
||||||
|
payment_details: paymentDetails,
|
||||||
|
reference: reference,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnConfirmPayment').disabled = true;
|
||||||
|
document.getElementById('btnConfirmPayment').textContent = 'Procesando...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sale = await api('/pos/api/sales', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(saleData),
|
||||||
|
});
|
||||||
|
|
||||||
|
lastSaleId = sale.id;
|
||||||
|
closePaymentModal();
|
||||||
|
showTicket(sale);
|
||||||
|
|
||||||
|
// Clear cart
|
||||||
|
cart = [];
|
||||||
|
selectedRow = -1;
|
||||||
|
clearCustomer();
|
||||||
|
renderCart();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error al procesar venta: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
document.getElementById('btnConfirmPayment').disabled = false;
|
||||||
|
document.getElementById('btnConfirmPayment').textContent = 'Confirmar Pago';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Credit Sale ─────────────────────
|
||||||
|
async function creditSale() {
|
||||||
|
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||||
|
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
|
||||||
|
if (!currentRegister) { alert('No hay caja abierta.'); return; }
|
||||||
|
|
||||||
|
const total = getTotal();
|
||||||
|
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
||||||
|
|
||||||
|
if (currentCustomer.credit_limit > 0 && total > available) {
|
||||||
|
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar de todas formas?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saleData = {
|
||||||
|
items: cart.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
discount_pct: item.discount_pct,
|
||||||
|
tax_rate: item.tax_rate,
|
||||||
|
})),
|
||||||
|
customer_id: currentCustomer.id,
|
||||||
|
payment_method: 'credito',
|
||||||
|
sale_type: 'credit',
|
||||||
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
|
amount_paid: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sale = await api('/pos/api/sales', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(saleData),
|
||||||
|
});
|
||||||
|
|
||||||
|
lastSaleId = sale.id;
|
||||||
|
showTicket(sale);
|
||||||
|
cart = [];
|
||||||
|
selectedRow = -1;
|
||||||
|
clearCustomer();
|
||||||
|
renderCart();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Quotation ───────────────────────
|
||||||
|
async function saveQuotation() {
|
||||||
|
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
items: cart.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
discount_pct: item.discount_pct,
|
||||||
|
tax_rate: item.tax_rate,
|
||||||
|
})),
|
||||||
|
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/pos/api/quotations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
alert(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}\nValida hasta: ${result.valid_until}`);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Layaway ─────────────────────────
|
||||||
|
async function createLayaway() {
|
||||||
|
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||||
|
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
|
||||||
|
|
||||||
|
const total = getTotal();
|
||||||
|
const initialPayment = prompt(`Total: ${fmt(total)}\nIngrese monto del anticipo:`);
|
||||||
|
if (!initialPayment) return;
|
||||||
|
|
||||||
|
const amount = parseFloat(initialPayment);
|
||||||
|
if (isNaN(amount) || amount <= 0) { alert('Monto invalido'); return; }
|
||||||
|
if (amount > total) { alert('El anticipo no puede exceder el total'); return; }
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
items: cart.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
discount_pct: item.discount_pct,
|
||||||
|
tax_rate: item.tax_rate,
|
||||||
|
})),
|
||||||
|
customer_id: currentCustomer.id,
|
||||||
|
initial_payment: amount,
|
||||||
|
payment_method: 'efectivo',
|
||||||
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/pos/api/layaways', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
alert(
|
||||||
|
`Apartado #${result.id} creado.\n` +
|
||||||
|
`Total: ${fmt(result.total)}\n` +
|
||||||
|
`Anticipo: ${fmt(result.amount_paid)}\n` +
|
||||||
|
`Restante: ${fmt(result.remaining)}\n` +
|
||||||
|
`Vence: ${result.expires_at}`
|
||||||
|
);
|
||||||
|
cart = [];
|
||||||
|
selectedRow = -1;
|
||||||
|
clearCustomer();
|
||||||
|
renderCart();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ticket ──────────────────────────
|
||||||
|
function showTicket(sale) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push('========================================');
|
||||||
|
lines.push(' NEXUS POS');
|
||||||
|
lines.push('========================================');
|
||||||
|
lines.push(`Venta #${sale.id}`);
|
||||||
|
lines.push(`Fecha: ${new Date(sale.created_at).toLocaleString('es-MX')}`);
|
||||||
|
if (currentCustomer) {
|
||||||
|
lines.push(`Cliente: ${currentCustomer.name}`);
|
||||||
|
if (currentCustomer.rfc) lines.push(`RFC: ${currentCustomer.rfc}`);
|
||||||
|
} else {
|
||||||
|
lines.push('Cliente: Publico General');
|
||||||
|
}
|
||||||
|
lines.push('----------------------------------------');
|
||||||
|
|
||||||
|
(sale.items || []).forEach(item => {
|
||||||
|
lines.push(`${item.name}`);
|
||||||
|
let line = ` ${item.quantity} x ${fmt(item.unit_price)}`;
|
||||||
|
if (item.discount_pct > 0) line += ` (-${item.discount_pct}%)`;
|
||||||
|
line += ` ${fmt(item.subtotal)}`;
|
||||||
|
lines.push(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('----------------------------------------');
|
||||||
|
lines.push(`Subtotal: ${fmt(sale.subtotal).padStart(12)}`);
|
||||||
|
if (sale.discount_total > 0) {
|
||||||
|
lines.push(`Descuento: -${fmt(sale.discount_total).padStart(12)}`);
|
||||||
|
}
|
||||||
|
lines.push(`IVA: ${fmt(sale.tax_total).padStart(12)}`);
|
||||||
|
lines.push('========================================');
|
||||||
|
lines.push(`TOTAL: ${fmt(sale.total).padStart(12)}`);
|
||||||
|
lines.push('========================================');
|
||||||
|
|
||||||
|
if (sale.payment_method === 'efectivo') {
|
||||||
|
lines.push(`Efectivo: ${fmt(sale.amount_paid).padStart(12)}`);
|
||||||
|
lines.push(`Cambio: ${fmt(sale.change_given).padStart(12)}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`Pago: ${sale.payment_method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push(' Gracias por su compra!');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
document.getElementById('ticketPreview').textContent = lines.join('\n');
|
||||||
|
document.getElementById('ticketModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTicketModal() {
|
||||||
|
document.getElementById('ticketModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTicket() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Last Sale ───────────────────────
|
||||||
|
async function showLastSale() {
|
||||||
|
if (!lastSaleId) { alert('No hay venta reciente'); return; }
|
||||||
|
try {
|
||||||
|
const sale = await api(`/pos/api/sales/${lastSaleId}`);
|
||||||
|
showTicket(sale);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Drawer ──────────────────────────
|
||||||
|
function openDrawer() {
|
||||||
|
// Cash drawer open command (ESC/POS compatible)
|
||||||
|
// In a real implementation, this would send the command to the printer
|
||||||
|
alert('Comando enviado al cajon de efectivo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keyboard Shortcuts ──────────────
|
||||||
|
function setupKeyboard() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Don't intercept when typing in inputs
|
||||||
|
const tag = e.target.tagName;
|
||||||
|
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'F1':
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('itemSearch').focus();
|
||||||
|
break;
|
||||||
|
case 'F2':
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('customerSearch').focus();
|
||||||
|
document.getElementById('customerSearch').style.display = '';
|
||||||
|
document.getElementById('customerSelected').style.display = 'none';
|
||||||
|
break;
|
||||||
|
case 'F3':
|
||||||
|
e.preventDefault();
|
||||||
|
checkout();
|
||||||
|
break;
|
||||||
|
case 'F4':
|
||||||
|
e.preventDefault();
|
||||||
|
saveQuotation();
|
||||||
|
break;
|
||||||
|
case 'F5':
|
||||||
|
e.preventDefault();
|
||||||
|
showLastSale();
|
||||||
|
break;
|
||||||
|
case 'F6':
|
||||||
|
e.preventDefault();
|
||||||
|
openDrawer();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
if (document.getElementById('paymentModal').classList.contains('active')) {
|
||||||
|
closePaymentModal();
|
||||||
|
} else if (document.getElementById('newCustomerModal').classList.contains('active')) {
|
||||||
|
closeNewCustomerModal();
|
||||||
|
} else if (document.getElementById('ticketModal').classList.contains('active')) {
|
||||||
|
closeTicketModal();
|
||||||
|
} else {
|
||||||
|
hideSearchResults();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Delete':
|
||||||
|
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
removeFromCart(selectedRow);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '+':
|
||||||
|
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
cart[selectedRow].quantity++;
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '-':
|
||||||
|
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (cart[selectedRow].quantity > 1) {
|
||||||
|
cart[selectedRow].quantity--;
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '*':
|
||||||
|
if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
const disc = prompt('Descuento %:', cart[selectedRow].discount_pct);
|
||||||
|
if (disc !== null) {
|
||||||
|
updateDiscount(selectedRow, disc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
if (!inInput && cart.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedRow = Math.max(0, selectedRow - 1);
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
if (!inInput && cart.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedRow = Math.min(cart.length - 1, selectedRow + 1);
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (e.target.id === 'cashReceived') {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmPayment();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────
|
||||||
|
init();
|
||||||
|
|
||||||
|
return {
|
||||||
|
addToCart, removeFromCart, selectRow,
|
||||||
|
updateQty, updateDiscount,
|
||||||
|
addFromSearch, hideSearchResults,
|
||||||
|
selectCustomer, clearCustomer,
|
||||||
|
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
||||||
|
checkout, confirmPayment, closePaymentModal,
|
||||||
|
selectPaymentMethod, updateChange, updateMixedTotal,
|
||||||
|
creditSale, saveQuotation, createLayaway,
|
||||||
|
showLastSale, openDrawer,
|
||||||
|
showTicket, closeTicketModal, printTicket,
|
||||||
|
};
|
||||||
|
})();
|
||||||
221
pos/templates/customers.html
Normal file
221
pos/templates/customers.html
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<!-- /home/Autopartes/pos/templates/customers.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Clientes - Nexus POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/common.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); }
|
||||||
|
|
||||||
|
.topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.topbar h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
.topbar .nav-links a { color: #b0bec5; text-decoration: none; margin-left: 16px; font-size: 14px; }
|
||||||
|
.topbar .nav-links a:hover { color: #fff; }
|
||||||
|
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
|
||||||
|
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
|
||||||
|
.toolbar input { flex: 1; padding: 10px 14px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
|
||||||
|
.toolbar .btn { padding: 10px 20px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 500; }
|
||||||
|
.btn-primary { background: var(--color-primary, #1a1a2e); color: #fff; }
|
||||||
|
.btn-secondary { background: #e0e0e0; color: #333; }
|
||||||
|
|
||||||
|
.customers-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius, 6px); overflow: hidden; box-shadow: var(--shadow, 0 1px 3px rgba(0,0,0,0.1)); }
|
||||||
|
.customers-table th { background: var(--color-surface, #f8f9fa); padding: 10px 12px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
|
||||||
|
.customers-table td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.customers-table tr { cursor: pointer; }
|
||||||
|
.customers-table tr:hover { background: #f8f9fa; }
|
||||||
|
|
||||||
|
.tier-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
|
||||||
|
.tier-1 { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.tier-2 { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.tier-3 { background: #fff3e0; color: #e65100; }
|
||||||
|
|
||||||
|
.credit-bar { width: 80px; height: 6px; background: #e0e0e0; border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 6px; }
|
||||||
|
.credit-fill { height: 100%; border-radius: 3px; background: #4caf50; }
|
||||||
|
.credit-fill.warning { background: #ff9800; }
|
||||||
|
.credit-fill.danger { background: #f44336; }
|
||||||
|
|
||||||
|
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
|
||||||
|
.pagination .btn { padding: 6px 12px; font-size: 13px; }
|
||||||
|
.pagination .btn.active { background: var(--color-primary, #1a1a2e); color: #fff; }
|
||||||
|
|
||||||
|
/* Detail panel */
|
||||||
|
.detail-panel { display: none; position: fixed; top: 0; right: 0; width: 480px; height: 100vh; background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.15); z-index: 100; overflow-y: auto; }
|
||||||
|
.detail-panel.active { display: block; }
|
||||||
|
.detail-header { padding: 16px 20px; background: var(--color-primary, #1a1a2e); color: #fff; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.detail-header h2 { font-size: 16px; }
|
||||||
|
.detail-header .btn-close { background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; }
|
||||||
|
.detail-body { padding: 20px; }
|
||||||
|
.detail-section { margin-bottom: 20px; }
|
||||||
|
.detail-section h3 { font-size: 14px; font-weight: 600; color: #666; margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 4px; }
|
||||||
|
.detail-field { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
|
||||||
|
.detail-field .label { color: #999; }
|
||||||
|
|
||||||
|
.credit-summary { background: #f5f5f5; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
|
||||||
|
.credit-summary .big-number { font-size: 24px; font-weight: 700; }
|
||||||
|
|
||||||
|
.purchases-list { max-height: 300px; overflow-y: auto; }
|
||||||
|
.purchase-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; display: flex; justify-content: space-between; }
|
||||||
|
|
||||||
|
.vehicle-card { background: #f5f5f5; padding: 10px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
.modal { background: #fff; border-radius: 12px; padding: 24px; width: 550px; max-width: 95vw; max-height: 90vh; overflow-y: auto; }
|
||||||
|
.modal h2 { margin-bottom: 16px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.form-grid .full-width { grid-column: 1 / -1; }
|
||||||
|
.form-field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.form-field label { font-size: 12px; color: #666; font-weight: 500; }
|
||||||
|
.form-field input, .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
|
||||||
|
.modal-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
|
||||||
|
.modal-actions .btn { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<h1>Clientes</h1>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/pos/sale">POS</a>
|
||||||
|
<a href="/pos/catalog">Catalogo</a>
|
||||||
|
<a href="/pos/inventory">Inventario</a>
|
||||||
|
<a href="/pos/customers">Clientes</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="text" id="searchInput" placeholder="Buscar por nombre, RFC, telefono..." oninput="Customers.search()">
|
||||||
|
<button class="btn btn-primary" onclick="Customers.showCreateModal()">+ Nuevo Cliente</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="customers-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>RFC</th>
|
||||||
|
<th>Telefono</th>
|
||||||
|
<th>Lista</th>
|
||||||
|
<th>Credito</th>
|
||||||
|
<th>Saldo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="customersBody"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pagination" id="pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail Panel (slides in from right) -->
|
||||||
|
<div class="detail-panel" id="detailPanel">
|
||||||
|
<div class="detail-header">
|
||||||
|
<h2 id="detailName">Cliente</h2>
|
||||||
|
<button class="btn-close" onclick="Customers.closeDetail()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="credit-summary">
|
||||||
|
<div>Credito disponible</div>
|
||||||
|
<div class="big-number" id="detailCreditAvailable">$0.00</div>
|
||||||
|
<div style="font-size: 12px; color: #666;">
|
||||||
|
Limite: <span id="detailCreditLimit">$0.00</span> |
|
||||||
|
Saldo: <span id="detailCreditBalance">$0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Datos Fiscales</h3>
|
||||||
|
<div id="detailFiscal"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Contacto</h3>
|
||||||
|
<div id="detailContact"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Vehiculos</h3>
|
||||||
|
<div id="detailVehicles"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Compras Recientes</h3>
|
||||||
|
<div class="purchases-list" id="detailPurchases"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||||||
|
<button class="btn btn-primary" onclick="Customers.editCurrent()">Editar</button>
|
||||||
|
<button class="btn btn-secondary" onclick="Customers.showStatement()">Estado de Cuenta</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Modal -->
|
||||||
|
<div class="modal-overlay" id="customerModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="modalTitle">Nuevo Cliente</h2>
|
||||||
|
<input type="hidden" id="editId">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field full-width"><label>Nombre *</label><input type="text" id="fName"></div>
|
||||||
|
<div class="form-field"><label>RFC</label><input type="text" id="fRfc" maxlength="13"></div>
|
||||||
|
<div class="form-field"><label>Razon Social</label><input type="text" id="fRazonSocial"></div>
|
||||||
|
<div class="form-field"><label>Regimen Fiscal</label>
|
||||||
|
<select id="fRegimenFiscal">
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<option value="601">601 - General de Ley PM</option>
|
||||||
|
<option value="603">603 - PM Fines No Lucrativos</option>
|
||||||
|
<option value="605">605 - Sueldos y Salarios</option>
|
||||||
|
<option value="606">606 - Arrendamiento</option>
|
||||||
|
<option value="612">612 - PF Actividad Empresarial</option>
|
||||||
|
<option value="616">616 - Sin Obligaciones Fiscales</option>
|
||||||
|
<option value="621">621 - Incorporacion Fiscal</option>
|
||||||
|
<option value="625">625 - RESICO</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Uso CFDI</label>
|
||||||
|
<select id="fUsoCfdi">
|
||||||
|
<option value="G03">G03 - Gastos en general</option>
|
||||||
|
<option value="G01">G01 - Adquisicion de mercancias</option>
|
||||||
|
<option value="P01">P01 - Por definir</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Codigo Postal</label><input type="text" id="fCp" maxlength="5"></div>
|
||||||
|
<div class="form-field"><label>Telefono</label><input type="tel" id="fPhone"></div>
|
||||||
|
<div class="form-field"><label>Email</label><input type="email" id="fEmail"></div>
|
||||||
|
<div class="form-field full-width"><label>Direccion</label><input type="text" id="fAddress"></div>
|
||||||
|
<div class="form-field"><label>Lista de precio</label>
|
||||||
|
<select id="fPriceTier">
|
||||||
|
<option value="1">1 - Mostrador</option>
|
||||||
|
<option value="2">2 - Taller</option>
|
||||||
|
<option value="3">3 - Mayoreo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Limite de credito</label><input type="number" id="fCreditLimit" value="0" min="0" step="100"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="Customers.closeModal()">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="Customers.save()">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statement Modal -->
|
||||||
|
<div class="modal-overlay" id="statementModal">
|
||||||
|
<div class="modal" style="width: 650px;">
|
||||||
|
<h2>Estado de Cuenta: <span id="statementName"></span></h2>
|
||||||
|
<div id="statementContent" style="max-height: 500px; overflow-y: auto;"></div>
|
||||||
|
<div class="modal-actions" style="margin-top: 16px;">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('statementModal').classList.remove('active')">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/customers.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
403
pos/templates/pos.html
Normal file
403
pos/templates/pos.html
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
<!-- /home/Autopartes/pos/templates/pos.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Punto de Venta - Nexus POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/common.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Top bar */
|
||||||
|
.pos-topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 8px 16px; display: flex; align-items: center; justify-content: space-between; font-size: 14px; }
|
||||||
|
.pos-topbar .employee-info { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pos-topbar .register-info { display: flex; align-items: center; gap: 8px; opacity: 0.8; }
|
||||||
|
.pos-topbar .register-info.no-register { color: #ff6b6b; opacity: 1; }
|
||||||
|
.pos-topbar .shortcuts-hint { font-size: 12px; opacity: 0.6; }
|
||||||
|
|
||||||
|
/* Main layout */
|
||||||
|
.pos-main { display: flex; flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Left panel: search + items */
|
||||||
|
.pos-left { width: 55%; display: flex; flex-direction: column; border-right: 2px solid var(--color-border, #ddd); background: #fff; }
|
||||||
|
|
||||||
|
/* Customer bar */
|
||||||
|
.customer-bar { padding: 8px 12px; background: var(--color-surface, #f8f9fa); border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; align-items: center; }
|
||||||
|
.customer-bar input { flex: 1; padding: 8px 12px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
|
||||||
|
.customer-bar .customer-selected { background: #e8f5e9; padding: 6px 12px; border-radius: var(--radius, 6px); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.customer-bar .customer-selected .tier-badge { font-size: 11px; padding: 2px 6px; border-radius: 3px; background: #1976d2; color: #fff; }
|
||||||
|
.customer-bar .customer-selected .credit-info { font-size: 11px; color: #666; }
|
||||||
|
.customer-bar .btn-new-customer { padding: 8px 12px; border: 1px dashed var(--color-border, #ddd); border-radius: var(--radius, 6px); background: none; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||||||
|
.customer-autocomplete { position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid var(--color-border, #ddd); border-radius: 0 0 var(--radius, 6px) var(--radius, 6px); box-shadow: var(--shadow, 0 4px 12px rgba(0,0,0,0.1)); z-index: 100; max-height: 200px; overflow-y: auto; }
|
||||||
|
.customer-autocomplete .ac-item { padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.customer-autocomplete .ac-item:hover { background: #f0f2f5; }
|
||||||
|
.customer-autocomplete .ac-item .ac-meta { font-size: 11px; color: #999; }
|
||||||
|
|
||||||
|
/* Vehicle info banner */
|
||||||
|
.vehicle-banner { display: none; padding: 6px 12px; background: #fff3e0; border-bottom: 1px solid #ffe0b2; font-size: 12px; }
|
||||||
|
.vehicle-banner.visible { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* Search bar */
|
||||||
|
.search-bar { padding: 8px 12px; border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; }
|
||||||
|
.search-bar input { flex: 1; padding: 10px 14px; border: 2px solid var(--color-primary, #1a1a2e); border-radius: var(--radius, 6px); font-size: 15px; outline: none; }
|
||||||
|
.search-bar input:focus { border-color: var(--color-accent, #4361ee); box-shadow: 0 0 0 3px rgba(67,97,238,0.15); }
|
||||||
|
|
||||||
|
/* Cart items */
|
||||||
|
.cart-items { flex: 1; overflow-y: auto; padding: 0; }
|
||||||
|
.cart-items table { width: 100%; border-collapse: collapse; }
|
||||||
|
.cart-items thead th { position: sticky; top: 0; background: var(--color-surface, #f8f9fa); padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
|
||||||
|
.cart-items tbody tr { border-bottom: 1px solid #f0f0f0; cursor: pointer; }
|
||||||
|
.cart-items tbody tr:hover { background: #f8f9fa; }
|
||||||
|
.cart-items tbody tr.selected { background: #e3f2fd; }
|
||||||
|
.cart-items td { padding: 8px 10px; font-size: 13px; }
|
||||||
|
.cart-items td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.cart-items td .part-name { font-weight: 500; }
|
||||||
|
.cart-items td .part-number { font-size: 11px; color: #999; }
|
||||||
|
.cart-items td .margin-info { font-size: 11px; color: #888; }
|
||||||
|
.cart-items td .margin-warning { color: #e53935; font-weight: 600; }
|
||||||
|
.cart-items .qty-input { width: 50px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
|
||||||
|
.cart-items .discount-input { width: 55px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
|
||||||
|
.cart-items .btn-remove { background: none; border: none; color: #e53935; cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||||
|
|
||||||
|
.cart-empty { display: flex; align-items: center; justify-content: center; flex: 1; color: #999; font-size: 15px; }
|
||||||
|
|
||||||
|
/* Right panel: totals + actions */
|
||||||
|
.pos-right { width: 45%; display: flex; flex-direction: column; background: var(--color-surface, #f8f9fa); }
|
||||||
|
|
||||||
|
/* Search results (right side when searching) */
|
||||||
|
.search-results { flex: 1; overflow-y: auto; padding: 8px; display: none; }
|
||||||
|
.search-results.active { display: block; }
|
||||||
|
.search-result-item { padding: 10px 12px; background: #fff; border: 1px solid #eee; border-radius: var(--radius, 6px); margin-bottom: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.search-result-item:hover { border-color: var(--color-primary, #1a1a2e); background: #fafafa; }
|
||||||
|
.search-result-item .sr-name { font-weight: 500; font-size: 14px; }
|
||||||
|
.search-result-item .sr-pn { font-size: 12px; color: #666; }
|
||||||
|
.search-result-item .sr-stock { font-size: 12px; }
|
||||||
|
.search-result-item .sr-stock.zero { color: #e53935; }
|
||||||
|
.search-result-item .sr-price { font-weight: 600; font-size: 15px; }
|
||||||
|
|
||||||
|
/* Totals panel */
|
||||||
|
.totals-panel { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; padding: 16px; }
|
||||||
|
.totals-panel.hidden { display: none; }
|
||||||
|
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
|
||||||
|
.totals-row.discount { color: #e53935; }
|
||||||
|
.totals-row.total { font-size: 28px; font-weight: 700; padding: 12px 0; border-top: 2px solid var(--color-border, #ddd); margin-top: 8px; }
|
||||||
|
|
||||||
|
/* Global discount */
|
||||||
|
.global-discount { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid #eee; margin-top: 8px; }
|
||||||
|
.global-discount label { font-size: 13px; color: #666; }
|
||||||
|
.global-discount input { width: 60px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.action-buttons { padding: 12px 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; border-top: 2px solid var(--color-border, #ddd); }
|
||||||
|
.action-buttons .btn { padding: 14px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px; transition: opacity 0.15s; }
|
||||||
|
.action-buttons .btn:hover { opacity: 0.85; }
|
||||||
|
.action-buttons .btn:active { transform: scale(0.98); }
|
||||||
|
.btn-cobrar { background: #2e7d32; color: #fff; grid-column: 1 / -1; font-size: 18px; padding: 18px; }
|
||||||
|
.btn-cotizacion { background: #1565c0; color: #fff; }
|
||||||
|
.btn-apartado { background: #e65100; color: #fff; }
|
||||||
|
.btn-credito { background: #6a1b9a; color: #fff; }
|
||||||
|
.btn-last-sale { background: #455a64; color: #fff; }
|
||||||
|
.btn-shortcut { font-size: 11px; opacity: 0.7; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* Payment modal */
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
.modal { background: #fff; border-radius: 12px; padding: 24px; width: 500px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
|
||||||
|
.modal h2 { margin-bottom: 16px; font-size: 20px; }
|
||||||
|
.modal .modal-total { font-size: 32px; font-weight: 700; text-align: center; padding: 12px; background: #f5f5f5; border-radius: 8px; margin-bottom: 16px; }
|
||||||
|
.modal .payment-methods { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
.modal .payment-methods .pm-btn { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; background: #fff; cursor: pointer; text-align: center; font-size: 13px; font-weight: 500; }
|
||||||
|
.modal .payment-methods .pm-btn.active { border-color: var(--color-primary, #1a1a2e); background: #f0f4ff; }
|
||||||
|
.modal .payment-field { margin-bottom: 12px; }
|
||||||
|
.modal .payment-field label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; }
|
||||||
|
.modal .payment-field input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
|
||||||
|
.modal .payment-field input.amount-input { font-size: 24px; text-align: right; font-weight: 600; }
|
||||||
|
.modal .change-display { text-align: center; padding: 12px; font-size: 20px; font-weight: 600; border-radius: 8px; margin: 12px 0; }
|
||||||
|
.modal .change-display.positive { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.modal .change-display.negative { background: #ffebee; color: #c62828; }
|
||||||
|
|
||||||
|
/* Mixed payment rows */
|
||||||
|
.mixed-payments { margin-bottom: 12px; }
|
||||||
|
.mixed-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||||
|
.mixed-row select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
|
||||||
|
.mixed-row input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
|
||||||
|
.mixed-row .btn-remove-row { background: none; border: none; color: #e53935; cursor: pointer; font-size: 18px; }
|
||||||
|
.btn-add-mixed { background: none; border: 1px dashed #999; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #666; }
|
||||||
|
|
||||||
|
.modal .modal-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
.modal .modal-actions .btn { flex: 1; padding: 14px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
|
||||||
|
.modal .btn-confirm-payment { background: #2e7d32; color: #fff; }
|
||||||
|
.modal .btn-cancel-modal { background: #eee; color: #333; }
|
||||||
|
|
||||||
|
/* New customer modal */
|
||||||
|
.modal .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.modal .form-grid .full-width { grid-column: 1 / -1; }
|
||||||
|
.modal .form-field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.modal .form-field label { font-size: 12px; color: #666; font-weight: 500; }
|
||||||
|
.modal .form-field input, .modal .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Ticket preview */
|
||||||
|
.ticket-preview { font-family: var(--font-mono, 'Courier New', monospace); font-size: 12px; white-space: pre-wrap; background: #fff; padding: 16px; border: 1px dashed #999; max-width: 300px; margin: 0 auto; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* Keyboard hint bar */
|
||||||
|
.keyboard-bar { background: #263238; color: #b0bec5; padding: 6px 16px; display: flex; gap: 16px; font-size: 11px; }
|
||||||
|
.keyboard-bar .kb-key { background: #37474f; padding: 2px 6px; border-radius: 3px; color: #e0e0e0; font-weight: 600; margin-right: 4px; }
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body * { visibility: hidden; }
|
||||||
|
.ticket-preview, .ticket-preview * { visibility: visible; }
|
||||||
|
.ticket-preview { position: absolute; left: 0; top: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<div class="pos-topbar">
|
||||||
|
<div class="employee-info">
|
||||||
|
<span id="employeeName">Cargando...</span>
|
||||||
|
<span id="branchName" style="opacity: 0.7; font-size: 12px;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="register-info" id="registerInfo">
|
||||||
|
<span>Caja: --</span>
|
||||||
|
</div>
|
||||||
|
<div class="shortcuts-hint">F1=Buscar F2=Cliente F3=Cobrar F4=Cotizacion F5=Ult.Venta</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Layout -->
|
||||||
|
<div class="pos-main">
|
||||||
|
<!-- Left: Search + Cart -->
|
||||||
|
<div class="pos-left">
|
||||||
|
<!-- Customer Bar -->
|
||||||
|
<div class="customer-bar" style="position: relative;">
|
||||||
|
<span style="font-size: 13px; color: #666;">Cliente:</span>
|
||||||
|
<div id="customerSearchWrap" style="flex: 1; position: relative;">
|
||||||
|
<input type="text" id="customerSearch" placeholder="Buscar cliente por nombre, RFC, telefono... (F2)" autocomplete="off">
|
||||||
|
<div class="customer-autocomplete" id="customerAutocomplete" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="customerSelected" class="customer-selected" style="display:none;">
|
||||||
|
<span id="customerName"></span>
|
||||||
|
<span class="tier-badge" id="customerTier"></span>
|
||||||
|
<span class="credit-info" id="customerCredit"></span>
|
||||||
|
<button onclick="POS.clearCustomer()" style="background:none;border:none;cursor:pointer;color:#999;font-size:16px;" title="Quitar cliente">×</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-new-customer" onclick="POS.showNewCustomerModal()">+ Nuevo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehicle Banner -->
|
||||||
|
<div class="vehicle-banner" id="vehicleBanner">
|
||||||
|
<span style="font-weight: 600;">Vehiculo:</span>
|
||||||
|
<span id="vehicleInfo"></span>
|
||||||
|
<span id="lastPurchaseInfo" style="margin-left: auto; font-size: 11px; color: #e65100;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="itemSearch" placeholder="Buscar por # parte, nombre o codigo de barras... (F1)" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cart Table -->
|
||||||
|
<div class="cart-items" id="cartContainer">
|
||||||
|
<div class="cart-empty" id="cartEmpty">
|
||||||
|
<div>Carrito vacio<br><span style="font-size: 13px;">Busca productos o presiona F1</span></div>
|
||||||
|
</div>
|
||||||
|
<table id="cartTable" style="display:none;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30px;">#</th>
|
||||||
|
<th>Producto</th>
|
||||||
|
<th style="width: 60px;">Cant</th>
|
||||||
|
<th style="width: 90px;">Precio</th>
|
||||||
|
<th style="width: 65px;">Desc%</th>
|
||||||
|
<th style="width: 90px;" class="num">Subtotal</th>
|
||||||
|
<th id="thCost" style="width: 70px; display:none;" class="num">Costo</th>
|
||||||
|
<th id="thMargin" style="width: 65px; display:none;" class="num">Margen</th>
|
||||||
|
<th style="width: 30px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cartBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Search Results / Totals + Actions -->
|
||||||
|
<div class="pos-right">
|
||||||
|
<!-- Search results (shown when searching) -->
|
||||||
|
<div class="search-results" id="searchResults"></div>
|
||||||
|
|
||||||
|
<!-- Totals panel -->
|
||||||
|
<div class="totals-panel" id="totalsPanel">
|
||||||
|
<div>
|
||||||
|
<div class="totals-row"><span>Subtotal:</span><span id="dispSubtotal">$0.00</span></div>
|
||||||
|
<div class="totals-row discount" id="discountRow" style="display:none;">
|
||||||
|
<span>Descuento:</span><span id="dispDiscount">-$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="totals-row"><span>IVA (16%):</span><span id="dispTax">$0.00</span></div>
|
||||||
|
<div class="totals-row total"><span>TOTAL:</span><span id="dispTotal">$0.00</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="global-discount">
|
||||||
|
<label>Descuento global:</label>
|
||||||
|
<input type="number" id="globalDiscount" value="0" min="0" max="100" step="0.5">
|
||||||
|
<span>%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn btn-cobrar" onclick="POS.checkout()">
|
||||||
|
Cobrar<span class="btn-shortcut">F3</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-cotizacion" onclick="POS.saveQuotation()">
|
||||||
|
Cotizacion<span class="btn-shortcut">F4</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-apartado" onclick="POS.createLayaway()">
|
||||||
|
Apartado
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-credito" onclick="POS.creditSale()">
|
||||||
|
Credito
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-last-sale" onclick="POS.showLastSale()">
|
||||||
|
Ult. Venta<span class="btn-shortcut">F5</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" style="background:#78909c;color:#fff;" onclick="POS.openDrawer()">
|
||||||
|
Cajon<span class="btn-shortcut">F6</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keyboard hints -->
|
||||||
|
<div class="keyboard-bar">
|
||||||
|
<span><span class="kb-key">F1</span>Buscar</span>
|
||||||
|
<span><span class="kb-key">F2</span>Cliente</span>
|
||||||
|
<span><span class="kb-key">F3</span>Cobrar</span>
|
||||||
|
<span><span class="kb-key">F4</span>Cotizacion</span>
|
||||||
|
<span><span class="kb-key">F5</span>Ult.Venta</span>
|
||||||
|
<span><span class="kb-key">F6</span>Cajon</span>
|
||||||
|
<span><span class="kb-key">+/-</span>Cantidad</span>
|
||||||
|
<span><span class="kb-key">*</span>Descuento</span>
|
||||||
|
<span><span class="kb-key">Enter</span>Agregar</span>
|
||||||
|
<span><span class="kb-key">Del</span>Eliminar</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Modal -->
|
||||||
|
<div class="modal-overlay" id="paymentModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Cobrar Venta</h2>
|
||||||
|
<div class="modal-total" id="modalTotal">$0.00</div>
|
||||||
|
|
||||||
|
<div class="payment-methods">
|
||||||
|
<button class="pm-btn active" data-method="efectivo" onclick="POS.selectPaymentMethod('efectivo', this)">Efectivo</button>
|
||||||
|
<button class="pm-btn" data-method="transferencia" onclick="POS.selectPaymentMethod('transferencia', this)">Transferencia</button>
|
||||||
|
<button class="pm-btn" data-method="tarjeta" onclick="POS.selectPaymentMethod('tarjeta', this)">Tarjeta</button>
|
||||||
|
<button class="pm-btn" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">Mixto</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cash payment -->
|
||||||
|
<div id="cashPayment">
|
||||||
|
<div class="payment-field">
|
||||||
|
<label>Monto recibido:</label>
|
||||||
|
<input type="number" id="cashReceived" class="amount-input" step="0.01" min="0" oninput="POS.updateChange()">
|
||||||
|
</div>
|
||||||
|
<div class="change-display positive" id="changeDisplay">Cambio: $0.00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transfer/Card payment -->
|
||||||
|
<div id="refPayment" style="display:none;">
|
||||||
|
<div class="payment-field">
|
||||||
|
<label>Referencia:</label>
|
||||||
|
<input type="text" id="paymentRef" placeholder="Numero de referencia o autorizacion">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mixed payment -->
|
||||||
|
<div id="mixedPayment" style="display:none;">
|
||||||
|
<div class="mixed-payments" id="mixedRows">
|
||||||
|
<div class="mixed-row">
|
||||||
|
<select><option value="efectivo">Efectivo</option><option value="transferencia">Transferencia</option><option value="tarjeta">Tarjeta</option></select>
|
||||||
|
<input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
|
||||||
|
<input type="text" placeholder="Ref." style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
<div class="mixed-row">
|
||||||
|
<select><option value="transferencia">Transferencia</option><option value="efectivo">Efectivo</option><option value="tarjeta">Tarjeta</option></select>
|
||||||
|
<input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
|
||||||
|
<input type="text" placeholder="Ref." style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mixedRemaining" style="text-align:center; font-size: 14px; color: #666; padding: 8px 0;">Faltante: $0.00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-cancel-modal" onclick="POS.closePaymentModal()">Cancelar (Esc)</button>
|
||||||
|
<button class="btn btn-confirm-payment" id="btnConfirmPayment" onclick="POS.confirmPayment()">Confirmar Pago</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Customer Modal -->
|
||||||
|
<div class="modal-overlay" id="newCustomerModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Nuevo Cliente</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field full-width"><label>Nombre *</label><input type="text" id="ncName"></div>
|
||||||
|
<div class="form-field"><label>RFC</label><input type="text" id="ncRfc" maxlength="13" placeholder="XAXX010101000"></div>
|
||||||
|
<div class="form-field"><label>Razon Social</label><input type="text" id="ncRazonSocial"></div>
|
||||||
|
<div class="form-field"><label>Regimen Fiscal</label>
|
||||||
|
<select id="ncRegimenFiscal">
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<option value="601">601 - General de Ley PM</option>
|
||||||
|
<option value="603">603 - Personas Morales Fines No Lucrativos</option>
|
||||||
|
<option value="605">605 - Sueldos y Salarios</option>
|
||||||
|
<option value="606">606 - Arrendamiento</option>
|
||||||
|
<option value="612">612 - Personas Fisicas Actividad Empresarial</option>
|
||||||
|
<option value="616">616 - Sin Obligaciones Fiscales</option>
|
||||||
|
<option value="621">621 - Incorporacion Fiscal</option>
|
||||||
|
<option value="625">625 - RESICO</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Uso CFDI</label>
|
||||||
|
<select id="ncUsoCfdi">
|
||||||
|
<option value="G03">G03 - Gastos en general</option>
|
||||||
|
<option value="G01">G01 - Adquisicion de mercancias</option>
|
||||||
|
<option value="P01">P01 - Por definir</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Telefono</label><input type="tel" id="ncPhone"></div>
|
||||||
|
<div class="form-field"><label>Email</label><input type="email" id="ncEmail"></div>
|
||||||
|
<div class="form-field"><label>Lista de precio</label>
|
||||||
|
<select id="ncPriceTier">
|
||||||
|
<option value="1">Mostrador (Precio 1)</option>
|
||||||
|
<option value="2">Taller (Precio 2)</option>
|
||||||
|
<option value="3">Mayoreo (Precio 3)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Limite de credito</label><input type="number" id="ncCreditLimit" value="0" min="0" step="100"></div>
|
||||||
|
<div class="form-field full-width"><label>Vehiculo (opcional)</label></div>
|
||||||
|
<div class="form-field"><label>Marca</label><input type="text" id="ncVehMake" placeholder="Nissan, Toyota..."></div>
|
||||||
|
<div class="form-field"><label>Modelo</label><input type="text" id="ncVehModel" placeholder="Tsuru, Corolla..."></div>
|
||||||
|
<div class="form-field"><label>Ano</label><input type="number" id="ncVehYear" placeholder="2020"></div>
|
||||||
|
<div class="form-field"><label>Placas</label><input type="text" id="ncVehPlates"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions" style="margin-top: 16px;">
|
||||||
|
<button class="btn btn-cancel-modal" onclick="POS.closeNewCustomerModal()">Cancelar</button>
|
||||||
|
<button class="btn btn-confirm-payment" onclick="POS.saveNewCustomer()">Guardar Cliente</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Modal -->
|
||||||
|
<div class="modal-overlay" id="ticketModal">
|
||||||
|
<div class="modal" style="width: 380px;">
|
||||||
|
<h2>Venta Completada</h2>
|
||||||
|
<div class="ticket-preview" id="ticketPreview"></div>
|
||||||
|
<div class="modal-actions" style="margin-top: 16px;">
|
||||||
|
<button class="btn btn-cancel-modal" onclick="POS.closeTicketModal()">Cerrar</button>
|
||||||
|
<button class="btn btn-confirm-payment" onclick="POS.printTicket()">Imprimir</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/pos.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user