# /home/Autopartes/pos/blueprints/config_bp.py """Config blueprint: tenant configuration, branches, theming.""" import json from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission from tenant_db import get_tenant_conn config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config') # Default permission set per role. Can be overridden per tenant via role_permissions config. _DEFAULT_ROLE_PERMISSIONS = { 'owner': [], # owner bypasses permission checks 'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost', 'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer', 'catalog.view', 'catalog.edit', 'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit', 'invoicing.view', 'invoicing.create', 'reports.view', 'reports.financial', 'config.view', 'config.edit', 'config.edit_prices', 'workshop.view', 'workshop.edit', 'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'], 'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission', 'catalog.view', 'inventory.view', 'inventory.create', 'customers.view', 'customers.create', 'workshop.view', 'workshop.edit', 'workshop.add_items', 'invoicing.view', 'invoicing.create', 'invoicing.cancel'], 'counter': ['pos.remission', 'pos.view', 'catalog.view', 'inventory.view', 'inventory.create', 'customers.view', 'customers.create'], 'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer', 'catalog.view'], 'accountant': ['accounting.view', 'accounting.create', 'invoicing.view', 'invoicing.create', 'invoicing.cancel', 'reports.view', 'reports.financial', 'customers.view', 'fleet.view'], 'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'], 'mechanic': ['workshop.view'], 'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view', 'customers.view', 'customers.create'], } # 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'}, ]}, {'module': 'Punto de Venta', 'permissions': [ {'key': 'pos.sell', 'label': 'Realizar ventas'}, {'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'}, {'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 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 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 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'}, {'key': 'invoicing.create', 'label': 'Crear facturas'}, {'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 globales'}, ]}, {'module': 'Contabilidad', 'permissions': [ {'key': 'accounting.view', 'label': 'Ver contabilidad'}, {'key': 'accounting.create', 'label': 'Crear movimientos contables'}, ]}, {'module': 'Reportes', 'permissions': [ {'key': 'reports.view', 'label': 'Ver reportes'}, {'key': 'reports.financial', 'label': 'Reportes financieros'}, ]}, {'module': 'Flotillas', 'permissions': [ {'key': 'fleet.view', 'label': 'Ver flotillas'}, {'key': 'fleet.create', 'label': 'Crear flotillas'}, {'key': 'fleet.edit', 'label': 'Editar flotillas'}, {'key': 'fleet.delete', 'label': 'Eliminar flotillas'}, ]}, ] def _get_role_permissions(conn, role): """Return configured permissions for a role, falling back to defaults.""" cur = conn.cursor() cur.execute("SELECT value FROM tenant_config WHERE key = 'role_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 list(configured.get(role, [])) except (ValueError, TypeError): pass 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(): conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT id, name, address, phone, is_active, is_main, rfc, razon_social, regimen_fiscal, cp, direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email FROM branches ORDER BY id """) branches = [] for r in cur.fetchall(): branches.append({ 'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4], 'is_main': r[5], 'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8], 'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11], 'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14], }) cur.close() conn.close() return jsonify({'data': branches}) @config_bp.route('/branches/', methods=['GET']) @require_auth('config.view') def get_branch(branch_id): conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT id, name, address, phone, is_active, is_main, rfc, razon_social, regimen_fiscal, cp, direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email FROM branches WHERE id = %s """, (branch_id,)) r = cur.fetchone() cur.close() conn.close() if not r: return jsonify({'error': 'Branch not found'}), 404 return jsonify({ 'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4], 'is_main': r[5], 'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8], 'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11], 'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14], }) @config_bp.route('/branches', methods=['POST']) @require_auth('config.edit') def create_branch(): data = request.get_json() or {} if not data.get('name'): return jsonify({'error': 'name required'}), 400 # Plan limit check from services.billing import check_limit, next_plan, PLANS, get_plan conn_chk = get_tenant_conn(g.tenant_id) cur_chk = conn_chk.cursor() cur_chk.execute("SELECT count(*) FROM branches WHERE is_active = true") current_branches = cur_chk.fetchone()[0] cur_chk.close() conn_chk.close() allowed, limit, current = check_limit(g.tenant_id, 'max_branches', current_branches) if not allowed: plan_key = get_plan(g.tenant_id) nxt = next_plan(plan_key) nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise' return jsonify({'error': f'Plan limit reached ({limit} branches). Upgrade to {nxt_name}.'}), 403 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # If setting as main, clear any existing main if data.get('is_main'): cur.execute("UPDATE branches SET is_main = false WHERE is_main = true") cur.execute(""" INSERT INTO branches ( name, address, phone, is_main, rfc, razon_social, regimen_fiscal, cp, direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( data['name'], data.get('address'), data.get('phone'), bool(data.get('is_main')), data.get('rfc'), data.get('razon_social'), data.get('regimen_fiscal'), data.get('cp'), data.get('direccion_fiscal'), data.get('serie_cfdi'), data.get('folio_inicio'), data.get('folio_actual'), data.get('email'), )) branch_id = cur.fetchone()[0] conn.commit() cur.close() conn.close() return jsonify({'id': branch_id, 'message': 'Branch created'}), 201 @config_bp.route('/branches/', methods=['PUT']) @require_auth('config.edit') def update_branch(branch_id): data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,)) if not cur.fetchone(): cur.close(); conn.close() return jsonify({'error': 'Branch not found'}), 404 # If setting as main, clear any existing main if data.get('is_main'): cur.execute("UPDATE branches SET is_main = false WHERE is_main = true AND id <> %s", (branch_id,)) updates = [] params = [] field_map = { 'name': 'name', 'address': 'address', 'phone': 'phone', 'is_active': 'is_active', 'is_main': 'is_main', 'rfc': 'rfc', 'razon_social': 'razon_social', 'regimen_fiscal': 'regimen_fiscal', 'cp': 'cp', 'direccion_fiscal': 'direccion_fiscal', 'serie_cfdi': 'serie_cfdi', 'folio_inicio': 'folio_inicio', 'folio_actual': 'folio_actual', 'email': 'email', } for json_key, col in field_map.items(): if json_key in data: updates.append(f"{col} = %s") params.append(data[json_key]) if not updates: cur.close(); conn.close() return jsonify({'error': 'Nothing to update'}), 400 params.append(branch_id) cur.execute(f"UPDATE branches SET {', '.join(updates)} WHERE id = %s", params) conn.commit() cur.close() conn.close() return jsonify({'ok': True, 'message': 'Branch updated'}) @config_bp.route('/branches/', methods=['DELETE']) @require_auth('config.edit') def delete_branch(branch_id): """Hard-delete a branch. Only owner/admin can delete; main branch cannot be deleted. Related records keep their data but lose the branch reference; stock and count rows tied exclusively to the branch are removed. """ if g.employee_role not in ('owner', 'admin'): return jsonify({'error': 'Solo administradores pueden eliminar sucursales'}), 403 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT is_main FROM branches WHERE id = %s", (branch_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Branch not found'}), 404 if row[0]: cur.close(); conn.close() return jsonify({'error': 'No se puede eliminar la sucursal principal'}), 403 # Remove branch-specific stock and count rows first. cur.execute("DELETE FROM inventory_stock WHERE branch_id = %s", (branch_id,)) cur.execute("DELETE FROM inventory_stock_summary WHERE branch_id = %s", (branch_id,)) cur.execute("DELETE FROM physical_counts WHERE branch_id = %s", (branch_id,)) # Nullify every other FK reference back to branches. cur.execute(""" SELECT c.relname::text AS tbl, a.attname::text AS col FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey) WHERE con.confrelid = 'public.branches'::regclass AND con.contype = 'f' """) for tbl, col in cur.fetchall(): if tbl in ('inventory_stock', 'inventory_stock_summary', 'physical_counts'): continue cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (branch_id,)) cur.execute("DELETE FROM branches WHERE id = %s", (branch_id,)) conn.commit() cur.close() conn.close() return jsonify({'ok': True, 'message': 'Sucursal eliminada'}) @config_bp.route('/employees', methods=['GET']) @require_auth() def list_employees(): conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT e.id, e.name, e.email, e.phone, e.role, e.branch_id, b.name as branch_name, e.max_discount_pct, e.is_active FROM employees e LEFT JOIN branches b ON e.branch_id = b.id ORDER BY e.id """) employees = [] for r in cur.fetchall(): employees.append({ 'id': r[0], 'name': r[1], 'email': r[2], 'phone': r[3], 'role': r[4], 'branch_id': r[5], 'branch_name': r[6], 'max_discount_pct': float(r[7]) if r[7] else 0, 'is_active': r[8] }) cur.close() conn.close() return jsonify({'data': employees}) @config_bp.route('/employees', methods=['POST']) @require_auth('config.edit') def create_employee(): import bcrypt data = request.get_json() or {} required = ['name', 'role', 'pin'] for f in required: if not data.get(f): return jsonify({'error': f'{f} required'}), 400 # Plan limit check from services.billing import check_limit, next_plan, PLANS, get_plan conn_chk = get_tenant_conn(g.tenant_id) cur_chk = conn_chk.cursor() cur_chk.execute("SELECT count(*) FROM employees WHERE is_active = true") current_employees = cur_chk.fetchone()[0] cur_chk.close() conn_chk.close() allowed, limit, current = check_limit(g.tenant_id, 'max_employees', current_employees) if not allowed: plan_key = get_plan(g.tenant_id) nxt = next_plan(plan_key) nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise' return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403 valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic'] if data['role'] not in valid_roles: return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400 pin_hash = bcrypt.hashpw(data['pin'].encode(), bcrypt.gensalt()).decode() conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO employees (name, email, phone, pin, role, branch_id, max_discount_pct, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, true) RETURNING id """, (data['name'], data.get('email'), data.get('phone'), pin_hash, data['role'], data.get('branch_id'), data.get('max_discount_pct', 0))) emp_id = cur.fetchone()[0] # Set default permissions by role (configurable per tenant) for perm in _get_role_permissions(conn, data['role']): cur.execute( "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING", (emp_id, perm) ) from services.audit import log_action log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id, new_value={'name': data['name'], 'role': data['role']}) conn.commit() cur.close() conn.close() return jsonify({'id': emp_id, 'message': 'Employee created'}), 201 @config_bp.route('/role-permissions', methods=['GET']) @require_auth() def get_role_permissions_config(): """Return the configured permissions for each role and the available permission list.""" conn = get_tenant_conn(g.tenant_id) try: configured = {} for role in _DEFAULT_ROLE_PERMISSIONS: configured[role] = _get_role_permissions(conn, role) return jsonify({ 'roles': configured, 'available': _AVAILABLE_PERMISSIONS, }) finally: conn.close() @config_bp.route('/role-permissions', methods=['PUT']) @require_auth('config.edit') def save_role_permissions_config(): """Save the permission mapping per role. Only owner/admin can edit.""" 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, perms in data['roles'].items(): if role not in _DEFAULT_ROLE_PERMISSIONS: return jsonify({'error': f'Invalid role: {role}'}), 400 if not isinstance(perms, list): return jsonify({'error': f'permissions for {role} must be a list'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (json.dumps(data['roles']),)) # Apply the new permissions to existing employees of each affected role for role, perms in data['roles'].items(): cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,)) cur.execute("SELECT id FROM employees WHERE role = %s", (role,)) emp_ids = [r[0] for r in cur.fetchall()] for emp_id in emp_ids: for perm in perms: cur.execute( "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING", (emp_id, perm) ) conn.commit() cur.close(); conn.close() 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): """Update an existing employee's name, email, role, branch, discount, active status. If PIN is provided, it gets re-hashed. Otherwise PIN stays unchanged.""" import bcrypt data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Check employee exists cur.execute("SELECT id FROM employees WHERE id = %s", (emp_id,)) if not cur.fetchone(): cur.close(); conn.close() return jsonify({'error': 'Employee not found'}), 404 # Build SET clause dynamically — only update provided fields updates = [] params = [] field_map = { 'name': 'name', 'email': 'email', 'phone': 'phone', 'role': 'role', 'branch_id': 'branch_id', 'max_discount_pct': 'max_discount_pct', 'is_active': 'is_active', } for json_key, col in field_map.items(): if json_key in data: updates.append(f"{col} = %s") params.append(data[json_key]) # PIN update (only if provided and non-empty) if data.get('pin') and len(str(data['pin'])) >= 4: pin_hash = bcrypt.hashpw(str(data['pin']).encode(), bcrypt.gensalt()).decode() updates.append("pin = %s") params.append(pin_hash) updates.append("password_hash = %s") params.append(pin_hash) if not updates: cur.close(); conn.close() return jsonify({'error': 'Nothing to update'}), 400 params.append(emp_id) cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params) # If the role changed, re-sync permissions to match the new role defaults/config. if 'role' in data: cur.execute("DELETE FROM employee_permissions WHERE employee_id = %s", (emp_id,)) for perm in _get_role_permissions(conn, data['role']): cur.execute( "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING", (emp_id, perm) ) from services.audit import log_action log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id, new_value={k: v for k, v in data.items() if k != 'pin'}) conn.commit() cur.close() conn.close() return jsonify({'ok': True, 'message': 'Employee updated'}) @config_bp.route('/employees/', methods=['DELETE']) @require_auth('config.edit') def delete_employee(emp_id): """Hard-delete an employee. Foreign-key references are nulled and dependent rows (permissions/sessions) are removed. Owners cannot be deleted via UI to prevent locking out the tenant.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT role FROM employees WHERE id = %s", (emp_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Employee not found'}), 404 if row[0] == 'owner': cur.close(); conn.close() return jsonify({'error': 'No se puede eliminar una cuenta de dueno'}), 403 # Resolve every foreign-key column that points back to employees. cur.execute(""" SELECT c.relname::text AS tbl, a.attname::text AS col FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey) WHERE con.confrelid = 'public.employees'::regclass AND con.contype = 'f' """) refs = cur.fetchall() for tbl, col in refs: if tbl in ('employee_permissions', 'employee_sessions', 'notification_preferences'): cur.execute(f"DELETE FROM \"{tbl}\" WHERE \"{col}\" = %s", (emp_id,)) else: cur.execute(f"UPDATE \"{tbl}\" SET \"{col}\" = NULL WHERE \"{col}\" = %s", (emp_id,)) cur.execute("DELETE FROM employees WHERE id = %s", (emp_id,)) from services.audit import log_action log_action(conn, 'EMPLOYEE_DELETE', 'employee', emp_id) conn.commit() cur.close() conn.close() return jsonify({'ok': True, 'message': 'Empleado eliminado'}) @config_bp.route('/currency', methods=['GET']) @require_auth() def get_currency(): """Get currency config for this tenant.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('currency', 'exchange_rate_usd_mxn')") cfg = {} for row in cur.fetchall(): cfg[row[0]] = row[1] cur.close() conn.close() from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN return jsonify({ 'currency': cfg.get('currency', DEFAULT_CURRENCY), 'exchange_rate': float(cfg.get('exchange_rate_usd_mxn', str(EXCHANGE_RATE_USD_MXN))), 'currencies': { 'MXN': {'symbol': '$', 'name': 'Peso Mexicano'}, 'USD': {'symbol': 'US$', 'name': 'US Dollar'}, } }) @config_bp.route('/currency', methods=['PUT']) @require_auth('config.edit') def update_currency(): """Update currency config for this tenant.""" data = request.get_json() or {} currency = data.get('currency', 'MXN') rate = data.get('exchange_rate') if currency not in ('MXN', 'USD'): return jsonify({'error': 'currency must be MXN or USD'}), 400 if rate is not None: try: rate = float(rate) if rate <= 0: raise ValueError except (TypeError, ValueError): return jsonify({'error': 'exchange_rate must be a positive number'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('currency', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (currency,)) if rate is not None: cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('exchange_rate_usd_mxn', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (str(rate),)) conn.commit() cur.close() conn.close() # Invalidate cached exchange rate so next sale picks up the new value from services.currency import invalidate_rate_cache invalidate_rate_cache() return jsonify({'message': 'Currency config updated', 'currency': currency}) @config_bp.route('/business', methods=['GET']) @require_auth() def get_business(): """Read-only tenant business info from tenant_config.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'") cfg = {} for row in cur.fetchall(): cfg[row[0]] = row[1] cur.close() conn.close() return jsonify({ 'razon_social': cfg.get('tenant_razon_social', ''), 'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')), 'rfc': cfg.get('tenant_rfc', ''), 'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''), 'cp': cfg.get('tenant_cp', ''), 'direccion': cfg.get('tenant_direccion', ''), 'numero_exterior': cfg.get('tenant_numero_exterior', ''), 'numero_interior': cfg.get('tenant_numero_interior', ''), 'colonia': cfg.get('tenant_colonia', ''), 'ciudad': cfg.get('tenant_ciudad', ''), 'municipio': cfg.get('tenant_municipio', ''), 'estado': cfg.get('tenant_estado', ''), 'telefono': cfg.get('tenant_telefono', ''), 'email': cfg.get('tenant_email', ''), }) @config_bp.route('/business', methods=['PUT']) @require_auth('config.edit') def update_business(): """Save tenant business info to tenant_config.""" data = request.get_json() or {} field_map = { 'razon_social': 'tenant_razon_social', 'nombre': 'tenant_nombre', 'rfc': 'tenant_rfc', 'regimen_fiscal': 'tenant_regimen_fiscal', 'cp': 'tenant_cp', 'direccion': 'tenant_direccion', 'numero_exterior': 'tenant_numero_exterior', 'numero_interior': 'tenant_numero_interior', 'colonia': 'tenant_colonia', 'ciudad': 'tenant_ciudad', 'municipio': 'tenant_municipio', 'estado': 'tenant_estado', 'telefono': 'tenant_telefono', 'email': 'tenant_email', # Tax params (also keep cfdi_* aliases in sync) 'tax_iva': 'tax_iva', 'tax_ieps': 'tax_ieps', 'invoice_serie': 'invoice_serie', 'invoice_folio': 'invoice_folio', 'cfdi_serie': 'cfdi_serie', 'default_currency': 'default_currency', 'default_payment_method': 'default_payment_method', } conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() for field, key in field_map.items(): val = data.get(field) if val is not None: cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (key, str(val).strip())) conn.commit() cur.close() conn.close() return jsonify({'ok': True}) @config_bp.route('/theme', methods=['GET']) @require_auth() def get_theme(): """Get current theme for this tenant. Returns CSS variables.""" # For v1, return a default theme. The design team will add more. return jsonify({ 'theme': 'default', 'variables': { '--color-primary': '#1a73e8', '--color-secondary': '#5f6368', '--color-accent': '#ff6b35', '--color-bg': '#ffffff', '--color-surface': '#f8f9fa', '--color-text': '#202124', '--color-border': '#dadce0', '--font-display': "'Sora', sans-serif", '--font-body': "'Plus Jakarta Sans', sans-serif", '--font-mono': "'JetBrains Mono', monospace", '--radius': '8px', } }) # ─── Billing / Subscription ────────────────────────── @config_bp.route('/billing', methods=['GET']) @require_auth() def get_billing(): """Get current plan, usage stats, and available plans.""" from services.billing import get_plan_details, PLANS, PLAN_ORDER plan = get_plan_details(g.tenant_id) # Get current usage counts conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT count(*) FROM inventory WHERE is_active = true") products = cur.fetchone()[0] cur.execute("SELECT count(*) FROM employees WHERE is_active = true") employees = cur.fetchone()[0] cur.execute("SELECT count(*) FROM branches WHERE is_active = true") branches = cur.fetchone()[0] cur.close() conn.close() return jsonify({ 'current_plan': plan, 'usage': { 'products': products, 'employees': employees, 'branches': branches, }, 'plans': {k: {**v, 'key': k} for k, v in PLANS.items()}, 'plan_order': PLAN_ORDER, }) @config_bp.route('/billing/upgrade', methods=['POST']) @require_auth('config.edit') def upgrade_billing(): """Upgrade tenant plan.""" from services.billing import upgrade_plan data = request.get_json() or {} new_plan = data.get('plan') if not new_plan: return jsonify({'error': 'plan required'}), 400 result = upgrade_plan(g.tenant_id, new_plan) if 'error' in result: return jsonify(result), 400 return jsonify(result) # ─── Vehicle Compatibility Source ──────────────────── @config_bp.route('/vehicle-compat-source', methods=['GET']) @require_auth() def get_vehicle_compat_source(): """Get the configured vehicle compatibility source. Returns: {'source': 'tecdoc' | 'qwen' | 'both'} """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT value FROM tenant_config WHERE key = 'vehicle_compat_source'") row = cur.fetchone() cur.close() conn.close() source = row[0] if row else 'both' if source not in ('tecdoc', 'qwen', 'both'): source = 'both' return jsonify({'source': source}) @config_bp.route('/vehicle-compat-source', methods=['PUT']) @require_auth('config.edit') def update_vehicle_compat_source(): """Set the vehicle compatibility source.""" data = request.get_json() or {} source = data.get('source', 'both') if source not in ('tecdoc', 'qwen', 'both'): return jsonify({'error': 'source must be tecdoc, qwen, or both'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('vehicle_compat_source', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (source,)) conn.commit() cur.close() conn.close() return jsonify({'message': 'Vehicle compatibility source updated', 'source': source}) # ─── Allowed Part Brands ───────────────────────────────────────────────────── # Whitelist of part manufacturers shown in the allowed-brands selector _ALLOWED_PART_BRANDS = [ 'Luk', 'Motocraft', 'Euzcadi', 'Gates', 'Injetech', 'Bilstein', 'Monroe', 'Yokomitzu', 'Ecom', 'Lth', 'Dynamik', 'Wagner', 'Bosch', 'Brembo', 'Champion', 'Dorman', 'Kyb', 'Handkook', 'Tomco', 'Mann Filter', 'Total Parts', 'Kanadian', 'Pirelli', 'NGK', 'Moresa', 'Fritec', 'Acdelco', 'Dash4', 'Moog', 'SYD', 'FRAM', 'AUTOLITE' ] @config_bp.route('/available-brands', methods=['GET']) @require_auth() def get_available_brands(): """Return the whitelisted part manufacturer names. The master DB manufacturers/aftermarket_parts tables were removed with TecDoc, so we return the curated whitelist directly. """ brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()}) return jsonify({'brands': brands}) @config_bp.route('/allowed-brands', methods=['GET']) @require_auth() def get_allowed_brands(): """Return the tenant's allowed part brands from tenant_config.""" import json conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'") row = cur.fetchone() cur.close() conn.close() if row and row[0]: try: brands = json.loads(row[0]) if isinstance(brands, list): return jsonify({'brands': brands}) except (json.JSONDecodeError, ValueError): pass return jsonify({'brands': []}) @config_bp.route('/allowed-brands', methods=['PUT']) @require_auth('config.edit') def update_allowed_brands(): """Save the tenant's allowed part brands to tenant_config.""" import json data = request.get_json() or {} brands = data.get('brands', []) if not isinstance(brands, list): return jsonify({'error': 'brands must be an array'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('allowed_part_brands', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (json.dumps(brands),)) conn.commit() cur.close() conn.close() return jsonify({'message': 'Allowed brands updated', 'brands': brands}) # ─── WhatsApp Configuration ──────────────────────────────────────────────── @config_bp.route('/whatsapp', methods=['GET']) @require_auth('config.view') def get_whatsapp_config(): """Get WhatsApp bridge configuration for this tenant.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'") rows = {row[0]: row[1] for row in cur.fetchall()} cur.close() conn.close() return jsonify({ 'bridge_url': rows.get('whatsapp_bridge_url', ''), 'bridge_key': rows.get('whatsapp_bridge_key', ''), 'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true', 'phone_number': rows.get('whatsapp_phone_number', ''), }) @config_bp.route('/whatsapp', methods=['PUT']) @require_auth('config.edit') def update_whatsapp_config(): """Update WhatsApp bridge configuration for this tenant.""" data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() settings = { 'whatsapp_bridge_url': data.get('bridge_url', ''), 'whatsapp_bridge_key': data.get('bridge_key', ''), 'whatsapp_enabled': 'true' if data.get('enabled') else 'false', 'whatsapp_phone_number': data.get('phone_number', ''), } for key, value in settings.items(): cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (key, value)) conn.commit() cur.close() conn.close() return jsonify({'message': 'WhatsApp configuration updated'}) @config_bp.route('/modules', methods=['GET']) @require_auth() def get_modules(): """Get enabled modules for this tenant.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'") rows = {row[0]: row[1] for row in cur.fetchall()} cur.close() conn.close() def _bool(key): return rows.get(key, 'true').lower() == 'true' return jsonify({ 'whatsapp': _bool('module_whatsapp'), 'marketplace': _bool('module_marketplace'), 'meli': _bool('module_meli'), 'catalog': _bool('module_catalog'), 'workshop': _bool('module_workshop'), }) @config_bp.route('/modules', methods=['PUT']) @require_auth('config.edit') def update_modules(): """Update enabled modules for this tenant.""" data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() settings = { 'module_whatsapp': 'true' if data.get('whatsapp') else 'false', 'module_marketplace': 'true' if data.get('marketplace') else 'false', 'module_meli': 'true' if data.get('meli') else 'false', 'module_catalog': 'true' if data.get('catalog') else 'false', 'module_workshop': 'true' if data.get('workshop') else 'false', } for key, value in settings.items(): cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (key, value)) conn.commit() cur.close() conn.close() return jsonify({'message': 'Modules updated', 'modules': { 'whatsapp': data.get('whatsapp'), 'marketplace': data.get('marketplace'), 'meli': data.get('meli'), 'catalog': data.get('catalog'), 'workshop': data.get('workshop'), }}) @config_bp.route('/counter-remission', methods=['GET']) @require_auth() def get_counter_remission_config(): """Get counter remission note feature flag for this tenant.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'") row = cur.fetchone() cur.close(); conn.close() return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False}) @config_bp.route('/counter-remission', methods=['PUT']) @require_auth('config.edit') def update_counter_remission_config(): """Enable/disable counter remission notes for this tenant.""" data = request.get_json() or {} enabled = 'true' if data.get('enabled') else 'false' conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, ('counter_remission_enabled', enabled)) conn.commit() cur.close(); conn.close() return jsonify({'enabled': enabled == 'true'}) @config_bp.route('/sales-settings', methods=['GET']) @require_auth('pos.view') def get_sales_settings(): """Get sales-related settings (zero-price sales, negative stock).""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')") rows = {k: v for k, v in cur.fetchall()} cur.close(); conn.close() # Default allow_zero_price_sales to true to avoid breaking existing tenants. allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes') # Default allow_negative_stock to false (safer). neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes') return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg}) @config_bp.route('/sales-settings', methods=['PUT']) @require_auth('config.edit') def update_sales_settings(): """Update sales-related settings.""" data = request.get_json() or {} allow = 'true' if data.get('allow_zero_price_sales') else 'false' neg = 'true' if data.get('allow_negative_stock') else 'false' conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, ('allow_zero_price_sales', allow)) cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, ('allow_negative_stock', neg)) conn.commit() cur.close(); conn.close() return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'}) @config_bp.route('/onboarding-status', methods=['GET']) @require_auth('pos.view') def get_onboarding_status(): """Check if tenant onboarding wizard has been completed.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT value FROM tenant_config WHERE key = 'onboarding_completed'") row = cur.fetchone() cur.close() conn.close() return jsonify({'completed': row[0] == 'true' if row else False}) @config_bp.route('/onboarding-status', methods=['POST']) @require_auth('pos.view') def set_onboarding_status(): """Mark tenant onboarding wizard as completed.""" data = request.get_json() or {} completed = 'true' if data.get('completed') else 'false' conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, ('onboarding_completed', completed)) conn.commit() cur.close() conn.close() return jsonify({'completed': completed == 'true'}) # ─── Receipt / Ticket Customization ──────────────────────────────────────── RECEIPT_CONFIG_KEYS = [ 'receipt_logo', 'receipt_store_name', 'receipt_tagline', 'receipt_rfc', 'receipt_address', 'receipt_phone', 'receipt_footer', 'receipt_thanks_message', 'receipt_show_logo', 'receipt_show_rfc', 'receipt_show_address', 'receipt_show_phone', 'receipt_show_iva_breakdown', 'receipt_show_payment_details', 'receipt_show_employee', 'receipt_paper_width', ] @config_bp.route('/receipt', methods=['GET']) @require_auth('pos.view') def get_receipt_config(): """Get receipt customization settings.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute( "SELECT key, value FROM tenant_config WHERE key = ANY(%s)", (RECEIPT_CONFIG_KEYS,) ) rows = {row[0]: row[1] for row in cur.fetchall()} cur.close() conn.close() def _bool(key, default=False): v = rows.get(key, 'true' if default else 'false') return str(v).lower() == 'true' return jsonify({ 'logo': rows.get('receipt_logo', ''), 'store_name': rows.get('receipt_store_name', ''), 'tagline': rows.get('receipt_tagline', ''), 'rfc': rows.get('receipt_rfc', ''), 'address': rows.get('receipt_address', ''), 'phone': rows.get('receipt_phone', ''), 'footer': rows.get('receipt_footer', ''), 'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'), 'show_logo': _bool('receipt_show_logo', True), 'show_rfc': _bool('receipt_show_rfc', True), 'show_address': _bool('receipt_show_address', False), 'show_phone': _bool('receipt_show_phone', False), 'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True), 'show_payment_details': _bool('receipt_show_payment_details', True), 'show_employee': _bool('receipt_show_employee', False), 'paper_width': rows.get('receipt_paper_width', '80') or '80', }) @config_bp.route('/receipt', methods=['PUT']) @require_auth('config.edit') def update_receipt_config(): """Update receipt customization settings.""" data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() settings = { 'receipt_logo': data.get('logo', ''), 'receipt_store_name': data.get('store_name', ''), 'receipt_tagline': data.get('tagline', ''), 'receipt_rfc': data.get('rfc', ''), 'receipt_address': data.get('address', ''), 'receipt_phone': data.get('phone', ''), 'receipt_footer': data.get('footer', ''), 'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'), 'receipt_show_logo': 'true' if data.get('show_logo') else 'false', 'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false', 'receipt_show_address': 'true' if data.get('show_address') else 'false', 'receipt_show_phone': 'true' if data.get('show_phone') else 'false', 'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false', 'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false', 'receipt_show_employee': 'true' if data.get('show_employee') else 'false', 'receipt_paper_width': str(data.get('paper_width', '80') or '80'), } for key, value in settings.items(): cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (key, value)) conn.commit() cur.close() conn.close() return jsonify({'message': 'Receipt configuration updated'})