Compare commits

...

9 Commits

Author SHA1 Message Date
09980c1cdb fix(pos): enrich quotation/layaway items from inventory and allow final payment on layaway complete
Quotation and layaway endpoints were calling calculate_totals() on raw
input items without looking up unit_price/tax_rate from inventory, causing
KeyError. Added _enrich_items() helper (with customer price tier support).
Also removed non-existent discount_total column from quotations INSERT,
and made layaway complete accept a final payment for the remaining balance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:51:21 +00:00
b2484af0fb feat(pos): add POS sale page — F-keys, payment modal, margin display, ticket print
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:38:31 +00:00
76f738652b feat(pos): add customers page — search, credit, vehicles, statements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:36:42 +00:00
c66fb13c15 feat(pos): add POS blueprint — sales, quotations, layaways with stock reservation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:36:04 +00:00
d0343f8087 feat(pos): add POS engine — sale processing with Decimal totals and margin info
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:34:44 +00:00
53e3548249 feat(pos): add cash register blueprint — open/close, X/Z cuts, daily summary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:34:35 +00:00
5550fe7bb0 feat(pos): add customers blueprint — CRUD, credit, vehicles, statements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:34:32 +00:00
2a1aee4ee4 feat(pos): add v1.1 migration — sale_payments, layaway_items, customer_payments 2026-03-31 03:33:58 +00:00
fc5a56ba62 feat(pos): register POS, customers, cash register blueprints and page routes 2026-03-31 03:33:39 +00:00
11 changed files with 4686 additions and 1 deletions

View File

@@ -16,6 +16,15 @@ def create_app():
from blueprints.catalog_bp import 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
@app.route('/pos/health')
def health():
@@ -35,6 +44,14 @@ def create_app():
def pos_inventory():
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>')
def pos_static(filename):
return send_from_directory('static', filename)

View 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,
})

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
# Migration registry: version -> filename
MIGRATIONS = {
'v1.0': 'v1.0_initial.sql',
# Future: 'v1.1': 'v1.1_add_xyz.sql',
'v1.1': 'v1.1_pos_tables.sql',
}

View 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
View 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
View 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
View 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})">&times;</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, "&#39;")}, ${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, "&#39;")})'>
<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,
};
})();

View 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()">&times;</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
View 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">&times;</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>