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).
This commit is contained in:
@@ -3,16 +3,26 @@
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
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('pos.sell')
|
||||
@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}
|
||||
@@ -84,7 +94,7 @@ def open_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/current', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def current_register():
|
||||
"""Get the current open register for this employee."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -115,8 +125,10 @@ def current_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/movement', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@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}
|
||||
@@ -273,8 +285,10 @@ def _compute_register_summary(conn, register_id):
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@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()
|
||||
@@ -301,8 +315,10 @@ def cut_x():
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@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)
|
||||
@@ -379,12 +395,18 @@ def cut_z():
|
||||
|
||||
|
||||
@cashregister_bp.route('/history', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@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()
|
||||
|
||||
@@ -402,6 +424,9 @@ def register_history():
|
||||
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)
|
||||
@@ -576,3 +601,89 @@ def daily_summary():
|
||||
'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()}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user