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