From 6e85497e4262f102148a4f197ce7a0a79f73e356 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Fri, 3 Jul 2026 07:55:17 +0000 Subject: [PATCH] feat(permissions): granular module permissions + workshop permissions matrix --- pos/blueprints/config_bp.py | 132 +++++++++++++++++++++-- pos/blueprints/service_order_bp.py | 136 +++++++++++------------- pos/static/js/config.js | 118 +++++++++++++++++++- pos/static/js/workshop.js | 120 +++++++++++++-------- pos/templates/config.html | 31 ++++-- pos/templates/workshop.html | 2 +- scripts/migrate_workshop_permissions.py | 81 ++++++++++++++ 7 files changed, 484 insertions(+), 136 deletions(-) create mode 100644 scripts/migrate_workshop_permissions.py diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 8cb7bd3..0aa8a85 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -45,6 +45,47 @@ _DEFAULT_ROLE_PERMISSIONS = { } +# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS. +_WORKSHOP_STATUSES = [ + 'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar', + 'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada', + 'por_entregar', 'entregado', 'por_enviar', 'enviado', + 'por_facturar', 'facturada', 'por_recolectar', 'cancelada' +] + +# Workshop-specific actions configurable per role. +_WORKSHOP_ACTIONS = [ + {'key': 'create_order', 'label': 'Crear órdenes'}, + {'key': 'edit_order', 'label': 'Editar órdenes'}, + {'key': 'delete_order', 'label': 'Eliminar órdenes'}, + {'key': 'assign_mechanic', 'label': 'Asignar mecánico'}, + {'key': 'add_items', 'label': 'Agregar artículos'}, + {'key': 'add_labor', 'label': 'Agregar mano de obra'}, + {'key': 'change_status', 'label': 'Cambiar estatus'}, + {'key': 'convert_to_sale', 'label': 'Convertir a venta'}, + {'key': 'convert_to_remission', 'label': 'Generar nota de remisión'}, + {'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'}, + {'key': 'view_prices', 'label': 'Ver precios/costos'}, + {'key': 'view_notes', 'label': 'Ver bitácora'}, +] + +# Default workshop permissions per role. Admins see everything; restricted roles only work statuses. +_DEFAULT_WORKSHOP_PERMISSIONS = { + 'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]}, + 'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]}, + 'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']}, + 'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']}, + 'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]}, + 'workshop': { + 'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')], + 'actions': ['change_status', 'add_labor', 'view_notes'] + }, + 'mechanic': { + 'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'], + 'actions': ['change_status', 'add_labor', 'view_notes'] + }, +} + _AVAILABLE_PERMISSIONS = [ {'module': 'Dashboard', 'permissions': [ {'key': 'dashboard.view', 'label': 'Ver Dashboard'}, @@ -55,23 +96,33 @@ _AVAILABLE_PERMISSIONS = [ {'key': 'pos.discount', 'label': 'Aplicar descuentos'}, {'key': 'pos.cancel', 'label': 'Cancelar ventas'}, {'key': 'pos.remission', 'label': 'Notas de remisión'}, + {'key': 'pos.view_cost', 'label': 'Ver costos en POS'}, ]}, {'module': 'Inventario', 'permissions': [ {'key': 'inventory.view', 'label': 'Ver inventario'}, {'key': 'inventory.create', 'label': 'Crear artículos'}, - {'key': 'inventory.edit', 'label': 'Editar/eliminar artículos'}, + {'key': 'inventory.edit', 'label': 'Editar artículos'}, + {'key': 'inventory.delete', 'label': 'Eliminar artículos'}, + {'key': 'inventory.adjust', 'label': 'Ajustar stock'}, + {'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'}, + {'key': 'inventory.import', 'label': 'Importar artículos masivamente'}, + {'key': 'inventory.view_cost', 'label': 'Ver costos'}, ]}, {'module': 'Catálogo', 'permissions': [ {'key': 'catalog.view', 'label': 'Ver catálogo'}, + {'key': 'catalog.edit', 'label': 'Editar catálogo'}, ]}, {'module': 'Clientes', 'permissions': [ {'key': 'customers.view', 'label': 'Ver clientes'}, - {'key': 'customers.create', 'label': 'Crear/editar clientes'}, + {'key': 'customers.create', 'label': 'Crear clientes'}, + {'key': 'customers.edit', 'label': 'Editar clientes'}, + {'key': 'customers.delete', 'label': 'Eliminar clientes'}, + {'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'}, ]}, {'module': 'Taller', 'permissions': [ - {'key': 'workshop.view', 'label': 'Ver órdenes'}, - {'key': 'workshop.edit', 'label': 'Crear/editar órdenes'}, - {'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra'}, + {'key': 'workshop.view', 'label': 'Ver módulo Taller'}, + {'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'}, + {'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'}, ]}, {'module': 'Facturación', 'permissions': [ {'key': 'invoicing.view', 'label': 'Ver facturas'}, @@ -79,8 +130,9 @@ _AVAILABLE_PERMISSIONS = [ {'key': 'invoicing.cancel', 'label': 'Cancelar facturas'}, ]}, {'module': 'Configuración', 'permissions': [ + {'key': 'config.view', 'label': 'Ver configuración'}, {'key': 'config.edit', 'label': 'Editar configuración'}, - {'key': 'config.edit_prices', 'label': 'Modificar precios'}, + {'key': 'config.edit_prices', 'label': 'Modificar precios globales'}, ]}, {'module': 'Contabilidad', 'permissions': [ {'key': 'accounting.view', 'label': 'Ver contabilidad'}, @@ -115,6 +167,30 @@ def _get_role_permissions(conn, role): return list(_DEFAULT_ROLE_PERMISSIONS.get(role, [])) +def _get_workshop_permissions(conn, role): + """Return configured workshop permissions for a role, falling back to defaults.""" + cur = conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'") + row = cur.fetchone() + cur.close() + if row and row[0]: + try: + configured = json.loads(row[0]) + if isinstance(configured, dict) and role in configured: + return configured[role] + except (ValueError, TypeError): + pass + return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []}) + + +def _get_all_workshop_permissions(conn): + """Return effective workshop permissions for every known role.""" + result = {} + for role in _DEFAULT_WORKSHOP_PERMISSIONS: + result[role] = _get_workshop_permissions(conn, role) + return result + + @config_bp.route('/branches', methods=['GET']) @require_auth() def list_branches(): @@ -444,6 +520,50 @@ def save_role_permissions_config(): return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())}) +@config_bp.route('/role-permissions/workshop', methods=['GET']) +@require_auth('config.view') +def get_workshop_permissions_config(): + """Return the configured workshop permissions (statuses + actions) per role.""" + conn = get_tenant_conn(g.tenant_id) + try: + return jsonify({ + 'roles': _get_all_workshop_permissions(conn), + 'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES], + 'actions': _WORKSHOP_ACTIONS, + }) + finally: + conn.close() + + +@config_bp.route('/role-permissions/workshop', methods=['PUT']) +@require_auth('config.edit') +def save_workshop_permissions_config(): + """Save the workshop permission mapping per role.""" + if g.employee_role not in ('owner', 'admin'): + return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403 + data = request.get_json() or {} + if 'roles' not in data: + return jsonify({'error': 'roles object required'}), 400 + + for role, cfg in data['roles'].items(): + if role not in _DEFAULT_WORKSHOP_PERMISSIONS: + return jsonify({'error': f'Invalid role: {role}'}), 400 + if not isinstance(cfg, dict): + return jsonify({'error': f'config for {role} must be an object'}), 400 + if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list): + return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (json.dumps(data['roles']),)) + conn.commit() + cur.close(); conn.close() + return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())}) + + @config_bp.route('/employees/', methods=['PUT']) @require_auth('config.edit') def update_employee(emp_id): diff --git a/pos/blueprints/service_order_bp.py b/pos/blueprints/service_order_bp.py index c9e0e2c..65d7605 100644 --- a/pos/blueprints/service_order_bp.py +++ b/pos/blueprints/service_order_bp.py @@ -33,45 +33,43 @@ from services.service_order_engine import ( update_status, ) from tenant_db import get_tenant_conn +from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS 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. +# Roles allowed to access the workshop module at all. _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). -# 'autorizada' is intentionally NOT hidden because mechanics must work on authorized orders. -# Delivery/logistics statuses are hidden for both workshop and mechanic. -_MECHANIC_HIDDEN_STATUSES = { - 'cotizada', 'por_autorizar', 'autorizacion_parcial', - 'por_facturar', 'facturada', - 'por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar' -} - -# Statuses hidden for workshop accounts (delivery/logistics only). -_WORKSHOP_HIDDEN_STATUSES = { - 'por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar' -} 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 _get_ws_perms(): + """Return effective workshop permissions for the current user.""" + conn = get_tenant_conn(g.tenant_id) + try: + return _get_workshop_permissions(conn, g.employee_role) + finally: + conn.close() -def _is_restricted_workshop_viewer(): - return g.employee_role in ('workshop', 'mechanic') +def _visible_statuses(): + if g.employee_role in ('owner', 'admin'): + return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses']) + return _get_ws_perms().get('statuses', []) -def _redact_order_for_mechanic(order): - """Remove customer/commercial data from the order payload for mechanics.""" - if not _is_restricted_workshop_viewer(): +def _can_do_action(action): + if g.employee_role in ('owner', 'admin'): + return True + return action in _get_ws_perms().get('actions', []) + + +def _redact_order_for_restricted(order): + """Remove customer/commercial data when the role cannot view sensitive data.""" + if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'): return order sensitive = { 'customer_id', 'customer_name', 'customer_phone', 'customer_address', @@ -93,14 +91,14 @@ def _redact_order_for_mechanic(order): 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] + redacted[k] = [_redact_item_for_restricted(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.""" +def _redact_item_for_restricted(item): + """Hide sensitive item rows for restricted viewers.""" item = dict(item) for k in ('mechanic_id', 'unit_cost', 'unit_price'): if k in item: @@ -118,14 +116,18 @@ def require_workshop_view(f): 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 +def require_workshop_action(action): + def decorator(f): + @wraps(f) + @require_auth() + def decorated(*args, **kwargs): + if not _can_view_workshop(): + return jsonify({'error': 'Missing permissions: workshop.view'}), 403 + if not _can_do_action(action): + return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403 + return f(*args, **kwargs) + return decorated + return decorator @service_order_bp.route('', methods=['GET']) @@ -149,12 +151,11 @@ 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', [])] - hidden_statuses = _MECHANIC_HIDDEN_STATUSES if g.employee_role == 'mechanic' else _WORKSHOP_HIDDEN_STATUSES + if g.employee_role not in ('owner', 'admin'): + visible = _visible_statuses() result['data'] = [ - o for o in result.get('data', []) - if o.get('status') not in hidden_statuses + _redact_order_for_restricted(o) for o in result.get('data', []) + if o.get('status') in visible ] return jsonify(result) finally: @@ -204,7 +205,7 @@ def create_order_from_pos(): @service_order_bp.route('', methods=['POST']) -@require_workshop_edit +@require_workshop_action('create_order') def create_order(): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) @@ -244,28 +245,22 @@ def get_order(so_id): order = get_service_order(conn, so_id) if not order: return jsonify({'error': 'Service order not found'}), 404 - # Workshop/mechanic accounts cannot view delivery/logistics (and mechanic also commercial) statuses. - if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES: + if order.get('status') not in _visible_statuses(): return jsonify({'error': 'No tienes acceso a esta orden'}), 403 - if g.employee_role == 'workshop' and order.get('status') in _WORKSHOP_HIDDEN_STATUSES: - return jsonify({'error': 'No tienes acceso a esta orden'}), 403 - return jsonify(_redact_order_for_mechanic(order)) + return jsonify(_redact_order_for_restricted(order)) finally: conn.close() @service_order_bp.route('/', methods=['PUT']) -@require_workshop_view +@require_workshop_action('edit_order') 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) @@ -277,7 +272,7 @@ def update_order(so_id): @service_order_bp.route('/', methods=['DELETE']) -@require_workshop_edit +@require_workshop_action('delete_order') def delete_order(so_id): """Soft-delete a service order. Restricted to owner/admin.""" if g.employee_role not in ('owner', 'admin'): @@ -294,16 +289,13 @@ def delete_order(so_id): @service_order_bp.route('//status', methods=['PUT']) -@require_workshop_view +@require_workshop_action('change_status') 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: + if new_status not in _visible_statuses(): return jsonify({'error': 'No puedes cambiar a este estatus'}), 403 conn = get_tenant_conn(g.tenant_id) try: @@ -322,7 +314,7 @@ def change_status(so_id): # ─── Items (Parts) ───────────────────────────── @service_order_bp.route('//items', methods=['POST']) -@require_workshop_edit +@require_workshop_action('add_items') def add_order_item(so_id): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) @@ -334,7 +326,7 @@ def add_order_item(so_id): @service_order_bp.route('/items/', methods=['PUT']) -@require_workshop_edit +@require_workshop_action('add_items') def update_order_item(item_id): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) @@ -348,7 +340,7 @@ def update_order_item(item_id): @service_order_bp.route('/items/', methods=['DELETE']) -@require_workshop_edit +@require_workshop_action('add_items') def delete_order_item(item_id): conn = get_tenant_conn(g.tenant_id) try: @@ -361,7 +353,7 @@ def delete_order_item(item_id): # ─── Labor ───────────────────────────── @service_order_bp.route('//labor', methods=['POST']) -@require_workshop_edit +@require_workshop_action('add_labor') def add_order_labor(so_id): data = request.get_json() or {} if not data.get('description'): @@ -375,7 +367,7 @@ def add_order_labor(so_id): @service_order_bp.route('/labor/', methods=['PUT']) -@require_workshop_edit +@require_workshop_action('add_labor') def update_order_labor(labor_id): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) @@ -389,7 +381,7 @@ def update_order_labor(labor_id): @service_order_bp.route('/labor/', methods=['DELETE']) -@require_workshop_edit +@require_workshop_action('add_labor') def delete_order_labor(labor_id): conn = get_tenant_conn(g.tenant_id) try: @@ -416,7 +408,7 @@ def kanban_summary(): @service_order_bp.route('//items//reserve', methods=['POST']) -@require_workshop_edit +@require_workshop_action('add_items') def reserve_order_item(so_id, item_id): """Reserve inventory for a service order item.""" conn = get_tenant_conn(g.tenant_id) @@ -430,7 +422,7 @@ def reserve_order_item(so_id, item_id): @service_order_bp.route('//items//release', methods=['POST']) -@require_workshop_edit +@require_workshop_action('add_items') def release_order_item(so_id, item_id): """Release a previous inventory reservation.""" conn = get_tenant_conn(g.tenant_id) @@ -448,6 +440,7 @@ def release_order_item(so_id, item_id): @service_order_bp.route('//convert-to-sale', methods=['POST']) @require_auth('pos.sell') +@require_workshop_action('convert_to_sale') def convert_order_to_sale(so_id): """Convert a service order into a POS sale. @@ -487,6 +480,7 @@ def convert_order_to_sale(so_id): @service_order_bp.route('//convert-to-remission', methods=['POST']) @require_auth('pos.remission') +@require_workshop_action('convert_to_remission') def convert_order_to_remission(so_id): """Convert a service order into a counter remission note (pending payment). @@ -517,7 +511,7 @@ def convert_order_to_remission(so_id): @service_order_bp.route('//assign-mechanic', methods=['PUT']) -@require_workshop_edit +@require_workshop_action('assign_mechanic') def assign_mechanic_endpoint(so_id): """Assign a mechanic/technician to a service order.""" data = request.get_json() or {} @@ -536,7 +530,7 @@ def assign_mechanic_endpoint(so_id): @service_order_bp.route('/customers', methods=['POST']) -@require_workshop_edit +@require_workshop_action('edit_order') def create_customer_for_workshop(): """Create a customer directly from the workshop flow.""" data = request.get_json() or {} @@ -570,7 +564,7 @@ def create_customer_for_workshop(): @service_order_bp.route('/vehicles', methods=['POST']) -@require_workshop_edit +@require_workshop_action('edit_order') def create_vehicle_for_workshop(): """Create a fleet vehicle directly from the workshop flow. @@ -685,7 +679,7 @@ def list_catalog(): @service_order_bp.route('/service-catalog', methods=['POST']) -@require_workshop_edit +@require_workshop_action('edit_order') def create_catalog_item(): """Create a reusable labor concept.""" data = request.get_json() or {} @@ -701,7 +695,7 @@ def create_catalog_item(): @service_order_bp.route('/service-catalog/', methods=['PUT']) -@require_workshop_edit +@require_workshop_action('edit_order') def update_catalog_item(item_id): """Update a reusable labor concept.""" data = request.get_json() or {} @@ -716,7 +710,7 @@ def update_catalog_item(item_id): @service_order_bp.route('/service-catalog/', methods=['DELETE']) -@require_workshop_edit +@require_workshop_action('edit_order') def delete_catalog_item(item_id): """Soft-delete a reusable labor concept.""" conn = get_tenant_conn(g.tenant_id) @@ -731,7 +725,7 @@ def delete_catalog_item(item_id): @service_order_bp.route('//print', methods=['POST']) -@require_workshop_edit +@require_workshop_action('edit_order') def print_service_order_ticket(so_id): """Generate a printable ticket for a service order. diff --git a/pos/static/js/config.js b/pos/static/js/config.js index e9d98cd..55d6d00 100644 --- a/pos/static/js/config.js +++ b/pos/static/js/config.js @@ -9,8 +9,6 @@ const Config = (() => { // Cache for branches (used by employee modal selector) let _branches = []; - let _rolePermissions = {}; - let _availablePermissions = []; function token() { return localStorage.getItem('pos_token') || ''; @@ -1035,6 +1033,12 @@ const Config = (() => { // ------------------------------------------------------------------------- // Role permissions editor // ------------------------------------------------------------------------- + var _rolePermissions = {}; + var _availablePermissions = []; + var _workshopPermissions = {}; + var _workshopSchema = { statuses: [], actions: [] }; + var _currentPermTab = 'modules'; + async function loadRolePermissions() { try { var res = await fetch(API + '/role-permissions', { headers: headers() }); @@ -1106,6 +1110,108 @@ const Config = (() => { } } + // Workshop-specific permissions editor + // ------------------------------------------------------------------------- + async function loadWorkshopPermissions() { + try { + var res = await fetch(API + '/role-permissions/workshop', { headers: headers() }); + if (!res.ok) return; + var data = await res.json(); + _workshopPermissions = data.roles || {}; + _workshopSchema = { statuses: data.statuses || [], actions: data.actions || [] }; + renderWorkshopPermissions(); + } catch (e) { + console.error('Config.loadWorkshopPermissions:', e); + } + } + + function renderWorkshopPermissions() { + var container = document.getElementById('workshop-permissions-container'); + var roleSel = document.getElementById('cfg-perm-role'); + if (!container || !roleSel) return; + var role = roleSel.value; + if (!role) { + container.innerHTML = '

Selecciona un rol para ver y editar sus permisos de Taller.

'; + return; + } + var cfg = _workshopPermissions[role] || { statuses: [], actions: [] }; + var visibleStatuses = cfg.statuses || []; + var visibleActions = cfg.actions || []; + + var html = '
'; + + // Statuses + html += '
'; + html += '

Estatus visibles en Taller

'; + (_workshopSchema.statuses || []).forEach(function(s) { + var checked = visibleStatuses.indexOf(s.key) !== -1 ? 'checked' : ''; + html += ''; + }); + html += '
'; + + // Actions + html += '
'; + html += '

Acciones permitidas en Taller

'; + (_workshopSchema.actions || []).forEach(function(a) { + var checked = visibleActions.indexOf(a.key) !== -1 ? 'checked' : ''; + html += ''; + }); + html += '
'; + + html += '
'; + container.innerHTML = html; + } + + async function saveWorkshopPermissions() { + var roleSel = document.getElementById('cfg-perm-role'); + var status = document.getElementById('workshop-permissions-status'); + if (!roleSel || !roleSel.value) { + if (status) status.textContent = 'Selecciona un rol'; + return; + } + var role = roleSel.value; + var statuses = []; + var actions = []; + document.querySelectorAll('#workshop-permissions-container input[data-ws-status]').forEach(function(cb) { + if (cb.checked) statuses.push(cb.dataset.wsStatus); + }); + document.querySelectorAll('#workshop-permissions-container input[data-ws-action]').forEach(function(cb) { + if (cb.checked) actions.push(cb.dataset.wsAction); + }); + var payload = { roles: {} }; + payload.roles[role] = { statuses: statuses, actions: actions }; + try { + var res = await fetch(API + '/role-permissions/workshop', { + method: 'PUT', + headers: headers(), + body: JSON.stringify(payload) + }); + var data = await res.json().catch(function() { return { error: res.statusText }; }); + if (!res.ok) throw new Error(data.error || 'Error al guardar'); + _workshopPermissions[role] = { statuses: statuses, actions: actions }; + if (status) status.textContent = 'Permisos de Taller guardados'; + setTimeout(function() { if (status) status.textContent = ''; }, 4000); + } catch (e) { + if (status) status.textContent = e.message; + toast(e.message, 'error'); + } + } + + function switchPermTab(tab) { + _currentPermTab = tab; + document.querySelectorAll('#perm-panel-modules, #perm-panel-workshop').forEach(function(el) { el.style.display = 'none'; el.classList.remove('is-active'); }); + document.querySelectorAll('#tab-perm-modules, #tab-perm-workshop').forEach(function(el) { el.classList.remove('is-active'); }); + document.getElementById('perm-panel-' + tab).style.display = 'block'; + document.getElementById('perm-panel-' + tab).classList.add('is-active'); + document.getElementById('tab-perm-' + tab).classList.add('is-active'); + } + // ------------------------------------------------------------------------- // Init // ------------------------------------------------------------------------- @@ -1172,7 +1278,10 @@ const Config = (() => { loadAllowedBrands(); loadModules(); loadReceiptConfig(); - if (isAdmin) loadRolePermissions(); + if (isAdmin) { + loadRolePermissions(); + loadWorkshopPermissions(); + } // Activate default or stored tab var defaultTab = 'general'; @@ -1203,7 +1312,8 @@ const Config = (() => { loadModules, saveModules, loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo, openModal, closeModal, openBranchModal, editBranch, - switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions + switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions, + loadWorkshopPermissions, renderWorkshopPermissions, saveWorkshopPermissions, switchPermTab }; })(); diff --git a/pos/static/js/workshop.js b/pos/static/js/workshop.js index 3b9d823..b29aaa3 100644 --- a/pos/static/js/workshop.js +++ b/pos/static/js/workshop.js @@ -34,13 +34,17 @@ var Workshop = (function() { var isMechanic = role === 'mechanic'; var hidePrices = isRestricted; var perms = user.permissions || []; - // Owner/admin/counter/cashier can create/edit/delete service orders. - // Mechanics have limited access: view allowed statuses, change status, edit diagnosis/repair notes. - var canEdit = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier'; - var canCreate = canEdit; - var canDelete = role === 'owner' || role === 'admin'; - var canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1; - var canChangeStatus = canEdit || isMechanic; + // Workshop-specific permissions are loaded from the backend. These defaults + // will be overwritten after init() fetches the role configuration. + var workshopPerms = { statuses: [], actions: [] }; + function hasWsAction(action) { + return workshopPerms.actions.indexOf(action) !== -1; + } + function isWsStatusVisible(status) { + return role === 'owner' || role === 'admin' || workshopPerms.statuses.indexOf(status) !== -1; + } + var canEdit = false, canCreate = false, canDelete = false, canSell = false, canChangeStatus = false; + var hidePrices = false; var COLUMNS = [ {key: 'por_revisar', label: 'Por revisar'}, @@ -183,7 +187,45 @@ var Workshop = (function() { // ─── Init ─── - function init() { + async function loadWorkshopPerms() { + try { + var res = await fetch('/pos/api/config/role-permissions/workshop', { headers: headers() }); + if (res.ok) { + var data = await res.json(); + var cfg = (data.roles || {})[role] || { statuses: [], actions: [] }; + workshopPerms = { + statuses: Array.isArray(cfg.statuses) ? cfg.statuses : [], + actions: Array.isArray(cfg.actions) ? cfg.actions : [] + }; + } + } catch (e) { + console.error('Workshop.loadWorkshopPerms:', e); + } + // Fallback defaults when fetch fails / no config + if (!workshopPerms.statuses.length && role !== 'owner' && role !== 'admin') { + if (role === 'mechanic') { + workshopPerms.statuses = ['por_revisar','en_revision','revisada','en_reparacion','reparada','autorizada','cancelada']; + workshopPerms.actions = ['change_status','add_labor','view_notes']; + } else if (role === 'workshop') { + workshopPerms.statuses = COLUMNS.filter(function(c) { + return ['por_entregar','entregado','por_enviar','enviado','por_recolectar'].indexOf(c.key) === -1; + }).map(function(c) { return c.key; }).concat(['cancelada']); + workshopPerms.actions = ['change_status','add_labor','view_notes']; + } + } + } + + async function init() { + await loadWorkshopPerms(); + + // Set action flags from workshop permissions. + canCreate = role === 'owner' || role === 'admin' || hasWsAction('create_order'); + canEdit = role === 'owner' || role === 'admin' || hasWsAction('edit_order'); + canDelete = role === 'owner' || role === 'admin' || hasWsAction('delete_order'); + canChangeStatus = role === 'owner' || role === 'admin' || hasWsAction('change_status'); + canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1; + hidePrices = role !== 'owner' && role !== 'admin' && !hasWsAction('view_prices'); + var savedView = localStorage.getItem('workshop_view'); if (savedView) currentView = savedView; bindFilters(); @@ -196,14 +238,11 @@ var Workshop = (function() { document.querySelectorAll('.restricted-hide').forEach(function(el) { el.style.display = 'none'; }); var searchInput = document.getElementById('filterSearch'); if (searchInput) searchInput.placeholder = 'Buscar orden'; - // Remove statuses that workshop/mechanic cannot see from the status filter. + // Remove statuses that the role cannot see from the status filter. var statusSel = document.getElementById('filterStatus'); if (statusSel) { - var hiddenStatuses = isMechanic - ? ['cotizada','por_autorizar','autorizacion_parcial','por_facturar','facturada','por_entregar','entregado','por_enviar','enviado','por_recolectar'] - : ['por_entregar','entregado','por_enviar','enviado','por_recolectar']; Array.from(statusSel.options).forEach(function(opt) { - if (opt.value && hiddenStatuses.indexOf(opt.value) !== -1) opt.remove(); + if (opt.value && !isWsStatusVisible(opt.value)) opt.remove(); }); statusSel.value = ''; } @@ -263,25 +302,18 @@ var Workshop = (function() { .then(function(r) { return r.json(); }) .then(function(d) { var cards = document.querySelectorAll('#statsRow .summary-card'); - if (isRestricted) { - // Taller accounts: hide delivery/logistics card; show work-in-progress summary. - cards.forEach(function(card, idx) { - card.style.display = (idx === 2) ? 'none' : ''; // hide "Por entregar" - }); - document.getElementById('statReceived').textContent = fmt(d.por_revisar || 0); - document.getElementById('statRepair').textContent = fmt( - (d.en_reparacion || 0) + (d.en_revision || 0) + (d.revisada || 0) + - (d.cotizada || 0) + (d.por_autorizar || 0) + - (d.autorizada || 0) + (d.autorizacion_parcial || 0) - ); - document.getElementById('statReady').textContent = '0'; - document.getElementById('statOverdue').textContent = fmt(d.overdue || 0); - return; - } cards.forEach(function(card) { card.style.display = ''; }); - document.getElementById('statReceived').textContent = fmt(d.por_revisar || 0); - document.getElementById('statRepair').textContent = fmt((d.en_reparacion || 0) + (d.en_revision || 0) + (d.revisada || 0) + (d.cotizada || 0) + (d.por_autorizar || 0)); - document.getElementById('statReady').textContent = fmt(d.por_entregar || 0); + + var porRevisar = isWsStatusVisible('por_revisar') ? (d.por_revisar || 0) : 0; + var enTaller = 0; + ['en_reparacion','en_revision','revisada','cotizada','por_autorizar','autorizada','autorizacion_parcial'].forEach(function(s) { + if (isWsStatusVisible(s)) enTaller += d[s] || 0; + }); + var porEntregar = isWsStatusVisible('por_entregar') ? (d.por_entregar || 0) : 0; + + document.getElementById('statReceived').textContent = fmt(porRevisar); + document.getElementById('statRepair').textContent = fmt(enTaller); + document.getElementById('statReady').textContent = fmt(porEntregar); document.getElementById('statOverdue').textContent = fmt(d.overdue || 0); }) .catch(function() {}); @@ -407,15 +439,7 @@ var Workshop = (function() { function renderKanban() { var board = document.getElementById('kanbanBoard'); board.innerHTML = ''; - var hiddenCols = []; - if (isMechanic) { - // Mechanic sees everything except commercial and delivery/logistics statuses. - hiddenCols = ['cotizada','por_autorizar','autorizacion_parcial','por_facturar','facturada','por_entregar','entregado','por_enviar','enviado','por_recolectar']; - } else if (role === 'workshop') { - // Workshop sees everything except delivery/logistics statuses. - hiddenCols = ['por_entregar','entregado','por_enviar','enviado','por_recolectar']; - } - COLUMNS.filter(function(col) { return hiddenCols.indexOf(col.key) === -1; }).forEach(function(col) { + COLUMNS.filter(function(col) { return isWsStatusVisible(col.key); }).forEach(function(col) { var colOrders = orders.filter(function(o) { return o.status === col.key; }); var colEl = document.createElement('div'); colEl.className = 'kanban-column'; @@ -558,7 +582,7 @@ var Workshop = (function() { // Footer actions var footer = document.getElementById('detailFooter'); var allowedNext = (VALID_NEXT[o.status] || []).filter(function(s) { - return !isMechanic || ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'].indexOf(s) === -1; + return isWsStatusVisible(s); }); var statusHtml = canChangeStatus && allowedNext.length ? '
' + @@ -568,15 +592,16 @@ var Workshop = (function() { ' ' + ' ' + '
' : ''; + var canPrint = role !== 'workshop' && role !== 'mechanic'; footer.innerHTML = statusHtml + '' + (canEdit ? '' : '') + (canDelete ? '' : '') + - (isRestricted ? '' : '') + - (canEdit && canSell && (o.status === 'por_entregar' || o.status === 'entregado') && !o.sale_id ? '' : '') + - (canEdit && canSell && !o.sale_id && o.status !== 'cancelled' ? '' : '') + + 'Imprimir orden' : '') + + (hasWsAction('convert_to_sale') && canSell && (o.status === 'por_entregar' || o.status === 'entregado') && !o.sale_id ? '' : '') + + (hasWsAction('convert_to_remission') && canSell && !o.sale_id && o.status !== 'cancelled' ? '' : '') + (o.sale_id ? 'Ver venta #' + o.sale_id + '' : ''); } @@ -632,11 +657,12 @@ var Workshop = (function() { } function renderArticles(o) { + var canManageItems = hasWsAction('add_items'); var colCount = (hidePrices ? 5 : 6) - (isRestricted ? 1 : 0); var partsRows = (o.items || []).map(function(it) { var priceCells = hidePrices ? '' : '' + fmtMoney(it.unit_price) + ''; var mechanicCells = isRestricted ? '' : '' + esc(mechanicName(it.mechanic_id)) + ''; - var actionCell = canEdit && it.status !== 'cancelado' ? + var actionCell = canManageItems && it.status !== 'cancelado' ? '' : ''; return '' + '' + esc(it.name) + '
' + esc(it.part_number || '') + '' + @@ -650,7 +676,7 @@ var Workshop = (function() { }).join(''); var partsHeader = 'ConceptoCant.' + (hidePrices ? '' : 'Precio') + (isRestricted ? '' : 'Mecánico') + 'EstadoObservaciones'; - var addParts = canEdit ? + var addParts = canManageItems ? '
' + '
' + ' ' + diff --git a/pos/templates/config.html b/pos/templates/config.html index 943f1a0..1ceebe6 100644 --- a/pos/templates/config.html +++ b/pos/templates/config.html @@ -854,7 +854,7 @@
- @@ -866,13 +866,30 @@
-
-

Selecciona un rol para ver y editar sus permisos.

+ +
+ +
-
- - +
+
+

Selecciona un rol para ver y editar sus permisos.

+
+
+ + +
+
+ +
@@ -1021,7 +1038,7 @@ - + diff --git a/pos/templates/workshop.html b/pos/templates/workshop.html index 2403e04..44d1534 100644 --- a/pos/templates/workshop.html +++ b/pos/templates/workshop.html @@ -499,7 +499,7 @@ - + diff --git a/scripts/migrate_workshop_permissions.py b/scripts/migrate_workshop_permissions.py new file mode 100644 index 0000000..7bc5774 --- /dev/null +++ b/scripts/migrate_workshop_permissions.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Initialize workshop_permissions tenant_config for all existing tenants. + +Uses the same defaults defined in pos/blueprints/config_bp.py. Safe to run +multiple times (it overwrites with defaults). + +Example: + sudo -u postgres python3 scripts/migrate_workshop_permissions.py +""" +import json +import os +import sys + +import psycopg2 + +# Mirror of pos/blueprints/config_bp.py defaults +_WORKSHOP_STATUSES = [ + 'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar', + 'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada', + 'por_entregar', 'entregado', 'por_enviar', 'enviado', + 'por_facturar', 'facturada', 'por_recolectar', 'cancelada' +] + +_ALL_ACTIONS = [ + 'create_order', 'edit_order', 'delete_order', 'assign_mechanic', + 'add_items', 'add_labor', 'change_status', 'convert_to_sale', + 'convert_to_remission', 'view_customer_data', 'view_prices', 'view_notes', +] + +_DEFAULT_WORKSHOP_PERMISSIONS = { + 'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': _ALL_ACTIONS}, + 'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': _ALL_ACTIONS}, + 'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a for a in _ALL_ACTIONS if a != 'delete_order']}, + 'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a for a in _ALL_ACTIONS if a != 'delete_order']}, + 'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a for a in _ALL_ACTIONS if a not in ('delete_order', 'convert_to_sale')]}, + 'workshop': { + 'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')], + 'actions': ['change_status', 'add_labor', 'view_notes'] + }, + 'mechanic': { + 'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'], + 'actions': ['change_status', 'add_labor', 'view_notes'] + }, +} + + +def get_tenant_dbs(master_db='nexus_autoparts'): + conn = psycopg2.connect(f"postgresql://postgres@/{master_db}") + cur = conn.cursor() + cur.execute("SELECT db_name FROM tenants WHERE is_active = true") + dbs = [r[0] for r in cur.fetchall() if r[0]] + cur.close(); conn.close() + return dbs + + +def migrate(db_name): + conn = psycopg2.connect(f"postgresql://postgres@/{db_name}") + cur = conn.cursor() + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (json.dumps(_DEFAULT_WORKSHOP_PERMISSIONS),)) + conn.commit() + cur.close(); conn.close() + return True + + +def main(): + dbs = get_tenant_dbs() + print(f"Migrating {len(dbs)} tenant(s)...") + for db in dbs: + try: + migrate(db) + print(f" OK {db}") + except Exception as e: + print(f" ERR {db}: {e}") + print("Done.") + + +if __name__ == '__main__': + main()