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

@@ -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.