feat: cashier/counter reports, service-order & remission flows, Rached migration utils
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-07-02 12:51:56 +00:00
parent 483498cfcc
commit f42910f4f6
71 changed files with 5388 additions and 626 deletions

View File

@@ -13,7 +13,8 @@ from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.pos_engine import (
process_sale, cancel_sale, calculate_totals,
get_price_for_customer, get_margin_info
get_price_for_customer, get_margin_info,
create_remission_note, pay_pending_sale
)
from services.inventory_engine import get_stock
from services.audit import log_action
@@ -22,6 +23,15 @@ from config import JWT_SECRET
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
def _tenant_allows_negative_stock(conn):
"""Return True if the tenant explicitly allows selling below zero stock."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'")
row = cur.fetchone()
cur.close()
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
def _enrich_items(cur, items, customer_id=None):
"""Look up inventory data for items that lack unit_price/tax_rate.
@@ -104,17 +114,19 @@ def create_sale():
conn = get_tenant_conn(g.tenant_id)
# Verify stock availability per item for the active branch
# (skipped for tenants configured to allow negative stock / oversell)
branch_id = data.get('branch_id', g.branch_id)
for item in data.get('items', []):
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if inv_id:
available = get_stock(conn, inv_id, branch_id)
if available < qty:
conn.close()
return jsonify({
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
}), 400
if not _tenant_allows_negative_stock(conn):
for item in data.get('items', []):
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if inv_id:
available = get_stock(conn, inv_id, branch_id)
if available < qty:
conn.close()
return jsonify({
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
}), 400
try:
sale = process_sale(conn, data)
@@ -131,8 +143,89 @@ def create_sale():
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales/remission', methods=['POST'])
@require_auth('pos.remission')
def create_remission():
"""Create a counter remission note (pending payment, reserved stock)."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
branch_id = data.get('branch_id', g.branch_id)
if not _tenant_allows_negative_stock(conn):
for item in data.get('items', []):
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if inv_id:
available = get_stock(conn, inv_id, branch_id)
if available < qty:
conn.close()
return jsonify({
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
}), 400
try:
sale = create_remission_note(conn, {
'tenant_id': g.tenant_id,
'branch_id': branch_id,
'customer_id': data.get('customer_id'),
'items': data.get('items', []),
'notes': data.get('notes'),
'register_id': data.get('register_id'),
'currency': data.get('currency', 'MXN'),
'exchange_rate': data.get('exchange_rate'),
'courier_id': data.get('courier_id'),
})
conn.commit()
conn.close()
return jsonify(sale), 201
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales/<int:sale_id>/pay', methods=['POST'])
@require_auth('pos.sell')
def pay_sale(sale_id):
"""Pay a pending counter remission note.
Body: {
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
amount_paid: float,
payment_details: [{method, amount, reference}],
register_id: int,
reference: str
}
"""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
sale = pay_pending_sale(conn, sale_id, {
'payment_method': data.get('payment_method', 'efectivo'),
'amount_paid': data.get('amount_paid', 0),
'payment_details': data.get('payment_details', []),
'register_id': data.get('register_id'),
'reference': data.get('reference', ''),
})
conn.commit()
conn.close()
return jsonify(sale), 200
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales', methods=['GET'])
@require_auth('pos.view')
@require_auth()
def list_sales():
"""List sales with filters.
@@ -146,6 +239,13 @@ def list_sales():
page: int (default 1)
per_page: int (default 50, max 200)
"""
sale_type = request.args.get('sale_type')
is_remission = sale_type == 'counter_remission'
can_view_all = g.employee_role == 'owner' or has_permission('pos.view')
can_view_remissions = is_remission and (has_permission('pos.sell') or has_permission('pos.remission'))
if not (can_view_all or can_view_remissions):
return jsonify({'error': 'Missing permissions: pos.view'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -160,6 +260,10 @@ def list_sales():
employee_id = request.args.get('employee_id')
customer_id = request.args.get('customer_id')
status = request.args.get('status')
sale_type = request.args.get('sale_type')
q = request.args.get('q')
customer_q = request.args.get('customer')
courier_id = request.args.get('courier_id')
register_id = request.args.get('register_id')
if date_from:
@@ -177,6 +281,18 @@ def list_sales():
if status:
where_clauses.append("s.status = %s")
params.append(status)
if sale_type:
where_clauses.append("s.sale_type = %s")
params.append(sale_type)
if q:
where_clauses.append("(s.id::text ILIKE %s OR c.name ILIKE %s)")
params.extend([f'%{q}%', f'%{q}%'])
if customer_q:
where_clauses.append("c.name ILIKE %s")
params.append(f'%{customer_q}%')
if courier_id:
where_clauses.append("s.courier_id = %s")
params.append(int(courier_id))
if register_id:
where_clauses.append("s.register_id = %s")
params.append(int(register_id))
@@ -195,12 +311,14 @@ def list_sales():
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_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,
s.status, s.created_at, s.courier_id,
e.name as employee_name,
c.name as customer_name
c.name as customer_name,
co.name as courier_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN couriers co ON s.courier_id = co.id
WHERE {where}
ORDER BY s.created_at DESC
LIMIT %s OFFSET %s
@@ -219,7 +337,9 @@ def list_sales():
'amount_paid': float(r[11]) if r[11] else 0,
'change_given': float(r[12]) if r[12] else 0,
'status': r[13], 'created_at': str(r[14]),
'employee_name': r[15], 'customer_name': r[16],
'courier_id': r[15],
'employee_name': r[16], 'customer_name': r[17],
'courier_name': r[18],
})
cur.close()
@@ -1475,6 +1595,7 @@ def convert_quotation(quot_id):
'register_id': data.get('register_id'),
'amount_paid': data.get('amount_paid', 0),
'payment_details': data.get('payment_details', []),
'reference': data.get('reference', ''),
'notes': f'Convertida de cotizacion #{quot_id}',
'currency': quot_currency,
'exchange_rate': quot_rate,
@@ -2545,6 +2666,79 @@ def print_ticket(sale_id):
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
@pos_bp.route('/sales/<int:sale_id>/print-remission', methods=['POST'])
@require_auth()
def print_remission(sale_id):
"""Generate printable data for a counter remission note."""
from middleware import has_permission
if not (has_permission('pos.remission') or has_permission('pos.sell')):
return jsonify({'error': 'Missing permissions'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.*, e.name as employee_name, c.name as customer_name, co.name as courier_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN couriers co ON s.courier_id = co.id
WHERE s.id = %s
""", (sale_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404
cols = [desc[0] for desc in cur.description]
sale = dict(zip(cols, row))
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
if sale.get(k) is not None:
sale[k] = float(sale[k])
cur.execute("""
SELECT name, quantity, unit_price, subtotal
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
items = []
for r in cur.fetchall():
items.append({
'name': r[0], 'quantity': r[1],
'unit_price': float(r[2]) if r[2] else 0,
'subtotal': float(r[3]) if r[3] else 0,
})
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try:
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
for rw in cur.fetchall():
if rw[0] == 'business_name':
business_info['name'] = rw[1]
else:
business_info[rw[0]] = rw[1]
except Exception:
pass
cur.close(); conn.close()
return jsonify({
'folio': f'NR-{sale["id"]}',
'date': str(sale.get('created_at', '')),
'employee': sale.get('employee_name', ''),
'customer': sale.get('customer_name', ''),
'courier': sale.get('courier_name', ''),
'items': items,
'subtotal': sale.get('subtotal', 0),
'discount_total': sale.get('discount_total', 0),
'tax_total': sale.get('tax_total', 0),
'total': sale.get('total', 0),
'status': sale.get('status', ''),
'business_name': business_info.get('name', ''),
'business_rfc': business_info.get('rfc', ''),
'business_address': business_info.get('address', ''),
})
# ─── Public Quote HTML Template ─────────────────────────────────────────────
PUBLIC_QUOTE_TEMPLATE = """