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:
@@ -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 = """
|
||||
|
||||
Reference in New Issue
Block a user