Files
Autoparts-DB/pos/blueprints/cashregister_bp.py
consultoria-as f42910f4f6
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
feat: cashier/counter reports, service-order & remission flows, Rached migration utils
- Add "Mis cortes de caja" report for cashiers/counters with sales detail.
- Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint.
- Remove dashboard from cashier menu; add Reports to cashier/counter.
- Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission.
- Remission notes module (UI, CSS, courier, counter remissions).
- Customer hard-delete and vehicle/customer linkage in workshop.
- POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio.
- Inventory: CSV template with sku_secondary, alias import.
- Rached migration scripts and DB migrations.
- Version-bump cached JS/CSS query strings.

Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
2026-07-02 12:51:56 +00:00

690 lines
23 KiB
Python

# /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, has_permission
from tenant_db import get_tenant_conn
from services.audit import log_action
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
# Roles expected to operate a cash register even without the explicit pos.sell permission.
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
def _can_operate_register():
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
@cashregister_bp.route('/open', methods=['POST'])
@require_auth()
def open_register():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""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()
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()
def cash_movement():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""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()
def cut_x():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""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()
def cut_z():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""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()
# Push notification to owner if cash difference > $500
if abs(difference) > 500:
try:
from services.push_service import notify_owner
emp_name = getattr(g, 'employee_name', 'Empleado')
notify_owner(
conn,
'Diferencia en Caja',
f'Corte Z caja #{register_id}: diferencia de ${difference:,.2f} ({emp_name})',
'/pos'
)
except Exception:
pass # Push failures never block business logic
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()
def register_history():
"""List closed registers with summary.
Query params: date_from, date_to, employee_id, page, per_page
Permission rules:
- owner/admin and users with pos.view can query any employee.
- Cashiers/counters without pos.view can only query their own registers.
"""
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
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 not can_view_all:
employee_id = g.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,
})
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
@require_auth()
def register_sales(register_id):
"""List the sales associated with a specific cash register (cash cut).
Returns sales details and a summary by payment method. Accessible to
owners/admins, users with pos.view, or the employee who operated the register.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
(register_id,)
)
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Register not found'}), 404
register_employee_id, register_branch_id = row
can_view = (
g.employee_role in ('owner', 'admin') or
has_permission('pos.view') or
register_employee_id == g.employee_id
)
if not can_view:
cur.close(); conn.close()
return jsonify({'error': 'Missing permissions: pos.view'}), 403
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
cur.close(); conn.close()
return jsonify({'error': 'Register belongs to another branch'}), 403
where = "s.register_id = %s"
params = [register_id]
if g.branch_id:
where += " AND s.branch_id = %s"
params.append(g.branch_id)
cur.execute(f"""
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
s.created_at, c.name as customer_name, e.name as employee_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN employees e ON s.employee_id = e.id
WHERE {where}
ORDER BY s.created_at DESC
""", params)
sales = []
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
for r in cur.fetchall():
sale = {
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
'subtotal': float(r[3]) if r[3] else 0,
'discount_total': float(r[4]) if r[4] else 0,
'tax_total': float(r[5]) if r[5] else 0,
'total': float(r[6]) if r[6] else 0,
'amount_paid': float(r[7]) if r[7] else 0,
'change_given': float(r[8]) if r[8] else 0,
'status': r[9], 'created_at': str(r[10]),
'customer_name': r[11], 'employee_name': r[12]
}
sales.append(sale)
if sale['status'] == 'completed':
summary['total'] += sale['total']
summary['count'] += 1
m = sale['payment_method'] or 'Otro'
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
cur.close(); conn.close()
return jsonify({
'register_id': register_id,
'sales': sales,
'summary': {
'total': round(summary['total'], 2),
'count': summary['count'],
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
}
})