feat: cashier/counter reports, service-order & remission flows, Rached migration utils
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- Add "Mis cortes de caja" report for cashiers/counters with sales detail.
- Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint.
- Remove dashboard from cashier menu; add Reports to cashier/counter.
- Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission.
- Remission notes module (UI, CSS, courier, counter remissions).
- Customer hard-delete and vehicle/customer linkage in workshop.
- POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio.
- Inventory: CSV template with sku_secondary, alias import.
- Rached migration scripts and DB migrations.
- Version-bump cached JS/CSS query strings.

Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
This commit is contained in:
2026-07-02 12:51:56 +00:00
parent 483498cfcc
commit f42910f4f6
71 changed files with 5388 additions and 626 deletions

View File

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

View File

@@ -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()}
}
})

View File

@@ -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():

View File

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

View File

@@ -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()

View File

@@ -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 = """

View File

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