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:
@@ -4,6 +4,7 @@ Prefix: /pos/api/service-orders
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
@@ -11,6 +12,7 @@ from services.service_order_engine import (
|
||||
add_item,
|
||||
add_labor,
|
||||
assign_mechanic,
|
||||
convert_to_remission,
|
||||
convert_to_sale,
|
||||
create_service_catalog_item,
|
||||
create_service_order,
|
||||
@@ -35,8 +37,91 @@ from tenant_db import get_tenant_conn
|
||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||
|
||||
|
||||
# Roles allowed to view or edit service orders.
|
||||
# Counter (mostrador) creates/edits; workshop/mechanic only view restricted info.
|
||||
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
||||
_WORKSHOP_EDIT_ROLES = {'owner', 'admin', 'counter', 'cashier'}
|
||||
|
||||
# Statuses that mechanics are not allowed to see (single shared mechanic account).
|
||||
_MECHANIC_HIDDEN_STATUSES = {
|
||||
'cotizada', 'por_autorizar', 'autorizada', 'autorizacion_parcial',
|
||||
'por_facturar', 'facturada'
|
||||
}
|
||||
|
||||
|
||||
def _can_view_workshop():
|
||||
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
|
||||
|
||||
|
||||
def _can_edit_workshop():
|
||||
return g.employee_role in _WORKSHOP_EDIT_ROLES
|
||||
|
||||
|
||||
def _is_restricted_workshop_viewer():
|
||||
return g.employee_role in ('workshop', 'mechanic')
|
||||
|
||||
|
||||
def _redact_order_for_mechanic(order):
|
||||
"""Remove customer/commercial data from the order payload for mechanics."""
|
||||
if not _is_restricted_workshop_viewer():
|
||||
return order
|
||||
sensitive = {
|
||||
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
||||
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
|
||||
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
|
||||
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
|
||||
'final_cost', 'total_parts', 'total_labor', 'total',
|
||||
}
|
||||
|
||||
def redact_value(v):
|
||||
if isinstance(v, str):
|
||||
return '—'
|
||||
if isinstance(v, (int, float)):
|
||||
return 0
|
||||
return None
|
||||
|
||||
redacted = {}
|
||||
for k, v in order.items():
|
||||
if k in sensitive:
|
||||
redacted[k] = redact_value(v)
|
||||
elif k == 'items' and isinstance(v, list):
|
||||
redacted[k] = [_redact_item_for_mechanic(it) for it in v]
|
||||
else:
|
||||
redacted[k] = v
|
||||
return redacted
|
||||
|
||||
|
||||
def _redact_item_for_mechanic(item):
|
||||
"""Hide mechanic name from item rows for mechanics."""
|
||||
item = dict(item)
|
||||
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
||||
if k in item:
|
||||
item[k] = None if not isinstance(item[k], (int, float)) else 0
|
||||
return item
|
||||
|
||||
|
||||
def require_workshop_view(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_view_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def require_workshop_edit(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_edit_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def list_orders():
|
||||
status = request.args.get('status')
|
||||
priority = request.args.get('priority')
|
||||
@@ -56,6 +141,14 @@ def list_orders():
|
||||
employee_id=employee_id, delivery_method=delivery_method,
|
||||
is_direct=is_direct, q=q, page=page, per_page=per_page
|
||||
)
|
||||
if _is_restricted_workshop_viewer():
|
||||
result['data'] = [_redact_order_for_mechanic(o) for o in result.get('data', [])]
|
||||
# Shared mechanic account can see all orders except commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic':
|
||||
result['data'] = [
|
||||
o for o in result.get('data', [])
|
||||
if o.get('status') not in _MECHANIC_HIDDEN_STATUSES
|
||||
]
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -88,6 +181,11 @@ def create_order_from_pos():
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
so_id = result['service_order_id']
|
||||
for item in items:
|
||||
@@ -99,7 +197,7 @@ def create_order_from_pos():
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -120,6 +218,11 @@ def create_order():
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
@@ -127,22 +230,33 @@ def create_order():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def get_order(so_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
return jsonify(order)
|
||||
# Shared mechanic account cannot view commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES:
|
||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
||||
return jsonify(_redact_order_for_mechanic(order))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_view
|
||||
def update_order(so_id):
|
||||
data = request.get_json() or {}
|
||||
# Full edit remains for owner/admin/counter/cashier.
|
||||
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
|
||||
if g.employee_role == 'mechanic':
|
||||
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
|
||||
if not data or any(k not in allowed for k in data.keys()):
|
||||
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
|
||||
elif not _can_edit_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_service_order(conn, so_id, data)
|
||||
@@ -154,7 +268,7 @@ def update_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order(so_id):
|
||||
"""Soft-delete a service order. Restricted to owner/admin."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
@@ -171,12 +285,17 @@ def delete_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_view
|
||||
def change_status(so_id):
|
||||
if not (_can_edit_workshop() or g.employee_role == 'mechanic'):
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
if not new_status:
|
||||
return jsonify({'error': 'status is required'}), 400
|
||||
# Mechanics cannot move orders into commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic' and new_status in _MECHANIC_HIDDEN_STATUSES:
|
||||
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = update_status(
|
||||
@@ -194,7 +313,7 @@ def change_status(so_id):
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -206,7 +325,7 @@ def add_order_item(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -220,7 +339,7 @@ def update_order_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -233,7 +352,7 @@ def delete_order_item(item_id):
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
@@ -247,7 +366,7 @@ def add_order_labor(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -261,7 +380,7 @@ def update_order_labor(labor_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -274,7 +393,7 @@ def delete_order_labor(labor_id):
|
||||
# ─── Kanban Summary ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def kanban_summary():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -288,7 +407,7 @@ def kanban_summary():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -302,7 +421,7 @@ def reserve_order_item(so_id, item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -354,11 +473,42 @@ def convert_order_to_sale(so_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Convert to remission note ────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
||||
@require_auth('pos.remission')
|
||||
def convert_order_to_remission(so_id):
|
||||
"""Convert a service order into a counter remission note (pending payment).
|
||||
|
||||
Body: {
|
||||
register_id: int (optional),
|
||||
notes: str (optional)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_payload = {
|
||||
'register_id': data.get('register_id'),
|
||||
'notes': data.get('notes'),
|
||||
}
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = convert_to_remission(
|
||||
conn, so_id, sale_payload, employee_id=g.employee_id
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Mechanic assignment ──────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
@@ -377,7 +527,7 @@ def assign_mechanic_endpoint(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/customers', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_customer_for_workshop():
|
||||
"""Create a customer directly from the workshop flow."""
|
||||
data = request.get_json() or {}
|
||||
@@ -411,7 +561,7 @@ def create_customer_for_workshop():
|
||||
|
||||
|
||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_vehicle_for_workshop():
|
||||
"""Create a fleet vehicle directly from the workshop flow.
|
||||
|
||||
@@ -456,7 +606,7 @@ def create_vehicle_for_workshop():
|
||||
|
||||
|
||||
@service_order_bp.route('/inventory-search', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def inventory_search():
|
||||
"""Search active inventory for attaching parts to a service order.
|
||||
|
||||
@@ -513,7 +663,7 @@ def inventory_search():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def list_catalog():
|
||||
"""List reusable labor/service concepts."""
|
||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||
@@ -526,7 +676,7 @@ def list_catalog():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -542,7 +692,7 @@ def create_catalog_item():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -557,7 +707,7 @@ def update_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -572,7 +722,7 @@ def delete_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_edit
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user