feat: cashier/counter reports, service-order & remission flows, Rached migration utils
- Add "Mis cortes de caja" report for cashiers/counters with sales detail. - Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint. - Remove dashboard from cashier menu; add Reports to cashier/counter. - Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission. - Remission notes module (UI, CSS, courier, counter remissions). - Customer hard-delete and vehicle/customer linkage in workshop. - POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio. - Inventory: CSV template with sku_secondary, alias import. - Rached migration scripts and DB migrations. - Version-bump cached JS/CSS query strings. Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
This commit is contained in:
@@ -8,6 +8,7 @@ from datetime import datetime, timezone, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from middleware import require_auth
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
||||
|
||||
@@ -158,6 +159,68 @@ def login_pin():
|
||||
return response
|
||||
|
||||
|
||||
@auth_bp.route('/refresh', methods=['POST'])
|
||||
@require_auth()
|
||||
def refresh_token():
|
||||
"""Reissue the JWT with the employee's current permissions from the DB.
|
||||
|
||||
This lets permission changes take effect without forcing a full re-login.
|
||||
The original expiration time is preserved.
|
||||
"""
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
try:
|
||||
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
|
||||
tenant_id = payload.get('tenant_id')
|
||||
employee_id = payload.get('employee_id')
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
|
||||
FROM employees e
|
||||
WHERE e.id = %s AND e.is_active = true
|
||||
""",
|
||||
(employee_id,)
|
||||
)
|
||||
emp = cur.fetchone()
|
||||
if not emp:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Employee not found or inactive'}), 404
|
||||
|
||||
cur.execute(
|
||||
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
||||
(emp[0],)
|
||||
)
|
||||
permissions = [r[0] for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
|
||||
new_payload = {
|
||||
'tenant_id': tenant_id,
|
||||
'employee_id': emp[0],
|
||||
'name': emp[1],
|
||||
'role': emp[2],
|
||||
'branch_id': emp[3],
|
||||
'max_discount_pct': float(emp[4]) if emp[4] else 0,
|
||||
'permissions': permissions,
|
||||
'device_id': payload.get('device_id', 'unknown'),
|
||||
'type': 'pos_access',
|
||||
'exp': payload.get('exp'),
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
|
||||
return jsonify({
|
||||
'token': token,
|
||||
'employee': {
|
||||
'id': emp[0], 'name': emp[1], 'role': emp[2],
|
||||
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
|
||||
},
|
||||
'permissions': permissions
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/employees/<int:tenant_id>', methods=['GET'])
|
||||
@auth_bp.route('/employees', methods=['GET'])
|
||||
def list_login_employees(tenant_id=None):
|
||||
@@ -191,7 +254,7 @@ def list_login_employees(tenant_id=None):
|
||||
name = row[1]
|
||||
parts = name.split()
|
||||
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
||||
employees.append({
|
||||
'id': row[0],
|
||||
'name': name,
|
||||
|
||||
@@ -3,16 +3,26 @@
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.audit import log_action
|
||||
|
||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||
|
||||
|
||||
# Roles expected to operate a cash register even without the explicit pos.sell permission.
|
||||
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
|
||||
|
||||
|
||||
def _can_operate_register():
|
||||
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
|
||||
|
||||
|
||||
@cashregister_bp.route('/open', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def open_register():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Open a cash register session.
|
||||
|
||||
Body: {register_number: int, opening_amount: float}
|
||||
@@ -84,7 +94,7 @@ def open_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/current', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def current_register():
|
||||
"""Get the current open register for this employee."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -115,8 +125,10 @@ def current_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/movement', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cash_movement():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Record a cash in/out movement with mandatory reason.
|
||||
|
||||
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||
@@ -273,8 +285,10 @@ def _compute_register_summary(conn, register_id):
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cut_x():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -301,8 +315,10 @@ def cut_x():
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cut_z():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Final cut (corte Z): close the register.
|
||||
|
||||
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||
@@ -379,12 +395,18 @@ def cut_z():
|
||||
|
||||
|
||||
@cashregister_bp.route('/history', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@require_auth()
|
||||
def register_history():
|
||||
"""List closed registers with summary.
|
||||
|
||||
Query params: date_from, date_to, employee_id, page, per_page
|
||||
|
||||
Permission rules:
|
||||
- owner/admin and users with pos.view can query any employee.
|
||||
- Cashiers/counters without pos.view can only query their own registers.
|
||||
"""
|
||||
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -402,6 +424,9 @@ def register_history():
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
|
||||
if not can_view_all:
|
||||
employee_id = g.employee_id
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("cr.closed_at >= %s")
|
||||
params.append(date_from)
|
||||
@@ -576,3 +601,89 @@ def daily_summary():
|
||||
'movements_out': movements['out'],
|
||||
'registers': registers,
|
||||
})
|
||||
|
||||
|
||||
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
|
||||
@require_auth()
|
||||
def register_sales(register_id):
|
||||
"""List the sales associated with a specific cash register (cash cut).
|
||||
|
||||
Returns sales details and a summary by payment method. Accessible to
|
||||
owners/admins, users with pos.view, or the employee who operated the register.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
|
||||
(register_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register not found'}), 404
|
||||
|
||||
register_employee_id, register_branch_id = row
|
||||
|
||||
can_view = (
|
||||
g.employee_role in ('owner', 'admin') or
|
||||
has_permission('pos.view') or
|
||||
register_employee_id == g.employee_id
|
||||
)
|
||||
if not can_view:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||
|
||||
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register belongs to another branch'}), 403
|
||||
|
||||
where = "s.register_id = %s"
|
||||
params = [register_id]
|
||||
if g.branch_id:
|
||||
where += " AND s.branch_id = %s"
|
||||
params.append(g.branch_id)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
|
||||
s.created_at, c.name as customer_name, e.name as employee_name
|
||||
FROM sales s
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
""", params)
|
||||
|
||||
sales = []
|
||||
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
|
||||
for r in cur.fetchall():
|
||||
sale = {
|
||||
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
'discount_total': float(r[4]) if r[4] else 0,
|
||||
'tax_total': float(r[5]) if r[5] else 0,
|
||||
'total': float(r[6]) if r[6] else 0,
|
||||
'amount_paid': float(r[7]) if r[7] else 0,
|
||||
'change_given': float(r[8]) if r[8] else 0,
|
||||
'status': r[9], 'created_at': str(r[10]),
|
||||
'customer_name': r[11], 'employee_name': r[12]
|
||||
}
|
||||
sales.append(sale)
|
||||
if sale['status'] == 'completed':
|
||||
summary['total'] += sale['total']
|
||||
summary['count'] += 1
|
||||
m = sale['payment_method'] or 'Otro'
|
||||
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'register_id': register_id,
|
||||
'sales': sales,
|
||||
'summary': {
|
||||
'total': round(summary['total'], 2),
|
||||
'count': summary['count'],
|
||||
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# /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
|
||||
@@ -8,6 +9,109 @@ 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'],
|
||||
}
|
||||
|
||||
|
||||
_AVAILABLE_PERMISSIONS = [
|
||||
{'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'},
|
||||
]},
|
||||
{'module': 'Inventario', 'permissions': [
|
||||
{'key': 'inventory.view', 'label': 'Ver inventario'},
|
||||
{'key': 'inventory.create', 'label': 'Crear artículos'},
|
||||
{'key': 'inventory.edit', 'label': 'Editar/eliminar artículos'},
|
||||
]},
|
||||
{'module': 'Catálogo', 'permissions': [
|
||||
{'key': 'catalog.view', 'label': 'Ver catálogo'},
|
||||
]},
|
||||
{'module': 'Clientes', 'permissions': [
|
||||
{'key': 'customers.view', 'label': 'Ver clientes'},
|
||||
{'key': 'customers.create', 'label': 'Crear/editar clientes'},
|
||||
]},
|
||||
{'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'},
|
||||
]},
|
||||
{'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.edit', 'label': 'Editar configuración'},
|
||||
{'key': 'config.edit_prices', 'label': 'Modificar precios'},
|
||||
]},
|
||||
{'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, []))
|
||||
|
||||
|
||||
@config_bp.route('/branches', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_branches():
|
||||
@@ -250,7 +354,7 @@ def create_employee():
|
||||
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', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
||||
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
|
||||
|
||||
@@ -265,33 +369,8 @@ def create_employee():
|
||||
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
||||
emp_id = cur.fetchone()[0]
|
||||
|
||||
# Set default permissions by role
|
||||
role_permissions = {
|
||||
'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',
|
||||
'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', 'workshop.edit', 'workshop.add_items'],
|
||||
}
|
||||
|
||||
for perm in role_permissions.get(data['role'], []):
|
||||
# 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)
|
||||
@@ -307,6 +386,61 @@ def create_employee():
|
||||
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('/employees/<int:emp_id>', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_employee(emp_id):
|
||||
@@ -767,7 +901,7 @@ def update_whatsapp_config():
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
@require_auth()
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -820,6 +954,35 @@ def update_modules():
|
||||
}})
|
||||
|
||||
|
||||
@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('/onboarding-status', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_onboarding_status():
|
||||
|
||||
@@ -10,10 +10,20 @@ from services.audit import log_action
|
||||
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
||||
|
||||
|
||||
def _can_view_customers():
|
||||
"""Taller/counter employees need customer autocomplete even without customers.view."""
|
||||
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('workshop', 'mechanic', 'counter')
|
||||
|
||||
|
||||
def _can_create_customer():
|
||||
"""Taller/counter employees can create customers on the fly from an order."""
|
||||
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('workshop', 'mechanic', 'counter')
|
||||
|
||||
|
||||
# ─── Customer CRUD ─────────────────────────────
|
||||
|
||||
@customers_bp.route('', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
@require_auth()
|
||||
def list_customers():
|
||||
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||
|
||||
@@ -23,6 +33,8 @@ def list_customers():
|
||||
per_page: items per page (default 50, max 200)
|
||||
branch_id: filter by branch (default: current user's branch)
|
||||
"""
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -219,13 +231,15 @@ def get_customer_purchases(customer_id):
|
||||
|
||||
|
||||
@customers_bp.route('', methods=['POST'])
|
||||
@require_auth('customers.create')
|
||||
@require_auth()
|
||||
def create_customer():
|
||||
"""Create a new customer.
|
||||
|
||||
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||
phone, address, price_tier, credit_limit, vehicle_info}
|
||||
"""
|
||||
if not _can_create_customer():
|
||||
return jsonify({'error': 'Missing permissions: customers.create'}), 403
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
|
||||
@@ -103,8 +103,13 @@ def list_items():
|
||||
# branch_id no longer filters inventory rows (shared catalog).
|
||||
# It is used only to show per-branch stock.
|
||||
if search:
|
||||
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
# Search also matches alternate / alias SKUs.
|
||||
where_clauses.append(
|
||||
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
|
||||
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
|
||||
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
|
||||
)
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
if category:
|
||||
where_clauses.append("i.category_id = %s")
|
||||
params.append(int(category))
|
||||
@@ -490,6 +495,7 @@ def bulk_import_items():
|
||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||
'ubicacion': 'location', 'categoria': 'category',
|
||||
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
|
||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||
@@ -599,7 +605,20 @@ def bulk_import_items():
|
||||
created_ids.append(item_id)
|
||||
created += 1
|
||||
|
||||
# ---------- 2. Vehicle compatibility ----------
|
||||
# ---------- 2. Secondary SKU alias ----------
|
||||
sku_secondary = str(row.get('sku_secondary', '')).strip()
|
||||
if sku_secondary:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(item_id, sku_secondary, 'Secundario')
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# ---------- 3. Vehicle compatibility ----------
|
||||
make = str(row.get('make', '')).strip()
|
||||
model = str(row.get('model', '')).strip()
|
||||
year_str = str(row.get('year', '')).strip()
|
||||
|
||||
@@ -13,7 +13,8 @@ from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.pos_engine import (
|
||||
process_sale, cancel_sale, calculate_totals,
|
||||
get_price_for_customer, get_margin_info
|
||||
get_price_for_customer, get_margin_info,
|
||||
create_remission_note, pay_pending_sale
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
@@ -22,6 +23,15 @@ from config import JWT_SECRET
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
|
||||
|
||||
def _tenant_allows_negative_stock(conn):
|
||||
"""Return True if the tenant explicitly allows selling below zero stock."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
|
||||
|
||||
|
||||
def _enrich_items(cur, items, customer_id=None):
|
||||
"""Look up inventory data for items that lack unit_price/tax_rate.
|
||||
|
||||
@@ -104,17 +114,19 @@ def create_sale():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
# Verify stock availability per item for the active branch
|
||||
# (skipped for tenants configured to allow negative stock / oversell)
|
||||
branch_id = data.get('branch_id', g.branch_id)
|
||||
for item in data.get('items', []):
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if inv_id:
|
||||
available = get_stock(conn, inv_id, branch_id)
|
||||
if available < qty:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||
}), 400
|
||||
if not _tenant_allows_negative_stock(conn):
|
||||
for item in data.get('items', []):
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if inv_id:
|
||||
available = get_stock(conn, inv_id, branch_id)
|
||||
if available < qty:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
sale = process_sale(conn, data)
|
||||
@@ -131,8 +143,89 @@ def create_sale():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/sales/remission', methods=['POST'])
|
||||
@require_auth('pos.remission')
|
||||
def create_remission():
|
||||
"""Create a counter remission note (pending payment, reserved stock)."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
branch_id = data.get('branch_id', g.branch_id)
|
||||
if not _tenant_allows_negative_stock(conn):
|
||||
for item in data.get('items', []):
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if inv_id:
|
||||
available = get_stock(conn, inv_id, branch_id)
|
||||
if available < qty:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
sale = create_remission_note(conn, {
|
||||
'tenant_id': g.tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': data.get('customer_id'),
|
||||
'items': data.get('items', []),
|
||||
'notes': data.get('notes'),
|
||||
'register_id': data.get('register_id'),
|
||||
'currency': data.get('currency', 'MXN'),
|
||||
'exchange_rate': data.get('exchange_rate'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
})
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(sale), 201
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/pay', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def pay_sale(sale_id):
|
||||
"""Pay a pending counter remission note.
|
||||
|
||||
Body: {
|
||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
|
||||
amount_paid: float,
|
||||
payment_details: [{method, amount, reference}],
|
||||
register_id: int,
|
||||
reference: str
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
sale = pay_pending_sale(conn, sale_id, {
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
'amount_paid': data.get('amount_paid', 0),
|
||||
'payment_details': data.get('payment_details', []),
|
||||
'register_id': data.get('register_id'),
|
||||
'reference': data.get('reference', ''),
|
||||
})
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(sale), 200
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/sales', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@require_auth()
|
||||
def list_sales():
|
||||
"""List sales with filters.
|
||||
|
||||
@@ -146,6 +239,13 @@ def list_sales():
|
||||
page: int (default 1)
|
||||
per_page: int (default 50, max 200)
|
||||
"""
|
||||
sale_type = request.args.get('sale_type')
|
||||
is_remission = sale_type == 'counter_remission'
|
||||
can_view_all = g.employee_role == 'owner' or has_permission('pos.view')
|
||||
can_view_remissions = is_remission and (has_permission('pos.sell') or has_permission('pos.remission'))
|
||||
if not (can_view_all or can_view_remissions):
|
||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -160,6 +260,10 @@ def list_sales():
|
||||
employee_id = request.args.get('employee_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
status = request.args.get('status')
|
||||
sale_type = request.args.get('sale_type')
|
||||
q = request.args.get('q')
|
||||
customer_q = request.args.get('customer')
|
||||
courier_id = request.args.get('courier_id')
|
||||
register_id = request.args.get('register_id')
|
||||
|
||||
if date_from:
|
||||
@@ -177,6 +281,18 @@ def list_sales():
|
||||
if status:
|
||||
where_clauses.append("s.status = %s")
|
||||
params.append(status)
|
||||
if sale_type:
|
||||
where_clauses.append("s.sale_type = %s")
|
||||
params.append(sale_type)
|
||||
if q:
|
||||
where_clauses.append("(s.id::text ILIKE %s OR c.name ILIKE %s)")
|
||||
params.extend([f'%{q}%', f'%{q}%'])
|
||||
if customer_q:
|
||||
where_clauses.append("c.name ILIKE %s")
|
||||
params.append(f'%{customer_q}%')
|
||||
if courier_id:
|
||||
where_clauses.append("s.courier_id = %s")
|
||||
params.append(int(courier_id))
|
||||
if register_id:
|
||||
where_clauses.append("s.register_id = %s")
|
||||
params.append(int(register_id))
|
||||
@@ -195,12 +311,14 @@ def list_sales():
|
||||
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
|
||||
s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||
s.tax_total, s.total, s.amount_paid, s.change_given,
|
||||
s.status, s.created_at,
|
||||
s.status, s.created_at, s.courier_id,
|
||||
e.name as employee_name,
|
||||
c.name as customer_name
|
||||
c.name as customer_name,
|
||||
co.name as courier_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
@@ -219,7 +337,9 @@ def list_sales():
|
||||
'amount_paid': float(r[11]) if r[11] else 0,
|
||||
'change_given': float(r[12]) if r[12] else 0,
|
||||
'status': r[13], 'created_at': str(r[14]),
|
||||
'employee_name': r[15], 'customer_name': r[16],
|
||||
'courier_id': r[15],
|
||||
'employee_name': r[16], 'customer_name': r[17],
|
||||
'courier_name': r[18],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -1475,6 +1595,7 @@ def convert_quotation(quot_id):
|
||||
'register_id': data.get('register_id'),
|
||||
'amount_paid': data.get('amount_paid', 0),
|
||||
'payment_details': data.get('payment_details', []),
|
||||
'reference': data.get('reference', ''),
|
||||
'notes': f'Convertida de cotizacion #{quot_id}',
|
||||
'currency': quot_currency,
|
||||
'exchange_rate': quot_rate,
|
||||
@@ -2545,6 +2666,79 @@ def print_ticket(sale_id):
|
||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/print-remission', methods=['POST'])
|
||||
@require_auth()
|
||||
def print_remission(sale_id):
|
||||
"""Generate printable data for a counter remission note."""
|
||||
from middleware import has_permission
|
||||
if not (has_permission('pos.remission') or has_permission('pos.sell')):
|
||||
return jsonify({'error': 'Missing permissions'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.*, e.name as employee_name, c.name as customer_name, co.name as courier_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||
WHERE s.id = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
|
||||
cols = [desc[0] for desc in cur.description]
|
||||
sale = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
|
||||
if sale.get(k) is not None:
|
||||
sale[k] = float(sale[k])
|
||||
|
||||
cur.execute("""
|
||||
SELECT name, quantity, unit_price, subtotal
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'name': r[0], 'quantity': r[1],
|
||||
'unit_price': float(r[2]) if r[2] else 0,
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
})
|
||||
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
else:
|
||||
business_info[rw[0]] = rw[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'folio': f'NR-{sale["id"]}',
|
||||
'date': str(sale.get('created_at', '')),
|
||||
'employee': sale.get('employee_name', ''),
|
||||
'customer': sale.get('customer_name', ''),
|
||||
'courier': sale.get('courier_name', ''),
|
||||
'items': items,
|
||||
'subtotal': sale.get('subtotal', 0),
|
||||
'discount_total': sale.get('discount_total', 0),
|
||||
'tax_total': sale.get('tax_total', 0),
|
||||
'total': sale.get('total', 0),
|
||||
'status': sale.get('status', ''),
|
||||
'business_name': business_info.get('name', ''),
|
||||
'business_rfc': business_info.get('rfc', ''),
|
||||
'business_address': business_info.get('address', ''),
|
||||
})
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
|
||||
@@ -4,6 +4,7 @@ Prefix: /pos/api/service-orders
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
@@ -11,6 +12,7 @@ from services.service_order_engine import (
|
||||
add_item,
|
||||
add_labor,
|
||||
assign_mechanic,
|
||||
convert_to_remission,
|
||||
convert_to_sale,
|
||||
create_service_catalog_item,
|
||||
create_service_order,
|
||||
@@ -35,8 +37,91 @@ from tenant_db import get_tenant_conn
|
||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||
|
||||
|
||||
# Roles allowed to view or edit service orders.
|
||||
# Counter (mostrador) creates/edits; workshop/mechanic only view restricted info.
|
||||
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
||||
_WORKSHOP_EDIT_ROLES = {'owner', 'admin', 'counter', 'cashier'}
|
||||
|
||||
# Statuses that mechanics are not allowed to see (single shared mechanic account).
|
||||
_MECHANIC_HIDDEN_STATUSES = {
|
||||
'cotizada', 'por_autorizar', 'autorizada', 'autorizacion_parcial',
|
||||
'por_facturar', 'facturada'
|
||||
}
|
||||
|
||||
|
||||
def _can_view_workshop():
|
||||
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
|
||||
|
||||
|
||||
def _can_edit_workshop():
|
||||
return g.employee_role in _WORKSHOP_EDIT_ROLES
|
||||
|
||||
|
||||
def _is_restricted_workshop_viewer():
|
||||
return g.employee_role in ('workshop', 'mechanic')
|
||||
|
||||
|
||||
def _redact_order_for_mechanic(order):
|
||||
"""Remove customer/commercial data from the order payload for mechanics."""
|
||||
if not _is_restricted_workshop_viewer():
|
||||
return order
|
||||
sensitive = {
|
||||
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
||||
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
|
||||
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
|
||||
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
|
||||
'final_cost', 'total_parts', 'total_labor', 'total',
|
||||
}
|
||||
|
||||
def redact_value(v):
|
||||
if isinstance(v, str):
|
||||
return '—'
|
||||
if isinstance(v, (int, float)):
|
||||
return 0
|
||||
return None
|
||||
|
||||
redacted = {}
|
||||
for k, v in order.items():
|
||||
if k in sensitive:
|
||||
redacted[k] = redact_value(v)
|
||||
elif k == 'items' and isinstance(v, list):
|
||||
redacted[k] = [_redact_item_for_mechanic(it) for it in v]
|
||||
else:
|
||||
redacted[k] = v
|
||||
return redacted
|
||||
|
||||
|
||||
def _redact_item_for_mechanic(item):
|
||||
"""Hide mechanic name from item rows for mechanics."""
|
||||
item = dict(item)
|
||||
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
||||
if k in item:
|
||||
item[k] = None if not isinstance(item[k], (int, float)) else 0
|
||||
return item
|
||||
|
||||
|
||||
def require_workshop_view(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_view_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def require_workshop_edit(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_edit_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def list_orders():
|
||||
status = request.args.get('status')
|
||||
priority = request.args.get('priority')
|
||||
@@ -56,6 +141,14 @@ def list_orders():
|
||||
employee_id=employee_id, delivery_method=delivery_method,
|
||||
is_direct=is_direct, q=q, page=page, per_page=per_page
|
||||
)
|
||||
if _is_restricted_workshop_viewer():
|
||||
result['data'] = [_redact_order_for_mechanic(o) for o in result.get('data', [])]
|
||||
# Shared mechanic account can see all orders except commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic':
|
||||
result['data'] = [
|
||||
o for o in result.get('data', [])
|
||||
if o.get('status') not in _MECHANIC_HIDDEN_STATUSES
|
||||
]
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -88,6 +181,11 @@ def create_order_from_pos():
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
so_id = result['service_order_id']
|
||||
for item in items:
|
||||
@@ -99,7 +197,7 @@ def create_order_from_pos():
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -120,6 +218,11 @@ def create_order():
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
@@ -127,22 +230,33 @@ def create_order():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def get_order(so_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
return jsonify(order)
|
||||
# Shared mechanic account cannot view commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES:
|
||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
||||
return jsonify(_redact_order_for_mechanic(order))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_view
|
||||
def update_order(so_id):
|
||||
data = request.get_json() or {}
|
||||
# Full edit remains for owner/admin/counter/cashier.
|
||||
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
|
||||
if g.employee_role == 'mechanic':
|
||||
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
|
||||
if not data or any(k not in allowed for k in data.keys()):
|
||||
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
|
||||
elif not _can_edit_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_service_order(conn, so_id, data)
|
||||
@@ -154,7 +268,7 @@ def update_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order(so_id):
|
||||
"""Soft-delete a service order. Restricted to owner/admin."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
@@ -171,12 +285,17 @@ def delete_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_view
|
||||
def change_status(so_id):
|
||||
if not (_can_edit_workshop() or g.employee_role == 'mechanic'):
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
if not new_status:
|
||||
return jsonify({'error': 'status is required'}), 400
|
||||
# Mechanics cannot move orders into commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic' and new_status in _MECHANIC_HIDDEN_STATUSES:
|
||||
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = update_status(
|
||||
@@ -194,7 +313,7 @@ def change_status(so_id):
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -206,7 +325,7 @@ def add_order_item(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -220,7 +339,7 @@ def update_order_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -233,7 +352,7 @@ def delete_order_item(item_id):
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
@@ -247,7 +366,7 @@ def add_order_labor(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -261,7 +380,7 @@ def update_order_labor(labor_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -274,7 +393,7 @@ def delete_order_labor(labor_id):
|
||||
# ─── Kanban Summary ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def kanban_summary():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -288,7 +407,7 @@ def kanban_summary():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -302,7 +421,7 @@ def reserve_order_item(so_id, item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -354,11 +473,42 @@ def convert_order_to_sale(so_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Convert to remission note ────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
||||
@require_auth('pos.remission')
|
||||
def convert_order_to_remission(so_id):
|
||||
"""Convert a service order into a counter remission note (pending payment).
|
||||
|
||||
Body: {
|
||||
register_id: int (optional),
|
||||
notes: str (optional)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_payload = {
|
||||
'register_id': data.get('register_id'),
|
||||
'notes': data.get('notes'),
|
||||
}
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = convert_to_remission(
|
||||
conn, so_id, sale_payload, employee_id=g.employee_id
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Mechanic assignment ──────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
@@ -377,7 +527,7 @@ def assign_mechanic_endpoint(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/customers', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_customer_for_workshop():
|
||||
"""Create a customer directly from the workshop flow."""
|
||||
data = request.get_json() or {}
|
||||
@@ -411,7 +561,7 @@ def create_customer_for_workshop():
|
||||
|
||||
|
||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_vehicle_for_workshop():
|
||||
"""Create a fleet vehicle directly from the workshop flow.
|
||||
|
||||
@@ -456,7 +606,7 @@ def create_vehicle_for_workshop():
|
||||
|
||||
|
||||
@service_order_bp.route('/inventory-search', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def inventory_search():
|
||||
"""Search active inventory for attaching parts to a service order.
|
||||
|
||||
@@ -513,7 +663,7 @@ def inventory_search():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def list_catalog():
|
||||
"""List reusable labor/service concepts."""
|
||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||
@@ -526,7 +676,7 @@ def list_catalog():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -542,7 +692,7 @@ def create_catalog_item():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -557,7 +707,7 @@ def update_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -572,7 +722,7 @@ def delete_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_edit
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user