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

4
.gitignore vendored
View File

@@ -91,3 +91,7 @@ backups/
# Local tools (AWS CLI)
tools/
# Rached migration session artifacts (tokens / captures / samples)
rached_*.json
rached_*.txt

View File

@@ -10,24 +10,20 @@ def create_app():
from middleware_tenant import resolve_tenant
app.before_request(resolve_tenant)
# ─── Server-side guard: workshop/mechanic users only see /pos/workshop ──────
@app.before_request
def restrict_workshop_users():
path = request.path
if not path.startswith('/pos/'):
return
if path == '/pos/workshop' or path.startswith('/pos/static/') or path.startswith('/pos/api/') or path == '/pos/sw.js' or path == '/pos/login' or path.startswith('/pos/login'):
return
role = request.cookies.get('pos_role', '').lower()
if role in ('workshop', 'mechanic'):
return redirect('/pos/workshop')
# NOTE: Page-level routing guards are handled client-side by app-init.js
# using the employee's current permissions; API endpoints enforce their own
# permission checks via @require_auth.
# ─── PWA: Service Worker must be served from /pos/ scope ──────
@app.route('/pos/sw.js')
def pos_sw():
from flask import send_from_directory
return send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript')
from flask import send_from_directory, make_response
response = make_response(send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Register blueprints
from blueprints.auth_bp import auth_bp
@@ -192,7 +188,11 @@ def create_app():
@app.route('/pos/dashboard')
def pos_dashboard():
return render_template('dashboard.html')
response = make_response(render_template('dashboard.html'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/config')
def pos_config():
@@ -234,6 +234,14 @@ def create_app():
def pos_historical_sales():
return render_template('historical_sales.html')
@app.route('/pos/remission-notes')
def pos_remission_notes():
response = make_response(render_template('remission_notes.html'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/static/<path:filename>')
def pos_static(filename):
return send_from_directory('static', filename)

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.

View File

@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
# Check permissions
if required_permissions:
missing = set(required_permissions) - g.permissions
# owner role bypasses all permission checks
if g.employee_role != 'owner' and missing:
# owner/admin roles bypass all permission checks
if g.employee_role not in ('owner', 'admin') and missing:
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
return f(*args, **kwargs)
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
def has_permission(permission):
"""Check if current user has a specific permission. Use inside a route."""
return g.employee_role == 'owner' or permission in g.permissions
return g.employee_role in ('owner', 'admin') or permission in g.permissions

View File

@@ -57,6 +57,19 @@ MIGRATIONS = {
"v4.8": "v4.8_workshop_permissions.sql",
"v4.9": "v4.9_workshop_customers_view.sql",
"v4.10": "v4.10_fleet_permissions.sql",
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
"v4.12": "v4.12_service_order_soft_delete.sql",
"v4.13": "v4.13_fleet_vehicle_customer.sql",
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
"v4.15": "v4.15_counter_remission.sql",
"v4.16": "v4.16_remission_courier.sql",
"v4.17": "v4.17_service_order_invoice.sql",
"v4.18": "v4.18_rached_workshop.sql",
"v4.19": "v4.19_counter_inventory_create.sql",
"v4.20": "v4.20_cashier_sell_permissions.sql",
"v4.21": "v4.21_cashier_workshop_permissions.sql",
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
"v4.23": "v4.23_service_order_mechanic_name.sql",
}
@@ -112,7 +125,10 @@ def apply_migration(db_name, version):
def run_migrations():
"""Apply pending migrations to all tenants."""
tenants = get_all_tenants()
sorted_versions = sorted(MIGRATIONS.keys())
def _version_key(v):
return tuple(int(x) for x in v.lstrip('v').split('.'))
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
print(f"Found {len(tenants)} active tenants")
print(f"Available migrations: {sorted_versions}")
@@ -120,8 +136,9 @@ def run_migrations():
for tenant_id, db_name, name, current_version in tenants:
print(f"\n[{name}] (db={db_name}, current={current_version})")
current_key = _version_key(current_version)
for version in sorted_versions:
if version <= current_version:
if _version_key(version) <= current_key:
continue
print(f" Applying {version}...", end=" ")

View File

@@ -0,0 +1,10 @@
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
UPDATE service_orders
SET delivery_method = 'pickup'
WHERE delivery_method = 'courier';
UPDATE service_orders
SET courier_id = NULL
WHERE delivery_method IS NULL OR delivery_method != 'delivery';

View File

@@ -0,0 +1,18 @@
-- v4.15: Counter remission note support
-- Ensures employees with role 'counter' have the base permissions needed to
-- generate remission notes from the POS.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('pos.remission'),
('pos.view'),
('catalog.view'),
('inventory.view'),
('customers.view'),
('customers.create')
) AS p(permission)
WHERE e.role = 'counter'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -0,0 +1,18 @@
-- v4.16: Add courier assignment to counter remission notes.
ALTER TABLE sales
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'sales_courier_id_fkey'
) THEN
ALTER TABLE sales
ADD CONSTRAINT sales_courier_id_fkey
FOREIGN KEY (courier_id) REFERENCES couriers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);

View File

@@ -0,0 +1,6 @@
-- Service order invoice requirement flag
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
ON service_orders(requires_invoice);

View File

@@ -0,0 +1,32 @@
-- v4.18 Rached workshop fields
-- Extends service orders and items to match the Rached legacy workshop flow.
-- Applied to all tenants.
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
ADD COLUMN IF NOT EXISTS customer_address TEXT,
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_order_items
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
ADD COLUMN IF NOT EXISTS observations TEXT;
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);

View File

@@ -0,0 +1,11 @@
-- v4.19 — Grant inventory.create permission to existing counter employees
-- so they can create items and record purchase entries.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'inventory.create'
FROM employees e
WHERE e.role = 'counter'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
);

View File

@@ -0,0 +1,18 @@
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
-- The cashier default set already includes these permissions, but employees
-- created before the fix may be missing them.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('pos.sell'),
('pos.discount'),
('pos.cancel'),
('pos.view')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -0,0 +1,15 @@
-- v4.21 — Allow existing cashier employees to create and edit service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -0,0 +1,15 @@
-- v4.22 -- Enable invoicing for existing cashier employees.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('invoicing.view'),
('invoicing.create'),
('invoicing.cancel')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -0,0 +1,2 @@
-- v4.23 -- Add free-text mechanic name for service orders.
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;

View File

@@ -230,6 +230,54 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
return op_id
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
cost_at_time=None, remaining_stock=None):
"""Reserve stock for a counter remission note (negative quantity).
The reserved quantity is deducted from available stock until the note is
paid or cancelled.
"""
op_id = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
notes='Reserva por nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
try:
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
if remaining <= 0:
cur = conn.cursor()
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
inv_row = cur.fetchone()
cur.close()
if inv_row:
from services.push_service import notify_owner
notify_owner(
conn,
'Stock en Cero',
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
'/pos'
)
except Exception:
pass
return op_id
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Release a previous reservation (positive quantity)."""
result = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
reference_id=sale_id, reference_type='sale',
notes=notes or 'Liberacion de reserva de nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity)."""
result = record_operation(

View File

@@ -16,6 +16,8 @@ from flask import g
from services.audit import log_action
from services.inventory_engine import (
record_sale as inventory_record_sale,
record_reservation as inventory_record_reservation,
release_reservation as inventory_release_reservation,
record_operation,
get_stock,
get_stock_bulk,
@@ -313,24 +315,34 @@ def process_sale(conn, sale_data):
credit_limit = float(cust[0] or 0)
credit_balance = float(cust[1] or 0)
credit_available = credit_limit - credit_balance
if totals['total'] > credit_available and credit_limit > 0:
if totals['total'] > credit_available:
raise ValueError(
f"Insufficient credit. Available: ${credit_available:.2f}, "
f"Required: ${totals['total']:.2f}"
)
# Pending payment sale (e.g. "Pendiente")
is_pending = payment_method == 'pendiente'
if is_pending:
amount_paid = 0.0
sale_type = 'cash'
# Calculate change
change_given = 0.0
if sale_type == 'cash' and payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
# SAT payment method codes
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
forma_pago_map = {
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
}
forma_pago_sat = forma_pago_map.get(payment_method, '99')
# Determine sale status
status = 'pending_payment' if is_pending else 'completed'
# Create sale record (with currency)
cur.execute("""
INSERT INTO sales
@@ -338,14 +350,14 @@ def process_sale(conn, sale_data):
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, device_id, notes, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, totals['subtotal'], totals['discount_total'],
totals['tax_total'], totals['total'], amount_paid, change_given,
metodo_pago_sat, forma_pago_sat,
_safe_g('device_id'), notes,
status, _safe_g('device_id'), notes,
currency, exchange_rate
))
sale_id, created_at = cur.fetchone()
@@ -404,23 +416,26 @@ def process_sale(conn, sale_data):
'subtotal': item['subtotal'],
})
# Record payment on cash register (cash movements for efectivo)
if register_id and payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
# Record payment on cash register (skip pending sales and zero-amount rows)
if not is_pending:
if register_id and payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
if amt <= 0:
continue
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
elif register_id and amount_paid > 0:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
elif register_id:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
# Update customer credit balance if credit sale
if sale_type == 'credit' and customer_id:
@@ -430,6 +445,16 @@ def process_sale(conn, sale_data):
WHERE id = %s
""", (totals['total'], customer_id))
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
# Audit log
log_action(conn, 'SALE', 'sale', sale_id,
new_value={
@@ -506,6 +531,8 @@ def process_sale(conn, sale_data):
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': sale_type,
@@ -526,6 +553,342 @@ def process_sale(conn, sale_data):
}
def create_remission_note(conn, data):
"""Create a counter remission note: reserve stock, no payment, pending status.
data: {
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int | null,
notes: str,
branch_id: int,
register_id: int | null (optional)
}
"""
cur = conn.cursor()
items = data.get('items', [])
customer_id = data.get('customer_id')
notes = data.get('notes')
branch_id = data.get('branch_id') or _safe_g('branch_id')
register_id = data.get('register_id')
employee_id = _safe_g('employee_id')
currency = data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'):
raise ValueError("Unsupported currency")
exchange_rate = float(data.get('exchange_rate') or 1.0)
if currency != 'MXN' and not exchange_rate:
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
if not branch_id:
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
row = cur.fetchone()
branch_id = row[0] if row else None
if not branch_id:
raise ValueError("No hay sucursal activa disponible")
if not items:
raise ValueError("No items in remission note")
inv_ids = [item.get('inventory_id') for item in items]
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id, retail_price
FROM inventory
WHERE id = ANY(%s) AND is_active = true
ORDER BY id
FOR UPDATE
""", (inv_ids,))
inv_rows = {r[0]: r for r in cur.fetchall()}
stock_map = get_stock_bulk(conn, branch_id)
if branch_id:
cur.execute("""
SELECT inventory_id, stock
FROM inventory_stock
WHERE branch_id = %s AND inventory_id = ANY(%s)
FOR UPDATE
""", (branch_id, inv_ids))
stock_map = {r[0]: r[1] for r in cur.fetchall()}
enriched_items = []
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if qty <= 0:
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
inv = inv_rows.get(inv_id)
if not inv:
raise ValueError(f"Inventory item {inv_id} not found or inactive")
current_stock = stock_map.get(inv_id, 0)
unit_price = float(item.get('unit_price', inv[4]))
discount_pct = float(item.get('discount_pct', 0))
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
unit_cost = float(inv[3]) if inv[3] else 0
enriched_items.append({
'inventory_id': inv_id,
'part_number': inv[1],
'name': inv[2],
'quantity': qty,
'unit_price': unit_price,
'unit_cost': unit_cost,
'discount_pct': discount_pct,
'tax_rate': tax_rate,
'branch_id': inv[8],
'stock_before': current_stock,
})
totals = calculate_totals(enriched_items)
cur.execute("""
INSERT INTO sales
(branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, status, device_id, notes, currency, exchange_rate,
courier_id)
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
%s, %s, %s, %s, 0, 0, 'pending_payment',
%s, %s, %s, %s, %s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id,
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
_safe_g('device_id'), notes, currency, exchange_rate,
data.get('courier_id')
))
sale_id, created_at = cur.fetchone()
sale_items_data = []
for item in totals['items']:
inv = inv_rows.get(item['inventory_id'])
retail_price = inv[9] if inv else None
sale_items_data.append((
sale_id, item['inventory_id'], item['part_number'], item['name'],
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal'],
retail_price, currency, exchange_rate
))
cur.executemany("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, discount_pct, discount_amount,
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", sale_items_data)
sale_items = []
for item in totals['items']:
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
remaining_after = stock_before - item['quantity']
inventory_record_reservation(
conn,
item['inventory_id'],
item.get('branch_id', branch_id),
item['quantity'],
sale_id=sale_id,
cost_at_time=item.get('unit_cost'),
remaining_stock=remaining_after
)
sale_items.append({
'inventory_id': item['inventory_id'],
'part_number': item['part_number'],
'name': item['name'],
'quantity': item['quantity'],
'unit_price': item['unit_price'],
'unit_cost': item.get('unit_cost', 0),
'discount_pct': item['discount_pct'],
'discount_amount': item['discount_amount'],
'tax_rate': item['tax_rate'],
'tax_amount': item['tax_amount'],
'subtotal': item['subtotal'],
})
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
new_value={
'total': totals['total'],
'items_count': len(sale_items),
'customer_id': customer_id,
})
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
cur.close()
return {
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': 'counter_remission',
'payment_method': 'remission',
'subtotal': totals['subtotal'],
'discount_total': totals['discount_total'],
'tax_total': totals['tax_total'],
'total': totals['total'],
'amount_paid': 0.0,
'change_given': 0.0,
'status': 'pending_payment',
'courier_id': data.get('courier_id'),
'items': sale_items,
'created_at': str(created_at),
'currency': currency,
'exchange_rate': exchange_rate,
}
def pay_pending_sale(conn, sale_id, data):
"""Pay a pending counter remission note and convert it into a completed sale.
data: {
payment_method: str,
amount_paid: float,
payment_details: [{method, amount, reference}],
register_id: int,
reference: str
}
"""
cur = conn.cursor()
cur.execute("""
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate
FROM sales WHERE id = %s
FOR UPDATE
""", (sale_id,))
row = cur.fetchone()
if not row:
raise ValueError("Sale not found")
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate) = row
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
if status != 'pending_payment':
raise ValueError("La nota no esta pendiente de pago")
payment_method = data.get('payment_method', 'efectivo')
sale_type = 'cash'
amount_paid = float(data.get('amount_paid', 0))
payment_details = data.get('payment_details', [])
register_id = data.get('register_id')
reference = data.get('reference', '')
if register_id:
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
reg = cur.fetchone()
if not reg or reg[0] != 'open':
raise ValueError("Cash register is not open")
totals = {'total': float(total)}
change_given = 0.0
if payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
forma_pago_sat = forma_pago_map.get(payment_method, '99')
cur.execute("""
UPDATE sales
SET status = 'completed',
sale_type = %s,
payment_method = %s,
amount_paid = %s,
change_given = %s,
register_id = COALESCE(%s, register_id),
metodo_pago_sat = 'PUE',
forma_pago_sat = %s
WHERE id = %s
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
if payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
else:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
# Release reservation and record actual sale movement
cur.execute("""
SELECT inventory_id, quantity, unit_cost
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
for inv_id, qty, cost in cur.fetchall():
inventory_release_reservation(
conn, inv_id, branch_id, qty, sale_id=sale_id,
notes='Pago de nota de remision'
)
inventory_record_sale(
conn, inv_id, branch_id, qty,
sale_id=sale_id, cost_at_time=float(cost) if cost else None
)
# Accounting (non-blocking)
try:
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
cur.execute("""
SELECT COALESCE(SUM(unit_cost * quantity), 0)
FROM sale_items WHERE sale_id = %s
""", (sale_id,))
cost_total = float(cur.fetchone()[0] or 0)
record_sale_entry(conn, {
'id': sale_id,
'sale_type': sale_type,
'total': total_mxn,
'tax_total': tax_mxn,
'subtotal': sub_mxn,
'cost_total': cost_total,
'payment_method': payment_method,
})
except Exception:
pass
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
old_value={'status': 'pending_payment', 'total': totals['total']},
new_value={'status': 'completed', 'payment_method': payment_method})
cur.close()
return {
'id': sale_id,
'status': 'completed',
'payment_method': payment_method,
'amount_paid': amount_paid,
'change_given': change_given,
'total': totals['total'],
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
}
def cancel_sale(conn, sale_id, reason):
"""Cancel a sale: validate permissions, reverse inventory, update credit.
@@ -567,15 +930,15 @@ def cancel_sale(conn, sale_id, reason):
if s_status == 'cancelled':
raise ValueError("Sale is already cancelled")
# Permission check: cashiers can only cancel own sales within 30 min
# Permission check: non-admin employees can only cancel their own docs within 30 min
role = _safe_g('employee_role', 'cashier')
emp_id = _safe_g('employee_id')
if role == 'cashier':
if role not in ('owner', 'admin'):
if s_emp_id != emp_id:
raise ValueError("Cashiers can only cancel their own sales")
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
if datetime.utcnow() - s_created > timedelta(minutes=30):
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
# Get sale items for inventory reversal
cur.execute("""
@@ -584,14 +947,23 @@ def cancel_sale(conn, sale_id, reason):
""", (sale_id,))
sale_items = cur.fetchall()
# Reverse inventory: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion venta #{sale_id}: {reason}"
)
if s_status == 'pending_payment':
# Pending remission: release reservation
for inv_id, qty, cost in sale_items:
inventory_release_reservation(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
)
else:
# Completed sale: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion venta #{sale_id}: {reason}"
)
# Update sale status
cur.execute("""
@@ -627,7 +999,7 @@ def cancel_sale(conn, sale_id, reason):
# Audit log
log_action(conn, 'CANCEL', 'sale', sale_id,
old_value={'status': 'completed', 'total': float(s_total)},
old_value={'status': s_status, 'total': float(s_total)},
new_value={'status': 'cancelled', 'reason': reason})
# Push notification to owner/admin (best-effort, non-blocking)

View File

@@ -8,15 +8,43 @@ from datetime import datetime
from services import inventory_engine
# Rached workshop statuses (applies to all tenants).
ORDER_STATUSES = [
'por_revisar',
'en_revision',
'revisada',
'cotizada',
'por_autorizar',
'autorizada',
'autorizacion_parcial',
'en_reparacion',
'reparada',
'por_entregar',
'entregado',
'por_enviar',
'enviado',
'por_facturar',
'facturada',
'por_recolectar',
'cancelada',
]
TERMINAL_STATUSES = {'entregado', 'facturada', 'cancelada'}
# Allow moving from any non-terminal status to any other non-terminal status,
# plus cancellation. Terminal statuses cannot change.
VALID_TRANSITIONS = {
'received': ['diagnosis', 'cancelled'],
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
'waiting_parts': ['repair', 'cancelled'],
'repair': ['ready', 'cancelled'],
'ready': ['delivered', 'cancelled'],
'delivered': [],
'cancelled': [],
status: [s for s in ORDER_STATUSES if s != status]
for status in ORDER_STATUSES
}
for terminal in TERMINAL_STATUSES:
VALID_TRANSITIONS[terminal] = []
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
_LEGACY_STATUSES = {
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
}
for legacy in _LEGACY_STATUSES:
VALID_TRANSITIONS.setdefault(legacy, [])
def _generate_order_number(conn):
@@ -45,6 +73,18 @@ def _generate_order_number(conn):
return f"{prefix}{new_num}"
_VALID_DELIVERY_METHODS = {'pickup', 'delivery', 'courier'}
def _normalize_delivery(data):
"""Restrict delivery_method to allowed values and clear courier when not applicable."""
dm = data.get('delivery_method')
if dm not in _VALID_DELIVERY_METHODS:
data['delivery_method'] = None
if data.get('delivery_method') not in ('delivery', 'courier'):
data['courier_id'] = None
def create_service_order(conn, data):
"""Create a new service order.
@@ -52,9 +92,10 @@ def create_service_order(conn, data):
customer_id, vehicle_id, branch_id, priority,
reception_notes, estimated_cost, estimated_completion,
employee_id, mileage_in, fuel_level, created_by,
delivery_method, courier_id, is_direct
delivery_method, courier_id, is_direct, requires_invoice
}
"""
_normalize_delivery(data)
cur = conn.cursor()
order_number = _generate_order_number(conn)
@@ -62,19 +103,23 @@ def create_service_order(conn, data):
INSERT INTO service_orders
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
priority, reception_notes, estimated_cost, estimated_completion,
employee_id, mileage_in, fuel_level, created_by,
delivery_method, courier_id, is_direct)
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s)
employee_id, mechanic_name, mileage_in, fuel_level, created_by,
delivery_method, courier_id, is_direct, requires_invoice,
workshop_name, customer_address, customer_phone, vehicle_description)
VALUES (%s, %s, %s, %s, %s, 'por_revisar', %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
data.get('vehicle_id'), order_number,
data.get('priority', 'normal'), data.get('reception_notes'),
data.get('estimated_cost'), data.get('estimated_completion'),
data.get('employee_id'), data.get('mileage_in'),
data.get('fuel_level'), data.get('created_by'),
data.get('delivery_method'), data.get('courier_id'), data.get('is_direct', False),
data.get('employee_id'), data.get('mechanic_name'),
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'),
data.get('delivery_method'), data.get('courier_id'),
data.get('is_direct', False), data.get('requires_invoice', False),
data.get('workshop_name'), data.get('customer_address'),
data.get('customer_phone'), data.get('vehicle_description'),
))
so_id = cur.fetchone()[0]
@@ -82,7 +127,7 @@ def create_service_order(conn, data):
cur.execute("""
INSERT INTO service_order_status_history
(service_order_id, new_status, changed_by, notes)
VALUES (%s, 'received', %s, 'Orden creada')
VALUES (%s, 'por_revisar', %s, 'Orden creada')
""", (so_id, data.get('created_by')))
conn.commit()
@@ -95,7 +140,7 @@ def get_service_order(conn, so_id):
cur.execute("""
SELECT so.id, so.order_number, so.status, so.priority,
so.customer_id, c.name as customer_name, c.phone as customer_phone,
c.address as customer_address,
c.address as customer_address, c.price_tier as customer_price_tier,
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone,
so.reception_notes, so.diagnosis_notes, so.repair_notes,
@@ -103,10 +148,12 @@ def get_service_order(conn, so_id):
so.estimated_completion, so.actual_completion, so.delivered_at,
so.mileage_in, so.mileage_out, so.fuel_level,
so.employee_id, e.name as employee_name,
so.mechanic_name,
so.created_by, creator.name as created_by_name,
so.created_at, so.updated_at,
so.delivery_method, so.courier_id, co.name as courier_name, so.is_direct,
so.sale_id
so.requires_invoice, so.sale_id,
so.workshop_name, so.customer_address, so.customer_phone, so.vehicle_description
FROM service_orders so
LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
@@ -124,28 +171,33 @@ def get_service_order(conn, so_id):
so = {
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
'customer_address': row[7],
'vehicle_id': row[8], 'vehicle_plate': row[9], 'vehicle_make': row[10], 'vehicle_model': row[11],
'branch_id': row[12], 'branch_name': row[13], 'branch_address': row[14], 'branch_phone': row[15],
'reception_notes': row[16], 'diagnosis_notes': row[17],
'repair_notes': row[18], 'delivery_notes': row[19],
'estimated_cost': float(row[20]) if row[20] else None,
'final_cost': float(row[21]) if row[21] else None,
'estimated_completion': str(row[22]) if row[22] else None,
'actual_completion': str(row[23]) if row[23] else None,
'delivered_at': str(row[24]) if row[24] else None,
'mileage_in': row[25], 'mileage_out': row[26], 'fuel_level': row[27],
'employee_id': row[28], 'employee_name': row[29],
'created_by': row[30], 'created_by_name': row[31],
'created_at': str(row[32]), 'updated_at': str(row[33]),
'delivery_method': row[34], 'courier_id': row[35], 'courier_name': row[36],
'is_direct': bool(row[37]) if row[37] is not None else False,
'sale_id': row[38],
'customer_address': row[7], 'customer_price_tier': row[8],
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12],
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16],
'reception_notes': row[17], 'diagnosis_notes': row[18],
'repair_notes': row[19], 'delivery_notes': row[20],
'estimated_cost': float(row[21]) if row[21] else None,
'final_cost': float(row[22]) if row[22] else None,
'estimated_completion': str(row[23]) if row[23] else None,
'actual_completion': str(row[24]) if row[24] else None,
'delivered_at': str(row[25]) if row[25] else None,
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28],
'employee_id': row[29], 'employee_name': row[30],
'mechanic_name': row[31],
'created_by': row[32], 'created_by_name': row[33],
'created_at': str(row[34]), 'updated_at': str(row[35]),
'delivery_method': row[36], 'courier_id': row[37], 'courier_name': row[38],
'is_direct': bool(row[39]) if row[39] is not None else False,
'requires_invoice': bool(row[40]) if row[40] is not None else False,
'sale_id': row[41],
'workshop_name': row[42], 'customer_address': row[43],
'customer_phone': row[44], 'vehicle_description': row[45],
}
# Items
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
mechanic_id, observations
FROM service_order_items
WHERE service_order_id = %s
ORDER BY id
@@ -161,6 +213,7 @@ def get_service_order(conn, so_id):
'unit_cost': float(r[5]) if r[5] else None,
'unit_price': price,
'status': r[7], 'notes': r[8],
'mechanic_id': r[9], 'observations': r[10],
})
total_parts += qty * price
@@ -238,8 +291,11 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
where_clauses.append("so.is_direct = %s")
params.append(is_direct)
if q:
where_clauses.append("(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s)")
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
where_clauses.append(
"(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s OR "
"so.workshop_name ILIKE %s OR so.vehicle_description ILIKE %s)"
)
params.extend([f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%'])
where_clauses.append("so.is_deleted = false")
where = " AND ".join(where_clauses)
@@ -259,14 +315,18 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
so.branch_id, b.name as branch_name,
so.estimated_cost, so.final_cost,
so.delivery_method, co.name as courier_name, so.is_direct,
so.sale_id, so.created_at,
creator.name as created_by_name
so.requires_invoice, so.sale_id, so.created_at,
creator.name as created_by_name,
so.employee_id, mech.name as employee_name,
so.mechanic_name,
so.workshop_name, so.vehicle_description
FROM service_orders so
LEFT JOIN customers c ON so.customer_id = c.id
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
LEFT JOIN branches b ON so.branch_id = b.id
LEFT JOIN couriers co ON so.courier_id = co.id
LEFT JOIN employees creator ON so.created_by = creator.id
LEFT JOIN employees mech ON so.employee_id = mech.id
WHERE {where}
ORDER BY
CASE so.priority
@@ -292,8 +352,12 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
'final_cost': final,
'delivery_method': r[14], 'courier_name': r[15],
'is_direct': bool(r[16]) if r[16] is not None else False,
'sale_id': r[17], 'created_at': str(r[18]),
'created_by_name': r[19],
'requires_invoice': bool(r[17]) if r[17] is not None else False,
'sale_id': r[18], 'created_at': str(r[19]),
'created_by_name': r[20],
'employee_id': r[21], 'employee_name': r[22],
'mechanic_name': r[23],
'workshop_name': r[24], 'vehicle_description': r[25],
'total': round(final or estimated, 2),
'paid': 0.0, # to be computed if needed
})
@@ -323,9 +387,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
# Update status
extra_sets = []
extra_vals = []
if new_status == 'ready':
if new_status == 'reparada':
extra_sets.append("actual_completion = NOW()")
if new_status == 'delivered':
if new_status == 'entregado':
extra_sets.append("delivered_at = NOW()")
extra_sets.append("delivered_by = %s")
extra_vals.append(changed_by)
@@ -354,14 +418,16 @@ def add_item(conn, so_id, item_data):
cur = conn.cursor()
cur.execute("""
INSERT INTO service_order_items
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
mechanic_id, observations)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
item_data.get('name'), item_data.get('quantity', 1),
item_data.get('unit_cost'), item_data.get('unit_price'),
item_data.get('status', 'pending'), item_data.get('notes'),
item_data.get('mechanic_id'), item_data.get('observations'),
))
item_id = cur.fetchone()[0]
conn.commit()
@@ -371,7 +437,7 @@ def add_item(conn, so_id, item_data):
def update_item(conn, item_id, data):
cur = conn.cursor()
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations']
sets = []
vals = []
for field in allowed:
@@ -453,12 +519,14 @@ def remove_labor(conn, labor_id):
def update_service_order(conn, so_id, data):
"""Update general service order fields."""
_normalize_delivery(data)
cur = conn.cursor()
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
'reception_notes', 'diagnosis_notes', 'repair_notes',
'delivery_notes', 'estimated_cost', 'estimated_completion',
'employee_id', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
'delivery_method', 'courier_id', 'is_direct']
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
sets = []
vals = []
for field in allowed:
@@ -491,15 +559,15 @@ def get_kanban_summary(conn, branch_id=None):
GROUP BY status
""", params)
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
for r in cur.fetchall():
summary[r[0]] = r[1]
# Overdue orders (estimated_completion passed and not ready/delivered)
# Overdue orders (estimated_completion passed and not delivered/invoiced)
cur.execute(f"""
SELECT count(*) FROM service_orders
WHERE estimated_completion < NOW()
AND status NOT IN ('ready', 'delivered', 'cancelled')
AND status NOT IN ('entregado', 'facturada', 'cancelada')
AND is_deleted = false
{branch_filter}
""", params)
@@ -812,6 +880,152 @@ def convert_to_sale(conn, so_id, sale_data, employee_id=None):
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
def convert_to_remission(conn, so_id, sale_data, employee_id=None):
"""Convert a service order into a counter remission note (pending payment).
sale_data keys:
register_id: int (optional)
notes: str (optional)
Returns dict with sale_id, total, items_count.
"""
cur = conn.cursor()
so = get_service_order(conn, so_id)
if not so:
cur.close()
raise ValueError("Service order not found")
if so["status"] == "cancelled":
cur.close()
raise ValueError("Cannot convert a cancelled service order")
if so.get("sale_id"):
cur.close()
raise ValueError("Service order already converted")
branch_id = so["branch_id"]
customer_id = so["customer_id"]
# Build sale items from SO parts and labor
sale_items = []
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
qty = int(item.get("quantity", 1))
unit_price = float(item.get("unit_price") or 0)
unit_cost = float(item.get("unit_cost") or 0)
sale_items.append({
"inventory_id": item.get("inventory_id"),
"part_number": item.get("part_number") or "PART",
"name": item.get("name") or "Refaccion",
"quantity": qty,
"unit_price": unit_price,
"unit_cost": unit_cost,
"tax_rate": 0.16,
})
for labor in so.get("labor", []):
if labor.get("status") == "cancelled":
continue
sale_items.append({
"inventory_id": None,
"part_number": "SERV",
"name": labor.get("description") or "Mano de obra",
"quantity": 1,
"unit_price": float(labor.get("total_cost") or 0),
"unit_cost": 0,
"tax_rate": 0.16,
})
if not sale_items:
cur.close()
raise ValueError("No items or labor to invoice")
subtotal = 0.0
tax_total = 0.0
for item in sale_items:
item_subtotal = item["quantity"] * item["unit_price"]
item_tax = item_subtotal * item["tax_rate"]
item["subtotal"] = item_subtotal
item["tax_amount"] = item_tax
subtotal += item_subtotal
tax_total += item_tax
total = subtotal + tax_total
register_id = sale_data.get("register_id")
notes = sale_data.get("notes") or f"Nota de remision desde orden {so['order_number']}"
cur.execute(
"""
INSERT INTO sales
(branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, notes)
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission', %s, %s, %s, %s, %s, %s, 'PPD', '99', 'pending_payment', %s)
RETURNING id, created_at
""",
(
branch_id,
customer_id,
employee_id,
register_id,
subtotal,
0,
tax_total,
total,
0,
0,
notes,
),
)
sale_id, _created_at = cur.fetchone()
for item in sale_items:
cur.execute(
"""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
sale_id,
item["inventory_id"],
item["part_number"],
item["name"],
item["quantity"],
item["unit_price"],
item["unit_cost"],
item["tax_rate"],
item["tax_amount"],
item["subtotal"],
),
)
# Reserve inventory for parts
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
inventory_id = item.get("inventory_id")
qty = int(item.get("quantity", 0))
if inventory_id and qty > 0:
inventory_engine.record_reservation(
conn,
inventory_id,
branch_id,
qty,
sale_id=sale_id,
cost_at_time=float(item.get("unit_cost") or 0),
notes=f"Reserva nota de remision orden {so['order_number']}"
)
# Link order to sale (the remission note)
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
conn.commit()
cur.close()
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
def assign_mechanic(conn, so_id, employee_id):
"""Assign a mechanic/technician to a service order."""
cur = conn.cursor()

View File

@@ -713,18 +713,23 @@
}
.pago-tabs {
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: var(--space-2);
padding: var(--space-4) var(--space-6);
border-bottom: none;
}
.pago-tab {
padding: var(--space-3) var(--space-5); font-family: var(--font-body);
font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
background: transparent; border: none; color: var(--color-text-muted);
cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: var(--transition-fast);
display: flex; align-items: center; gap: var(--space-2);
padding: var(--space-3) var(--space-2); font-family: var(--font-body);
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius-md); color: var(--color-text-muted);
cursor: pointer; transition: var(--transition-fast);
display: flex; align-items: center; justify-content: center;
text-align: center; min-height: 44px;
}
.pago-tab:hover { color: var(--color-text-primary); }
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
.tab-content { padding: var(--space-6); display: none; }
.tab-content.active { display: block; }

View File

@@ -0,0 +1,195 @@
/* Remission notes page — matches the Nexus POS design system */
/* Scrollable page content area */
.page-content {
flex: 1;
overflow-y: auto;
padding: var(--space-5) var(--space-6);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.page-content::-webkit-scrollbar { width: 6px; }
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
/* Filters card */
.filters-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-5);
box-shadow: var(--shadow-sm);
}
[data-theme="modern"] .filters-card {
background: var(--color-bg-overlay);
}
/* Date input reuse select-filter styling */
.select-filter[type="date"] {
padding: 0 var(--space-3);
}
/* Status badges for remission notes */
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
/* Action buttons in table */
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-2);
height: 28px;
border-radius: var(--radius-md);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
cursor: pointer;
border: 1px solid transparent;
transition: var(--transition-fast);
white-space: nowrap;
}
.action-btn svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.action-btn--ghost {
background: var(--btn-ghost-bg);
color: var(--btn-ghost-text);
border-color: var(--btn-ghost-border);
}
.action-btn--ghost:hover {
background: var(--color-surface-2);
border-color: var(--color-border-strong);
color: var(--color-text-primary);
}
.action-btn--primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-border);
}
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
/* Empty state */
.empty-state {
padding: var(--space-10) var(--space-6);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: var(--z-modal, 1050);
padding: var(--space-4);
}
.modal-overlay.is-open { display: flex; }
.modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
}
[data-theme="modern"] .modal {
background: var(--color-bg-overlay);
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-family: var(--font-heading);
font-size: var(--text-h6);
font-weight: var(--heading-weight-primary);
color: var(--color-text-primary);
margin: 0;
}
.modal__close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.25rem;
cursor: pointer;
line-height: 1;
padding: var(--space-1);
}
.modal__close:hover { color: var(--color-text-primary); }
.modal__body {
padding: var(--space-5);
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
}
/* Ticket preview (monospace) */
.ticket-preview {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-primary);
}
.ticket-preview__center { text-align: center; }
.ticket-preview__bold { font-weight: 700; }
.ticket-preview__line {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__divider {
border-top: 1px dashed var(--color-border-strong);
margin: var(--space-3) 0;
}
.ticket-preview__items { margin: var(--space-3) 0; }
.ticket-preview__item {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__footer {
text-align: center;
margin-top: var(--space-3);
}
/* Responsive */
@media (max-width: 1024px) {
.page-content {
padding: var(--space-4);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box, .select-filter {
max-width: 100%;
width: 100%;
}
.toolbar__spacer { display: none; }
}

View File

@@ -947,6 +947,30 @@ body {
.badge--delivered { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--cancelled { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--pending { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
.badge--por_revisar { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.badge--en_revision { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--revisada { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--cotizada { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
.badge--autorizada { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.badge--autorizacion_parcial { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--en_reparacion { background: rgba(245, 166, 35, 0.18); color: #d97706; }
.badge--reparada { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
.badge--por_entregar { background: rgba(59, 130, 246, 0.16); color: #2563eb; }
.badge--entregado { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--por_enviar { background: rgba(99, 102, 241, 0.16); color: #4f46e5; }
.badge--enviado { background: rgba(14, 165, 233, 0.12); color: #0ea5e9; }
.badge--por_facturar { background: rgba(168, 85, 247, 0.12); color: #a855f7; }
.badge--facturada { background: rgba(236, 72, 153, 0.12); color: #ec4899; }
.badge--por_recolectar { background: rgba(100, 116, 139, 0.12); color: #64748b; }
.badge--cancelada { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--revisando { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--revisado { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--cotizado { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
.badge--autorizado { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.badge--reparado { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
.badge--cancelado { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--normal { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
.badge--high { background: rgba(245, 166, 35, 0.12); color: #f5a623; }

View File

@@ -182,10 +182,85 @@
permissions: payload.permissions || []
};
// ─── Restrict workshop/mechanic users to the workshop view only ───
if ((role === 'workshop' || role === 'mechanic') && window.location.pathname !== '/pos/workshop') {
window.location.replace('/pos/workshop');
return;
// ─── Page guard based on role + permissions ───
function isPageAllowed(pagePath, userRole, userPerms) {
if (userRole === 'owner' || userRole === 'admin') return true;
// Workshop/mechanic accounts always see Taller; extra modules depend on permissions.
if (userRole === 'workshop' || userRole === 'mechanic') {
var allowed = ['/pos/workshop'];
var permMap = {
'customers.view': '/pos/customers',
'inventory.view': '/pos/inventory',
'catalog.view': '/pos/catalog',
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'config.view': '/pos/config',
'config.edit': '/pos/config'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
allowed.push(permMap[p]);
}
}
return allowed.indexOf(pagePath) !== -1;
}
// Counter: fixed module set (no dashboard).
if (userRole === 'counter') {
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/reports'].indexOf(pagePath) !== -1;
}
// Cashier: fixed module set (no dashboard).
if (userRole === 'cashier') {
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/invoicing','/pos/reports'].indexOf(pagePath) !== -1;
}
// Any other role (accountant, warehouse, sales, etc.) keeps the previous permissive behavior.
return true;
}
function enforcePageGuard(userRole, userPerms) {
if (isPageAllowed(path, userRole, userPerms)) return true;
var fallback = '/pos/catalog';
if (userRole === 'counter' || userRole === 'cashier') fallback = '/pos/sale';
else if (userRole === 'workshop' || userRole === 'mechanic') fallback = '/pos/workshop';
window.location.replace(fallback);
return false;
}
// ─── Refresh permissions/token from server before enforcing the guard ───
// This makes permission changes effective without requiring a full re-login.
try {
fetch('/pos/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) {
if (r.ok) return r.json();
return null;
}).then(function(data) {
if (data && data.token) {
localStorage.setItem('pos_token', data.token);
localStorage.setItem('pos_employee', JSON.stringify(data.employee));
token = data.token;
window.POS_USER.token = data.token;
window.POS_USER.permissions = data.permissions || [];
window.POS_USER.branchId = data.employee.branch_id;
}
if (!enforcePageGuard(window.POS_USER.role, window.POS_USER.permissions)) return;
if (typeof window.renderSidebar === 'function') {
window.renderSidebar(window.POS_USER.modules || JSON.parse(localStorage.getItem('pos_modules') || '{}'));
}
}).catch(function() {
enforcePageGuard(role, payload.permissions || []);
});
} catch(e) {
enforcePageGuard(role, payload.permissions || []);
}
// ─── Preload enabled modules for sidebar filtering ───

View File

@@ -9,6 +9,8 @@ const Config = (() => {
// Cache for branches (used by employee modal selector)
let _branches = [];
let _rolePermissions = {};
let _availablePermissions = [];
function token() {
return localStorage.getItem('pos_token') || '';
@@ -108,6 +110,7 @@ const Config = (() => {
owner: 'Dueno',
admin: 'Admin',
cashier: 'Cajero',
counter: 'Mostrador',
warehouse: 'Almacenista',
accountant: 'Contador',
workshop: 'Taller',
@@ -118,6 +121,7 @@ const Config = (() => {
owner: 'badge--owner',
admin: 'badge--blue',
cashier: 'badge--green',
counter: 'badge--gray',
warehouse: 'badge--yellow',
accountant: 'badge--purple',
workshop: 'badge--orange',
@@ -961,6 +965,15 @@ const Config = (() => {
} catch (e) {
console.error('Config.loadModules:', e);
}
try {
var res2 = await fetch(API + '/counter-remission', { headers: headers() });
if (!res2.ok) return;
var d2 = await res2.json();
var cbCr = document.getElementById('cfg-module-counter-remission');
if (cbCr) cbCr.checked = d2.enabled === true;
} catch (e) {
console.error('Config.loadCounterRemission:', e);
}
}
async function saveModules() {
@@ -968,7 +981,8 @@ const Config = (() => {
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
if (!cbWa && !cbMp && !cbMeli && !cbCat) return;
var cbCr = document.getElementById('cfg-module-counter-remission');
if (!cbWa && !cbMp && !cbMeli && !cbCat && !cbCr) return;
try {
var data = {
whatsapp: cbWa ? cbWa.checked : true,
@@ -986,12 +1000,112 @@ const Config = (() => {
throw new Error(err.error || 'Save failed');
}
localStorage.setItem('pos_modules', JSON.stringify(data));
if (cbCr) {
var res2 = await fetch(API + '/counter-remission', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ enabled: cbCr.checked })
});
if (!res2.ok) {
var err2 = await res2.json().catch(function() { return { error: res2.statusText }; });
throw new Error(err2.error || 'Save failed');
}
}
toast('Módulos actualizados');
} catch (e) {
toast(e.message, 'error');
}
}
// -------------------------------------------------------------------------
// Tab navigation
// -------------------------------------------------------------------------
function switchTab(tab) {
document.querySelectorAll('.cfg-tab-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.settings-section[data-tab]').forEach(function(sec) {
var isActive = sec.dataset.tab === tab;
sec.classList.toggle('active', isActive);
sec.style.display = isActive ? '' : 'none';
});
try { localStorage.setItem('pos_config_tab', tab); } catch(e) {}
}
// -------------------------------------------------------------------------
// Role permissions editor
// -------------------------------------------------------------------------
async function loadRolePermissions() {
try {
var res = await fetch(API + '/role-permissions', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_rolePermissions = data.roles || {};
_availablePermissions = data.available || [];
renderRolePermissions();
} catch (e) {
console.error('Config.loadRolePermissions:', e);
}
}
function renderRolePermissions() {
var container = document.getElementById('role-permissions-container');
var roleSel = document.getElementById('cfg-perm-role');
if (!container || !roleSel) return;
var role = roleSel.value;
if (!role) {
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>';
return;
}
var current = _rolePermissions[role] || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-4);">';
_availablePermissions.forEach(function(group) {
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">' + escapeHtml(group.module) + '</h4>';
group.permissions.forEach(function(p) {
var checked = current.indexOf(p.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-perm-key="' + escapeHtml(p.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(p.label) + '</span>';
html += '</label>';
});
html += '</div>';
});
html += '</div>';
container.innerHTML = html;
}
async function saveRolePermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('role-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var selected = [];
document.querySelectorAll('#role-permissions-container input[data-perm-key]').forEach(function(cb) {
if (cb.checked) selected.push(cb.dataset.permKey);
});
var payload = { roles: {} };
payload.roles[role] = selected;
try {
var res = await fetch(API + '/role-permissions', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload)
});
var data = await res.json().catch(function() { return { error: res.statusText }; });
if (!res.ok) throw new Error(data.error || 'Error al guardar');
_rolePermissions[role] = selected;
if (status) status.textContent = 'Permisos guardados y aplicados a empleados existentes';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -1044,6 +1158,11 @@ const Config = (() => {
});
}
// Show permissions tab only for owner/admin
var isAdmin = user.role === 'owner' || user.role === 'admin';
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
// Load real data in parallel
loadBranches();
loadEmployees();
@@ -1053,6 +1172,15 @@ const Config = (() => {
loadAllowedBrands();
loadModules();
loadReceiptConfig();
if (isAdmin) loadRolePermissions();
// Activate default or stored tab
var defaultTab = 'general';
try {
var storedTab = localStorage.getItem('pos_config_tab');
if (storedTab) defaultTab = storedTab;
} catch(e) {}
switchTab(defaultTab);
}
document.addEventListener('DOMContentLoaded', init);
@@ -1074,7 +1202,8 @@ const Config = (() => {
loadVehicleCompatSource, saveVehicleCompatSource,
loadModules, saveModules,
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
openModal, closeModal, openBranchModal, editBranch
openModal, closeModal, openBranchModal, editBranch,
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions
};
})();

View File

@@ -10,6 +10,7 @@ var I18N = {
'catalog': 'Catalogo',
'inventory': 'Inventario',
'diagrams': 'Diagramas',
'remission_notes': 'Notas de Remisión',
'customers': 'Clientes',
'invoicing': 'Facturacion',
'accounting': 'Contabilidad',
@@ -166,6 +167,7 @@ var I18N = {
'catalog': 'Catalog',
'inventory': 'Inventory',
'diagrams': 'Diagrams',
'remission_notes': 'Remission Notes',
'customers': 'Customers',
'invoicing': 'Invoicing',
'accounting': 'Accounting',

View File

@@ -21,6 +21,19 @@
var userRole = (user.role || '').toLowerCase();
var userPerms = user.permissions || [];
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
var canCreateItem = userRole === 'owner' || userRole === 'admin' || userRole === 'counter' || userRole === 'cashier' || userRole === 'warehouse' || userPerms.indexOf('inventory.create') !== -1;
var canEditItem = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
var canImportItems = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
// Hide toolbar actions the user is not allowed to use
(function applyInventoryPermissions() {
var headerNew = document.getElementById('btnHeaderNewProduct');
var stockNew = document.getElementById('btnStockNewProduct');
var headerImport = document.getElementById('btnHeaderImport');
if (headerNew) headerNew.style.display = canCreateItem ? '' : 'none';
if (stockNew) stockNew.style.display = canCreateItem ? '' : 'none';
if (headerImport) headerImport.style.display = canImportItems ? '' : 'none';
})();
// Load compatibility source setting
(function loadCompatSource() {
@@ -186,11 +199,11 @@
'<td>' + esc(it.location) + '</td>' +
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') +
(canCreateItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' : '') +
(userPerms.indexOf('marketplace.manage') !== -1 || userRole === 'owner' || userRole === 'admin' ? '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' : '') +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
'</td></tr>';
}
@@ -544,6 +557,28 @@
}
window.submitBulkImport = submitBulkImport;
function downloadBulkImportTemplate() {
var headers = [
'numero_de_parte','nombre','marca','precio','cantidad','costo',
'sku_secundario','descripcion','categoria','fabricante','modelo','anio','motor','codigo_motor'
];
var example = [
'EJ-001','Filtro de aceite','ACDelco','150.00','10','90.00',
'EJ001-ALT','Filtro para sedan','Filtros','Nissan','Sentra','2020','1.8','MR18DE'
];
var csv = [headers.join(','), example.join(',')].join('\n');
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'plantilla_inventario.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
window.downloadBulkImportTemplate = downloadBulkImportTemplate;
// =====================================================================
// PURCHASE / ENTRADA (purchaseModal)
// =====================================================================

View File

@@ -83,7 +83,12 @@
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
localStorage.setItem('pos_tenant_id', tenantId);
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
window.location.href = '/pos/catalog';
var role = (result.data.employee.role || '').toLowerCase();
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
window.location.href = '/pos/workshop';
} else {
window.location.href = '/pos/catalog';
}
})
.catch(function() {
errorEl.textContent = 'Error de conexion';

View File

@@ -30,7 +30,12 @@ const POS = (() => {
let canEditPrice = false;
let canCreateWorkshopOrder = false;
let canCreateLayaway = false;
let canCreateRemission = false;
let counterRemissionEnabled = false;
let currentPerms = [];
let receiptConfig = {};
let couriers = [];
let selectedCourierId = null;
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -38,6 +43,16 @@ const POS = (() => {
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Price based on customer tier: base price (price_1) with tier discount.
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
function priceForTier(basePrice, tier) {
const p = parseFloat(basePrice) || 0;
const t = parseInt(tier, 10) || 1;
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
return p;
}
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
@@ -66,6 +81,10 @@ const POS = (() => {
const el = document.querySelector(selector);
if (el) el.style.display = 'none';
}
function show(selector) {
const el = document.querySelector(selector);
if (el) el.style.display = '';
}
if (!canCancel) {
hide('#btnCancelSale');
hide('#fkeyEsc');
@@ -77,6 +96,25 @@ const POS = (() => {
hide('[title="Orden de servicio"]');
}
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
// Counter remission workflow
if (canCreateRemission) {
hide('#btnCobrar');
hide('.fkey[onclick="POS.checkout()"]');
hide('[onclick="POS.createLayaway()"]');
hide('[onclick="POS.saveQuotation()"]');
show('#btnRemission');
show('#courierSelectField');
} else {
hide('#btnRemission');
hide('#courierSelectField');
}
if (!currentPerms.includes('pos.sell')) {
hide('#btnPayRemission');
} else {
show('#btnPayRemission');
}
}
// ─── Init ────────────────────────────
@@ -87,14 +125,26 @@ const POS = (() => {
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
document.getElementById('branchName').textContent = payload.branch_name || '';
const perms = payload.permissions || [];
currentPerms = perms;
const employeeRole = payload.role || '';
canViewCost = perms.includes('pos.view_cost');
canCancel = perms.includes('pos.cancel');
canDiscount = perms.includes('pos.discount');
canEditPrice = perms.includes('config.edit_prices');
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
canCreateWorkshopOrder = perms.includes('workshop.edit');
canCreateLayaway = perms.includes('pos.sell');
employeeMaxDiscount = payload.max_discount_pct || 100;
// Counter remission feature
try {
const crCfg = await api('/pos/api/config/counter-remission');
counterRemissionEnabled = crCfg.enabled === true;
} catch (e) {
counterRemissionEnabled = false;
}
// Counter remission workflow applies only to the counter role.
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
// Show cost/margin columns and toggle button if permission
if (canViewCost) {
document.getElementById('thCost').style.display = '';
@@ -138,6 +188,7 @@ const POS = (() => {
// Load current register and receipt config
await loadRegister();
await loadReceiptConfig();
await loadCouriers();
// Setup event listeners
setupKeyboard();
@@ -157,8 +208,10 @@ const POS = (() => {
currentRegister = null;
document.getElementById('registerInfo').innerHTML =
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>';
// Force open register modal on first load
showOpenRegisterModal();
// Force open register modal on first load only for users who can sell
if (currentPerms.includes('pos.sell')) {
showOpenRegisterModal();
}
}
} catch (e) {
console.warn('Register check failed:', e);
@@ -174,6 +227,24 @@ const POS = (() => {
}
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('remissionCourier');
if (sel) {
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.addEventListener('change', () => {
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
});
}
} catch (e) {
console.warn('Could not load couriers:', e);
couriers = [];
}
}
function rc(key, fallback) {
const v = receiptConfig[key];
return v !== undefined && v !== null && v !== '' ? v : fallback;
@@ -570,26 +641,11 @@ const POS = (() => {
if (data.data.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
if (window.BarcodeFeedback) BarcodeFeedback.error();
} else if (data.data.length === 1 && q.length >= 8) {
// Auto-select single result on barcode scan (long codes)
const item = data.data[0];
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
addFromSearch(item, price);
input.value = '';
hideSearchResults();
return;
} else {
let html = '';
data.data.forEach(item => {
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
const price = priceForTier(item.price_1, tier);
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div>
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
@@ -684,7 +740,7 @@ const POS = (() => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => {
if (item.price_1 > 0) {
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
item.unit_price = priceForTier(item.price_1, tier);
}
});
}
@@ -880,9 +936,12 @@ const POS = (() => {
transferencia: 'refPayment',
tarjeta: 'refPayment',
mixto: 'mixedPayment',
credito: 'creditPayment',
cheque: 'chequePayment',
pendiente: 'pendingPayment',
};
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
const el = document.getElementById(id);
if (el) {
const isActive = el.id === tabs[method];
@@ -896,6 +955,12 @@ const POS = (() => {
const ref = document.getElementById('paymentRef');
if (ref) ref.focus();
}
if (method === 'cheque') {
const chequeAmount = document.getElementById('chequeAmount');
if (chequeAmount) chequeAmount.value = fmt(getTotal());
const chequeRef = document.getElementById('chequeRef');
if (chequeRef) chequeRef.focus();
}
}
function updateChange() {
@@ -933,6 +998,7 @@ const POS = (() => {
let amountPaid = 0;
let paymentDetails = [];
let reference = '';
let saleType = 'cash';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
@@ -952,6 +1018,20 @@ const POS = (() => {
}
});
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
} else if (paymentMethod === 'credito') {
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (total > available) {
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
return;
}
saleType = 'credit';
amountPaid = 0;
} else if (paymentMethod === 'cheque') {
amountPaid = total;
reference = document.getElementById('chequeRef').value.trim();
} else if (paymentMethod === 'pendiente') {
amountPaid = 0;
}
const saleData = {
@@ -964,7 +1044,7 @@ const POS = (() => {
})),
customer_id: currentCustomer ? currentCustomer.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
@@ -983,9 +1063,10 @@ const POS = (() => {
const convertData = {
register_id: currentRegister ? currentRegister.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
amount_paid: amountPaid,
payment_details: paymentDetails,
reference: reference,
};
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
method: 'POST',
@@ -1020,22 +1101,13 @@ const POS = (() => {
}
}
// ─── Credit Sale ─────────────────────
async function creditSale() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
if (!currentRegister) { alert('No hay caja abierta.'); return; }
// ─── Counter Remission Note ────────────
async function createRemissionNote() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
const total = getTotal();
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (currentCustomer.credit_limit > 0 && total > available) {
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
return;
}
}
const saleData = {
const noteData = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
@@ -1043,19 +1115,21 @@ const POS = (() => {
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer.id,
payment_method: 'credito',
sale_type: 'credit',
customer_id: currentCustomer ? currentCustomer.id : null,
notes: 'Nota de remision generada desde mostrador',
register_id: currentRegister ? currentRegister.id : null,
amount_paid: 0,
courier_id: selectedCourierId,
};
try {
const sale = await api('/pos/api/sales', {
const sale = await api('/pos/api/sales/remission', {
method: 'POST',
body: JSON.stringify(saleData),
body: JSON.stringify(noteData),
});
if (selectedCourierId) {
const courier = couriers.find(c => c.id === selectedCourierId);
sale.courier_name = courier ? courier.name : '';
}
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
@@ -1063,12 +1137,128 @@ const POS = (() => {
cart = [];
selectedRow = -1;
clearCustomer();
selectedCourierId = null;
const sel = document.getElementById('remissionCourier');
if (sel) sel.value = '';
renderCart();
showToast(`Nota de remision NR-${sale.id} generada`);
} catch (e) {
alert('Error: ' + e.message);
alert('Error al generar nota de remision: ' + e.message);
}
}
async function openPayRemissionModal() {
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
document.getElementById('payRemissionFolio').value = '';
document.getElementById('payRemissionDetail').innerHTML = '';
document.getElementById('payRemissionResult').innerHTML = '';
document.getElementById('payRemissionActions').style.display = 'none';
document.getElementById('payRemissionModal').classList.add('open');
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
}
function closePayRemissionModal() {
document.getElementById('payRemissionModal').classList.remove('open');
}
let pendingRemissionToPay = null;
async function searchRemissionToPay() {
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
if (!folio) { showToast('Ingresa un folio valido'); return; }
pendingRemissionToPay = null;
try {
const sale = await api('/pos/api/sales/' + folio);
if (sale.status !== 'pending_payment') {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
return;
}
pendingRemissionToPay = sale;
let itemsHtml = (sale.items || []).map(it => `
<div class="ticket-line">
<span class="qty">${it.quantity}</span>
<span class="name">${it.name || ''}</span>
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
</div>
`).join('');
document.getElementById('payRemissionDetail').innerHTML = `
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
<hr class="divider">
${itemsHtml}
`;
document.getElementById('payRemissionActions').style.display = '';
} catch (e) {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
}
}
async function confirmPayRemission() {
if (!pendingRemissionToPay) return;
const sale = pendingRemissionToPay;
const paymentMethod = document.getElementById('payRemissionMethod').value;
let amountPaid = parseFloat(sale.total);
let paymentDetails = [];
let reference = '';
if (paymentMethod === 'efectivo') {
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
if (received < sale.total) { alert('Monto insuficiente'); return; }
amountPaid = received;
} else if (paymentMethod === 'mixto') {
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
let sum = 0;
rows.forEach(row => {
const method = row.querySelector('select').value;
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
const ref = row.querySelectorAll('input')[1]?.value || '';
if (amount > 0) {
paymentDetails.push({ method, amount, reference: ref });
sum += amount;
}
});
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
amountPaid = sum;
} else {
reference = document.getElementById('payRemissionReference').value.trim();
}
try {
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: paymentMethod,
amount_paid: amountPaid,
payment_details: paymentDetails,
register_id: currentRegister ? currentRegister.id : null,
reference: reference,
}),
});
closePayRemissionModal();
showToast(`Nota NR-${sale.id} pagada`);
// Refresh sale object to print paid ticket
const updated = await api('/pos/api/sales/' + sale.id);
lastSaleId = updated.id;
lastSaleData = updated;
showTicket(updated);
} catch (e) {
alert('Error al cobrar nota: ' + e.message);
}
}
function updatePayRemissionMethod() {
const method = document.getElementById('payRemissionMethod').value;
const cashEl = document.getElementById('payRemissionCash');
const refEl = document.getElementById('payRemissionRef');
const mixedEl = document.getElementById('payRemissionMixed');
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
}
// ─── Quotation ───────────────────────
async function saveQuotation() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
@@ -1284,8 +1474,9 @@ const POS = (() => {
hour: '2-digit', minute: '2-digit'
});
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
const isRemission = sale.status === 'pending_payment';
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
let itemsHtml = '';
(sale.items || []).forEach(item => {
@@ -1333,13 +1524,14 @@ const POS = (() => {
</div>
<hr class="divider-double">
<div class="folio-line">
<span>VENTA: V-${sale.id}</span>
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</span>
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
</div>
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
<hr class="divider">
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
<span class="qty">Cant</span>
@@ -1363,6 +1555,12 @@ const POS = (() => {
${showPayment ? `
<hr class="divider">
<div class="payment-section">
${isRemission ? `
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
</div>
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
` : `
<div class="ticket-row">
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
</div>
@@ -1373,11 +1571,13 @@ const POS = (() => {
<div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''}
`}
</div>` : ''}
<hr class="divider">
<div class="footer-section">
<div class="thanks">${thanksMsg}</div>
${footerMsg ? `<div>${footerMsg}</div>` : ''}
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
</div>
`;
@@ -1387,7 +1587,7 @@ const POS = (() => {
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = 'Ticket de Venta';
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
document.getElementById('ticketModal').classList.add('open');
}
@@ -1592,7 +1792,9 @@ const POS = (() => {
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
selectPaymentMethod, updateChange, updateMixedTotal,
creditSale, saveQuotation, createLayaway,
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
saveQuotation, createLayaway,
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,

View File

@@ -54,7 +54,20 @@ const Reports = (() => {
}
// Track which tabs have been loaded
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false };
function currentUser() {
return (typeof window.POS_USER !== 'undefined') ? window.POS_USER : {};
}
function hasPerm(p) {
var u = currentUser();
return (u.role === 'owner' || u.role === 'admin') || (u.permissions || []).indexOf(p) !== -1;
}
function isLimitedUser() {
var u = currentUser();
var r = (u.role || '').toLowerCase();
return (r === 'cashier' || r === 'counter') && !hasPerm('pos.view');
}
// -------------------------------------------------------------------------
// Theme switcher
@@ -86,6 +99,7 @@ const Reports = (() => {
else if (id === 'clientes') loadClientes();
else if (id === 'financieros') loadFinancieros();
else if (id === 'historico') loadHistorico();
else if (id === 'cortes') loadCortes();
}
}
window.switchTab = switchTab;
@@ -737,6 +751,153 @@ const Reports = (() => {
}
}
// =========================================================================
// TAB 6: CORTES DE CAJA
// =========================================================================
async function loadCortes() {
loaded.cortes = true;
var dateFrom = document.getElementById('cortes-date-from').value;
var dateTo = document.getElementById('cortes-date-to').value;
var employeeId = document.getElementById('cortes-employee').value;
// Cashiers/counters without pos.view can only see their own cuts
var u = currentUser();
if (isLimitedUser()) {
employeeId = u.employeeId || '';
var empSel = document.getElementById('cortes-employee');
if (empSel) empSel.value = employeeId;
}
var kpiEl = document.getElementById('cortes-kpis');
var detalleEl = document.getElementById('cortes-detalle');
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
kpiEl.innerHTML = spinner();
detalleEl.innerHTML = spinner();
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
var params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (employeeId) params.set('employee_id', employeeId);
params.set('per_page', '200');
try {
var data = await apiFetch('/pos/api/register/history?' + params.toString());
var regs = data.data || [];
var totalEsperado = 0;
var totalCierre = 0;
var totalDiferencia = 0;
regs.forEach(function(r) {
totalEsperado += r.expected_amount || 0;
totalCierre += r.closing_amount || 0;
totalDiferencia += r.difference || 0;
});
kpiEl.innerHTML =
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
'<th>Acciones</th>' +
'</tr></thead><tbody>';
regs.forEach(function(r) {
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
'<td class="td-mono">#' + r.register_number + '</td>' +
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
});
cHtml += '</tbody></table></div>';
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
} catch (err) {
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
detalleEl.innerHTML = '';
}
}
// -------------------------------------------------------------------------
// Detail: sales for a selected cash cut
// -------------------------------------------------------------------------
async function showCorteVentas(registerId) {
var panel = document.getElementById('corte-ventas-detalle');
if (!panel) return;
panel.style.display = 'block';
panel.innerHTML = spinner();
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
var methodLabels = {
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
};
try {
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
var sales = data.sales || [];
var summary = data.summary || {};
var total = summary.total || 0;
var count = summary.count || 0;
var byMethod = summary.by_method || {};
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
// Summary by payment method
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
if (methods.length) {
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
methods.forEach(function(m) {
var label = methodLabels[m[0]] || m[0];
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
});
hHtml += '</div>';
}
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
'<th class="align-right">Total</th><th>Estado</th>' +
'</tr></thead><tbody>';
sales.forEach(function(s) {
var statusPill = s.status === 'completed' ? 'pill--success' :
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
var statusLabel = s.status === 'completed' ? 'Completada' :
s.status === 'cancelled' ? 'Cancelada' : s.status;
var method = methodLabels[s.payment_method] || s.payment_method || '--';
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
'<td>' + fmtDateTime(s.created_at) + '</td>' +
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
'<td>' + method + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
});
hHtml += '</tbody></table></div>';
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
} catch (err) {
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
}
}
window.showCorteVentas = showCorteVentas;
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -753,6 +914,9 @@ const Reports = (() => {
updateClock();
setInterval(updateClock, 1000);
var u = currentUser();
var limited = isLimitedUser();
// Set default date range: first day of current month to today
var now = new Date();
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -761,6 +925,49 @@ const Reports = (() => {
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
if (toEl) toEl.value = now.toISOString().substring(0, 10);
// Set default date range for cortes de caja
var cortesFrom = document.getElementById('cortes-date-from');
var cortesTo = document.getElementById('cortes-date-to');
if (cortesFrom) cortesFrom.value = firstDay.toISOString().substring(0, 10);
if (cortesTo) cortesTo.value = now.toISOString().substring(0, 10);
// Populate cajero filter for cortes de caja (admins/owners only see it)
var empSel = document.getElementById('cortes-employee');
if (empSel && !limited) {
apiFetch('/pos/api/config/employees?per_page=200').then(function(data) {
(data.data || []).forEach(function(e) {
var opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name;
empSel.appendChild(opt);
});
}).catch(function() {});
} else if (empSel && limited) {
empSel.value = u.employeeId || '';
}
// For cashiers/counters without pos.view, limit the page to "Mis cortes"
if (limited) {
['ventas', 'inventario', 'clientes', 'financieros', 'historico'].forEach(function(id) {
var btn = document.querySelector('.tab-btn[onclick="switchTab(\'' + id + '\', this)"]');
if (btn) btn.style.display = 'none';
});
var empFilter = document.getElementById('cortes-employee-filter');
if (empFilter) empFilter.style.display = 'none';
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
var cortesPanel = document.getElementById('panel-cortes');
var cortesBtn = document.querySelector('.tab-btn[onclick="switchTab(\'cortes\', this)"]');
if (cortesPanel) cortesPanel.classList.add('is-active');
if (cortesBtn) {
cortesBtn.classList.add('is-active');
var svg = '<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1" y="3" width="13" height="10" rx="1"/><path d="M4 7h7M4 10h5"/><circle cx="11" cy="10" r="1.5" fill="currentColor"/></svg>';
cortesBtn.innerHTML = svg + '<span>Mis cortes de caja</span>';
}
loadCortes();
}
// Populate financial period selectors
var monthSel = document.getElementById('fin-month');
var yearSel = document.getElementById('fin-year');
@@ -784,15 +991,18 @@ const Reports = (() => {
}
}
// Load the default active tab (ventas)
loadVentas();
// Load the default active tab (ventas) only for privileged users
if (!limited) {
loadVentas();
}
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, switchTab,
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
showCorteVentas, fmt
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -41,10 +41,32 @@ window.renderSidebar = function(modulesOverride) {
function itemAllowed(id) {
if (role === 'owner' || role === 'admin') return true;
if (role === 'workshop' || role === 'mechanic') {
return id === 'workshop';
// Taller siempre visible; el resto depende de los permisos asignados.
var allowed = ['workshop'];
var permMap = {
'customers.view': 'customers',
'inventory.view': 'inventory',
'catalog.view': 'catalog',
'pos.sell': 'pos',
'pos.view': 'pos',
'pos.remission': 'remission_notes',
'invoicing.view': 'invoicing',
'quotations.view': 'quotations',
'accounting.view': 'accounting',
'reports.view': 'reports'
};
for (var p in permMap) {
if (perms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
allowed.push(permMap[p]);
}
}
return allowed.indexOf(id) !== -1;
}
if (role === 'counter') {
return ['pos','catalog','inventory','customers','workshop','remission_notes','reports'].indexOf(id) !== -1;
}
if (role === 'cashier') {
return ['dashboard','pos','catalog','inventory','customers'].indexOf(id) !== -1;
return ['pos','catalog','inventory','customers','workshop','remission_notes','invoicing','reports'].indexOf(id) !== -1;
}
return true;
}
@@ -58,6 +80,7 @@ window.renderSidebar = function(modulesOverride) {
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
{ label: _t('nav_management'), items: [
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ id: 'remission_notes', name: _t('remission_notes'), href: '/pos/remission-notes', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ id: 'workshop', name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
{ id: 'quotations', name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
moduleEnabled('marketplace') ? { id: 'marketplace', name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
@@ -65,7 +88,6 @@ window.renderSidebar = function(modulesOverride) {
{ id: 'invoicing', name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ id: 'accounting', name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ id: 'reports', name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
hasPerm('fleet.view') ? { id: 'fleet', name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' } : null,
moduleEnabled('whatsapp') ? { id: 'whatsapp', name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
{ label: _t('nav_system'), items: [
@@ -132,14 +154,14 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>';
// Replace existing sidebar
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
if (existing) {
existing.className = 'pos-sidebar';
existing.className = 'pos-sidebar sidebar';
existing.innerHTML = sidebarHtml;
existing.removeAttribute('style');
} else {
var el = document.createElement('aside');
el.className = 'pos-sidebar';
el.className = 'pos-sidebar sidebar';
el.innerHTML = sidebarHtml;
document.body.insertBefore(el, document.body.firstChild);
}

View File

@@ -30,47 +30,99 @@ var Workshop = (function() {
var user = window.POS_USER || {};
var role = (user.role || '').toLowerCase();
var hidePrices = role === 'workshop' || role === 'mechanic';
var isRestricted = role === 'workshop' || role === 'mechanic';
var isMechanic = role === 'mechanic';
var hidePrices = isRestricted;
var perms = user.permissions || [];
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
// Owner/admin/counter/cashier can create/edit/delete service orders.
// Mechanics have limited access: view allowed statuses, change status, edit diagnosis/repair notes.
var canEdit = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier';
var canCreate = canEdit;
var canDelete = role === 'owner' || role === 'admin';
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
var canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1;
var canChangeStatus = canEdit || isMechanic;
var COLUMNS = [
{key: 'received', label: 'Recibido'},
{key: 'diagnosis', label: 'Diagnóstico'},
{key: 'waiting_parts', label: 'Espera refacciones'},
{key: 'repair', label: 'En reparación'},
{key: 'ready', label: 'Listo'},
{key: 'delivered', label: 'Entregado'},
{key: 'por_revisar', label: 'Por revisar'},
{key: 'en_revision', label: 'En revisión'},
{key: 'revisada', label: 'Revisada'},
{key: 'cotizada', label: 'Cotizada'},
{key: 'por_autorizar', label: 'Por autorizar'},
{key: 'autorizada', label: 'Autorizada'},
{key: 'autorizacion_parcial', label: 'Autorización parcial'},
{key: 'en_reparacion', label: 'En reparación'},
{key: 'reparada', label: 'Reparada'},
{key: 'por_entregar', label: 'Por entregar'},
{key: 'entregado', label: 'Entregado'},
{key: 'por_enviar', label: 'Por enviar'},
{key: 'enviado', label: 'Enviado'},
{key: 'por_facturar', label: 'Por facturar'},
{key: 'facturada', label: 'Facturada'},
{key: 'por_recolectar', label: 'Por recolectar'},
];
var STATUS_LABELS = {
received: 'Recibido',
diagnosis: 'Diagnóstico',
waiting_parts: 'Espera refacciones',
repair: 'En reparación',
ready: 'Listo',
delivered: 'Entregado',
cancelled: 'Cancelado'
por_revisar: 'Por revisar',
en_revision: 'En revisión',
revisada: 'Revisada',
cotizada: 'Cotizada',
por_autorizar: 'Por autorizar',
autorizada: 'Autorizada',
autorizacion_parcial: 'Autorización parcial',
en_reparacion: 'En reparación',
reparada: 'Reparada',
por_entregar: 'Por entregar',
entregado: 'Entregado',
por_enviar: 'Por enviar',
enviado: 'Enviado',
por_facturar: 'Por facturar',
facturada: 'Facturada',
por_recolectar: 'Por recolectar',
cancelada: 'Cancelada'
};
var VALID_NEXT = {
received: ['diagnosis', 'cancelled'],
diagnosis: ['waiting_parts', 'repair', 'cancelled'],
waiting_parts: ['repair', 'cancelled'],
repair: ['ready', 'cancelled'],
ready: ['delivered', 'cancelled'],
delivered: [],
cancelled: []
por_revisar: ['en_revision', 'cancelada'],
en_revision: ['revisada', 'por_revisar', 'cancelada'],
revisada: ['cotizada', 'en_revision', 'cancelada'],
cotizada: ['por_autorizar', 'revisada', 'cancelada'],
por_autorizar: ['autorizada', 'autorizacion_parcial', 'cotizada', 'cancelada'],
autorizada: ['en_reparacion', 'por_autorizar', 'cancelada'],
autorizacion_parcial: ['en_reparacion', 'por_autorizar', 'cancelada'],
en_reparacion: ['reparada', 'por_autorizar', 'cancelada'],
reparada: ['por_entregar', 'en_reparacion', 'cancelada'],
por_entregar: ['entregado', 'por_enviar', 'reparada', 'cancelada'],
por_enviar: ['enviado', 'por_entregar', 'cancelada'],
enviado: ['entregado', 'por_enviar', 'cancelada'],
por_recolectar: ['por_revisar', 'cancelada'],
por_facturar: ['facturada', 'cancelada'],
entregado: ['por_facturar'],
facturada: [],
cancelada: []
};
var DELIVERY_LABELS = {
pickup: 'Pasa cliente',
pickup: 'Mostrador',
delivery: 'Envío a domicilio',
courier: 'Motociclista'
};
var ITEM_STATUS_LABELS = {
por_revisar: 'Por revisar',
revisando: 'Revisando',
revisado: 'Revisado',
cotizado: 'Cotizado',
por_autorizar: 'Por autorizar',
autorizado: 'Autorizado',
en_reparacion: 'En reparación',
reparado: 'Reparado',
por_entregar: 'Por entregar',
entregado: 'Entregado',
por_enviar: 'Por enviar',
enviado: 'Enviado',
cancelado: 'Cancelado'
};
function headers() {
return {
'Content-Type': 'application/json',
@@ -88,6 +140,16 @@ var Workshop = (function() {
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
// Price based on customer tier: base price (price_1) with tier discount.
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
function priceForTier(basePrice, tier) {
var p = parseFloat(basePrice) || 0;
var t = parseInt(tier, 10) || 1;
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
return p;
}
function fmtDate(d) {
if (!d) return '—';
var dt = new Date(d);
@@ -126,6 +188,15 @@ var Workshop = (function() {
if (savedView) currentView = savedView;
bindFilters();
bindDeliverySelect();
if (!canCreate) {
var btnNewOrder = document.getElementById('btnNewOrder');
if (btnNewOrder) btnNewOrder.style.display = 'none';
}
if (isRestricted) {
document.querySelectorAll('.restricted-hide').forEach(function(el) { el.style.display = 'none'; });
var searchInput = document.getElementById('filterSearch');
if (searchInput) searchInput.placeholder = 'Buscar orden';
}
if (hidePrices) {
var btnCatalog = document.getElementById('btnCatalog');
if (btnCatalog) btnCatalog.style.display = 'none';
@@ -151,11 +222,19 @@ var Workshop = (function() {
function bindDeliverySelect() {
var sel = document.getElementById('noDelivery');
if (!sel) return;
sel.addEventListener('change', function() {
var cf = document.getElementById('courierField');
if (cf) cf.style.display = sel.value === 'courier' ? 'block' : 'none';
});
if (sel) {
sel.addEventListener('change', function() {
var cf = document.getElementById('courierField');
if (cf) cf.style.display = (sel.value === 'delivery' || sel.value === 'courier') ? 'block' : 'none';
});
}
var eoSel = document.getElementById('eoDelivery');
if (eoSel) {
eoSel.addEventListener('change', function() {
var cf = document.getElementById('eoCourierField');
if (cf) cf.style.display = (eoSel.value === 'delivery' || eoSel.value === 'courier') ? 'block' : 'none';
});
}
}
function debounce(fn, ms) {
@@ -172,9 +251,9 @@ var Workshop = (function() {
fetch(API + '/kanban/summary', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
document.getElementById('statReceived').textContent = fmt(d.received || 0);
document.getElementById('statRepair').textContent = fmt((d.repair || 0) + (d.diagnosis || 0) + (d.waiting_parts || 0) + (d.quality_check || 0));
document.getElementById('statReady').textContent = fmt(d.ready || 0);
document.getElementById('statReceived').textContent = fmt(d.por_revisar || 0);
document.getElementById('statRepair').textContent = fmt((d.en_reparacion || 0) + (d.en_revision || 0) + (d.revisada || 0) + (d.cotizada || 0) + (d.por_autorizar || 0));
document.getElementById('statReady').textContent = fmt(d.por_entregar || 0);
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
})
.catch(function() {});
@@ -243,20 +322,33 @@ var Workshop = (function() {
function renderList() {
var body = document.getElementById('listBody');
var fullCols = 8;
var restrictedCols = 4;
var cols = isRestricted ? restrictedCols : fullCols;
if (!orders.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
body.innerHTML = '<tr><td colspan="' + cols + '" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
return;
}
body.innerHTML = orders.map(function(o) {
var vehicle = esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || ''));
var statusCell = '<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>';
var actionCell = '<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>';
var base = '<td>' + esc(o.branch_name || '—') + '</td>' +
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
statusCell +
actionCell;
if (isRestricted) {
return '<tr>' + base + '</tr>';
}
var vehicle = esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—'));
return '<tr>' +
'<td>' + esc(o.branch_name || '—') + '</td>' +
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
'<td>' + esc(o.customer_name || 'Cliente general') + '</td>' +
'<td>' + esc(o.workshop_name || '—') + '</td>' +
'<td>' + vehicle + '</td>' +
'<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>' +
statusCell +
'<td class="price-col" style="text-align:right;">' + fmtMoney(o.total) + '</td>' +
'<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>' +
actionCell +
'</tr>';
}).join('');
if (hidePrices) {
@@ -287,7 +379,8 @@ var Workshop = (function() {
function renderKanban() {
var board = document.getElementById('kanbanBoard');
board.innerHTML = '';
COLUMNS.forEach(function(col) {
var hiddenCols = isMechanic ? ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'] : [];
COLUMNS.filter(function(col) { return hiddenCols.indexOf(col.key) === -1; }).forEach(function(col) {
var colOrders = orders.filter(function(o) { return o.status === col.key; });
var colEl = document.createElement('div');
colEl.className = 'kanban-column';
@@ -322,13 +415,22 @@ var Workshop = (function() {
card.className = 'kanban-card';
card.onclick = function() { openDetail(o.id); };
var priceHtml = hidePrices ? '' : '<span>' + fmtMoney(o.estimated_cost || o.total) + '</span>';
if (isRestricted) {
card.innerHTML =
'<div class="kanban-card__header">' +
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
'</div>' +
'<div class="kanban-card__branch">' + esc(o.branch_name || '—') + '</div>';
return card;
}
card.innerHTML =
'<div class="kanban-card__header">' +
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
' <span class="kanban-card__priority badge badge--' + esc(o.priority) + '">' + esc(priorityLabel(o.priority)) + '</span>' +
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
'</div>' +
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
'<div class="kanban-card__vehicle">' + esc(o.vehicle_description || o.vehicle_plate || 'Sin vehículo') + '</div>' +
'<div class="kanban-card__meta">' +
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
priceHtml +
@@ -355,47 +457,56 @@ var Workshop = (function() {
var activeTab = document.querySelector('.so-tabs__btn.is-active');
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
var mechanicAssignHtml = isMechanic ?
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' +
'<input id="mechanicNameInput" class="form-input" style="width:auto;min-width:180px;" value="' + esc(o.mechanic_name || '') + '" placeholder="Nombre del mecánico" />' +
' <button class="btn btn--sm btn--secondary" onclick="Workshop.saveMechanicName()">Guardar</button></div>' :
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' + esc(o.mechanic_name || o.employee_name || 'Sin asignar') + '</div>';
var html =
'<div class="so-detail-header">' +
' <div class="so-detail-header__row"><span class="so-detail__label">Estatus:</span> <span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></div>' +
' <div class="so-detail-header__row"><span class="so-detail__label">Fecha:</span> ' + fmtDate(o.created_at) + '</div>' +
' <div class="so-detail-header__row"><span class="so-detail__label">Registrado por:</span> ' + esc(o.created_by_name || '—') + '</div>' +
' <div class="so-detail-header__row"><span class="so-detail__label">Sucursal:</span> ' + esc(o.branch_name || '—') + '</div>' +
mechanicAssignHtml +
'</div>' +
(isRestricted ? '' :
'<div class="so-detail-info">' +
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Taller</span><span class="so-detail__value">' + esc(o.workshop_name || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Dirección</span><span class="so-detail__value">' + esc(o.customer_address || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Teléfono</span><span class="so-detail__value">' + esc(o.customer_phone || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—')) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Vía de entrega</span><span class="so-detail__value">' + esc(DELIVERY_LABELS[o.delivery_method] || o.delivery_method || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Motociclista</span><span class="so-detail__value">' + esc(o.courier_name || '—') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Factura</span><span class="so-detail__value">' + (o.requires_invoice ? 'Sí requiere' : 'No requiere') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Presupuesto</span><span class="so-detail__value">' + fmtMoney(o.estimated_cost) + '</span></div>') +
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Total</span><span class="so-detail__value">' + fmtMoney(o.total) + '</span></div>') +
'</div>' +
'</div>') +
(isRestricted ? '' :
'<div class="so-tabs">' +
' <button class="so-tabs__btn ' + (selectedTab === 'service' ? 'is-active' : '') + '" data-tab="service" onclick="Workshop.switchTab(\'service\')">Orden de servicio</button>' +
' <button class="so-tabs__btn ' + (selectedTab === 'articles' ? 'is-active' : '') + '" data-tab="articles" onclick="Workshop.switchTab(\'articles\')">Artículos</button>' +
'</div>' +
'</div>') +
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' || isRestricted ? '' : 'style="display:none;"') + '>' +
' <div class="so-detail__section">' +
' <h3>Notas</h3>' +
' <div class="so-notes-grid">' +
' <label class="form-label">Recepción</label>' +
' <textarea id="noteReception" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.reception_notes || '') + '</textarea>' +
' <label class="form-label">Diagnóstico</label>' +
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
' <label class="form-label">Reparación</label>' +
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
' <label class="form-label">Entrega</label>' +
' <textarea id="noteDelivery" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.delivery_notes || '') + '</textarea>' +
' </div>' +
(canEdit ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
(canEdit || isMechanic ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Bitácora</h3>' +
@@ -403,16 +514,18 @@ var Workshop = (function() {
' </div>' +
'</div>' +
'<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
(isRestricted ? '' : '<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
renderArticles(o) +
'</div>';
'</div>');
document.getElementById('detailBody').innerHTML = html;
// Footer actions
var footer = document.getElementById('detailFooter');
var allowedNext = VALID_NEXT[o.status] || [];
var statusHtml = canEdit && allowedNext.length ?
var allowedNext = (VALID_NEXT[o.status] || []).filter(function(s) {
return !isMechanic || ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'].indexOf(s) === -1;
});
var statusHtml = canChangeStatus && allowedNext.length ?
'<div class="so-detail__actions" style="margin-right:auto;">' +
' <select class="form-input" id="statusSelect" style="width:auto;">' +
'<option value="' + esc(o.status) + '" selected>' + esc(STATUS_LABELS[o.status] || o.status) + '</option>' +
@@ -424,10 +537,11 @@ var Workshop = (function() {
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
(canEdit ? '<button class="btn btn--secondary" onclick="Workshop.openEditOrderModal()">Editar orden</button>' : '') +
(canDelete ? '<button class="btn btn--danger" onclick="Workshop.deleteOrder()">Eliminar orden</button>' : '') +
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
(isRestricted ? '' : '<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
'Imprimir orden</button>' +
(canEdit && canSell && o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
'Imprimir orden</button>') +
(canEdit && canSell && (o.status === 'por_entregar' || o.status === 'entregado') && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
(canEdit && canSell && !o.sale_id && o.status !== 'cancelled' ? '<button class="btn btn--secondary" onclick="Workshop.convertToRemission()">Generar nota de remisión</button>' : '') +
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
}
@@ -439,93 +553,105 @@ var Workshop = (function() {
function renderBitacora(history) {
if (!history.length) return '<p style="color:var(--color-text-muted);">Sin movimientos</p>';
var vehicle = currentOrder ? (currentOrder.vehicle_description || (currentOrder.vehicle_plate ? currentOrder.vehicle_plate + ' ' + (currentOrder.vehicle_make || '') + ' ' + (currentOrder.vehicle_model || '') : null)) : null;
var rows = history.map(function(h) {
return '<tr>' +
'<td><span class="badge badge--' + esc(h.new_status) + '">' + esc(STATUS_LABELS[h.new_status] || h.new_status) + '</span></td>' +
'<td>' + fmtDate(h.created_at) + '</td>' +
'<td>' + esc(h.changed_by_name || '—') + '</td>' +
(isMechanic && vehicle ? '<td>' + esc(vehicle) + '</td>' : '') +
'<td>' + esc(h.notes || '—') + '</td>' +
'</tr>';
}).join('');
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th><th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
var vehicleTh = isMechanic && vehicle ? '<th>Vehículo</th>' : '';
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th>' + vehicleTh + '<th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
function mechanicName(id) {
var e = employees.find(function(x) { return x.id === id; });
return e ? e.name : '—';
}
function ensureEmployees() {
if (employees && employees.length) return Promise.resolve(employees);
return fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
employees = (d.data || d.employees || []);
return employees;
})
.catch(function() { employees = []; return employees; });
}
function populateMechanicSelect(selectId, selectedId) {
var sel = document.getElementById(selectId);
if (!sel) return;
ensureEmployees().then(function() {
sel.innerHTML = '<option value="">— Sin asignar —</option>' +
employees
.filter(function(e) { return e.is_active && (e.role === 'mechanic' || e.role === 'workshop'); })
.map(function(e) {
return '<option value="' + e.id + '"' + (e.id === selectedId ? ' selected' : '') + '>' + esc(e.name) + '</option>';
}).join('');
});
}
function renderArticles(o) {
var colCount = (hidePrices ? 5 : 6) - (isRestricted ? 1 : 0);
var partsRows = (o.items || []).map(function(it) {
var priceCells = hidePrices ? '' :
'<td>' + fmtMoney(it.unit_price) + '</td>';
var actionCell = canEdit && it.status !== 'cancelled' ?
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button></td>' : '<td></td>';
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(it.unit_price) + '</td>';
var mechanicCells = isRestricted ? '' : '<td>' + esc(mechanicName(it.mechanic_id)) + '</td>';
var actionCell = canEdit && it.status !== 'cancelado' ?
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.editItemInline(' + it.id + ')">Editar</button></td>' : '<td></td>';
return '<tr>' +
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
'<td>' + fmt(it.quantity) + '</td>' +
priceCells +
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(STATUS_LABELS[it.status] || it.status) + '</span></td>' +
mechanicCells +
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(ITEM_STATUS_LABELS[it.status] || it.status) + '</span></td>' +
'<td>' + esc(it.observations || '') + '</td>' +
actionCell +
'</tr>';
}).join('');
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + '<th>Estado</th><th></th></tr>';
var laborRows = (o.labor || []).map(function(l) {
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(l.hourly_rate) + '</td><td>' + fmtMoney(l.total_cost) + '</td>';
return '<tr>' +
'<td>' + esc(l.description) + '</td>' +
'<td>' + fmt(l.hours) + '</td>' +
priceCells +
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + esc(STATUS_LABELS[l.status] || l.status) + '</span></td>' +
'</tr>';
}).join('');
var laborHeader = '<tr><th>Concepto</th><th>Horas</th>' + (hidePrices ? '' : '<th>Precio/hr</th><th>Total</th>') + '<th>Estado</th></tr>';
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + (isRestricted ? '' : '<th>Mecánico</th>') + '<th>Estado</th><th>Observaciones</th><th></th></tr>';
var addParts = canEdit ?
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);align-items:flex-start;flex-wrap:wrap;">' +
' <div style="position:relative;flex:1;min-width:200px;">' +
' <div style="position:relative;flex:1;min-width:160px;">' +
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" autocomplete="off" oninput="Workshop.searchItemsForSO()" />' +
' <div id="itemSearchResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:220px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>' +
' </div>' +
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:80px;" />' +
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:70px;" />' +
(hidePrices ? '' : '<input class="form-input" id="newItemPrice" type="number" step="0.01" placeholder="Precio" style="width:90px;" />') +
' <select class="form-input" id="newItemMechanic" style="width:auto;"><option value="">— Mecánico —</option></select>' +
' <select class="form-input" id="newItemStatus" style="width:auto;">' +
Object.keys(ITEM_STATUS_LABELS).map(function(s) { return '<option value="' + s + '">' + esc(ITEM_STATUS_LABELS[s]) + '</option>'; }).join('') +
' </select>' +
' <input class="form-input" id="newItemObs" placeholder="Observaciones" style="min-width:140px;flex:1;" />' +
' <button class="btn btn--secondary" onclick="Workshop.addSelectedItem()">Agregar</button>' +
'</div>' : '';
var addLabor = canEdit ?
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap;">' +
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;min-width:160px;" />' +
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
(hidePrices ? '' : '<input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />') +
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
'</div>' : '';
var html =
'<div class="so-detail__section">' +
' <h3>Refacciones</h3>' +
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + (hidePrices ? 4 : 5) + '" style="text-align:center;">Sin refacciones</td></tr>') + '</tbody></table>' +
' <h3>Artículos</h3>' +
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + colCount + '" style="text-align:center;">Sin artículos</td></tr>') + '</tbody></table>' +
addParts +
'</div>' +
'<div class="so-detail__section">' +
' <h3>Mano de obra</h3>' +
' <table class="data-table"><thead>' + laborHeader + '</thead><tbody>' + (laborRows || '<tr><td colspan="' + (hidePrices ? 3 : 5) + '" style="text-align:center;">Sin mano de obra</td></tr>') + '</tbody></table>' +
addLabor +
'</div>';
// schedule labor catalog select population after DOM insertion
// populate mechanic select after DOM insertion
setTimeout(function() {
var sel = document.getElementById('laborCatalogSelect');
if (!sel || sel.dataset.populated) return;
catalog.forEach(function(c) {
var opt = document.createElement('option');
opt.value = JSON.stringify(c);
opt.textContent = c.name + (hidePrices ? '' : ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')');
sel.appendChild(opt);
ensureEmployees().then(function() {
var sel = document.getElementById('newItemMechanic');
if (!sel || sel.dataset.populated) return;
employees.forEach(function(e) {
if (!e.is_active) return;
var opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name;
sel.appendChild(opt);
});
sel.dataset.populated = '1';
});
sel.dataset.populated = '1';
sel.onchange = function() {
if (!sel.value) return;
var c = JSON.parse(sel.value);
document.getElementById('laborDesc').value = c.name;
document.getElementById('laborHours').value = c.suggested_hours;
if (!hidePrices) document.getElementById('laborRate').value = c.suggested_rate;
};
}, 0);
return html;
@@ -559,12 +685,9 @@ var Workshop = (function() {
async function populateEditOrderModal() {
var o = currentOrder;
selectedEditCustomer = o.customer_id ? {id: o.customer_id, name: o.customer_name || ''} : null;
selectedEditVehicle = o.vehicle_id ? {id: o.vehicle_id, label: (o.vehicle_plate || '') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')} : null;
document.getElementById('eoCustomerSearch').value = selectedEditCustomer ? selectedEditCustomer.name : '';
document.getElementById('eoCustomerId').value = selectedEditCustomer ? selectedEditCustomer.id : '';
document.getElementById('eoVehicleSearch').value = selectedEditVehicle ? selectedEditVehicle.label.trim() : '';
document.getElementById('eoVehicleId').value = selectedEditVehicle ? selectedEditVehicle.id : '';
// Branches
var branchSel = document.getElementById('eoBranch');
@@ -572,25 +695,26 @@ var Workshop = (function() {
return '<option value="' + b.id + '"' + (b.id === o.branch_id ? ' selected' : '') + '>' + esc(b.name) + '</option>';
}).join('');
// Mechanics/employees
var mechSel = document.getElementById('eoMechanic');
mechSel.innerHTML = '<option value="">— Ninguno —</option>';
try {
var res = await fetch('/pos/api/config/employees', {headers: headers()});
var json = await res.json();
(json.data || []).forEach(function(e) {
if (!e.is_active) return;
mechSel.innerHTML += '<option value="' + e.id + '"' + (e.id === o.employee_id ? ' selected' : '') + '>' + esc(e.name) + '</option>';
});
} catch (e) {}
document.getElementById('eoWorkshopName').value = o.workshop_name || '';
document.getElementById('eoCustomerAddress').value = o.customer_address || '';
document.getElementById('eoCustomerPhone').value = o.customer_phone || '';
document.getElementById('eoVehicleDescription').value = o.vehicle_description || '';
// Delivery / courier
var deliverySel = document.getElementById('eoDelivery');
deliverySel.value = o.delivery_method || '';
var courierField = document.getElementById('eoCourierField');
var courierSel = document.getElementById('eoCourier');
courierSel.innerHTML = couriers.map(function(c) {
return '<option value="' + c.id + '"' + (c.id === o.courier_id ? ' selected' : '') + '>' + esc(c.name) + '</option>';
}).join('');
courierField.style.display = (o.delivery_method === 'delivery' || o.delivery_method === 'courier') ? 'block' : 'none';
document.getElementById('eoPriority').value = o.priority || 'normal';
document.getElementById('eoFuelLevel').value = o.fuel_level || '';
document.getElementById('eoMileageIn').value = o.mileage_in != null ? o.mileage_in : '';
document.getElementById('eoMileageOut').value = o.mileage_out != null ? o.mileage_out : '';
document.getElementById('eoEstimatedCompletion').value = toDatetimeLocal(o.estimated_completion);
document.getElementById('eoEstimatedCost').value = o.estimated_cost != null ? o.estimated_cost : '';
populateMechanicSelect('eoMechanic', o.employee_id || null);
document.getElementById('eoMechanicName').value = o.mechanic_name || '';
document.getElementById('eoNotes').value = o.reception_notes || '';
document.getElementById('eoRequiresInvoice').checked = !!o.requires_invoice;
}
function hideEditSearchResults(type) {
@@ -638,36 +762,9 @@ var Workshop = (function() {
}
function searchVehiclesForSO() {
var input = document.getElementById('eoVehicleSearch');
// Fleet module removed; vehicle is free-text in the taller flow.
var box = document.getElementById('eoVehicleResults');
var q = input.value.trim();
selectedEditVehicle = null;
document.getElementById('eoVehicleId').value = '';
if (!q || q.length < 2) {
box.style.display = 'none'; box.innerHTML = '';
return;
}
clearTimeout(vehicleSearchTimer);
vehicleSearchTimer = setTimeout(function() {
fetch('/pos/api/fleet/vehicles?q=' + encodeURIComponent(q) + '&per_page=20&active_only=true', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var items = d.data || [];
if (!items.length) {
box.innerHTML = '<div style="padding:var(--space-2);color:var(--color-text-muted);">Sin resultados</div>';
box.style.display = 'block'; return;
}
box.innerHTML = items.map(function(v) {
var label = (v.plate || '') + ' · ' + (v.make || '') + ' ' + (v.model || '');
return '<div class="so-search-result" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectEditVehicle(' + v.id + ', \'' + escJs(label) + '\')">' +
'<strong>' + esc(v.plate || '') + '</strong>' +
'<small>' + esc(v.make || '') + ' ' + esc(v.model || '') + ' · ' + esc(v.owner_name || '') + '</small>' +
'</div>';
}).join('');
box.style.display = 'block';
})
.catch(function() { box.style.display = 'none'; });
}, 250);
if (box) { box.style.display = 'none'; box.innerHTML = ''; }
}
function selectEditVehicle(id, label) {
@@ -680,19 +777,22 @@ var Workshop = (function() {
async function saveOrderChanges() {
if (!currentOrderId) return;
var customerId = selectedEditCustomer ? selectedEditCustomer.id : parseInt(document.getElementById('eoCustomerId').value, 10) || null;
var vehicleId = selectedEditVehicle ? selectedEditVehicle.id : parseInt(document.getElementById('eoVehicleId').value, 10) || null;
var delivery = document.getElementById('eoDelivery').value;
var mechanicVal = document.getElementById('eoMechanic').value;
var payload = {
customer_id: customerId,
vehicle_id: vehicleId,
branch_id: parseInt(document.getElementById('eoBranch').value, 10) || null,
employee_id: parseInt(document.getElementById('eoMechanic').value, 10) || null,
priority: document.getElementById('eoPriority').value,
fuel_level: document.getElementById('eoFuelLevel').value || null,
mileage_in: document.getElementById('eoMileageIn').value ? parseInt(document.getElementById('eoMileageIn').value, 10) : null,
mileage_out: document.getElementById('eoMileageOut').value ? parseInt(document.getElementById('eoMileageOut').value, 10) : null,
estimated_completion: document.getElementById('eoEstimatedCompletion').value || null,
workshop_name: document.getElementById('eoWorkshopName').value.trim() || null,
customer_address: document.getElementById('eoCustomerAddress').value.trim() || null,
customer_phone: document.getElementById('eoCustomerPhone').value.trim() || null,
vehicle_description: document.getElementById('eoVehicleDescription').value.trim() || null,
delivery_method: delivery || null,
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('eoCourier').value, 10) || null) : null,
estimated_cost: document.getElementById('eoEstimatedCost').value ? parseFloat(document.getElementById('eoEstimatedCost').value) : null,
reception_notes: document.getElementById('eoNotes').value
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
mechanic_name: document.getElementById('eoMechanicName').value.trim() || null,
reception_notes: document.getElementById('eoNotes').value,
requires_invoice: document.getElementById('eoRequiresInvoice').checked
};
try {
await api('PUT', '/' + currentOrderId, payload);
@@ -832,8 +932,11 @@ var Workshop = (function() {
opt.textContent = label.trim();
sel.appendChild(opt);
sel.value = res.id;
document.getElementById('noWorkshopName').value = name;
document.getElementById('noCustomerPhone').value = payload.phone || '';
document.getElementById('noCustomerAddress').value = payload.address || '';
if (typeof customers !== 'undefined') {
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc});
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc, address: payload.address});
}
} else {
selectedEditCustomer = {id: res.id, name: name};
@@ -867,25 +970,46 @@ var Workshop = (function() {
function saveNotes() {
if (!currentOrderId) return;
var payload = {
reception_notes: document.getElementById('noteReception').value,
diagnosis_notes: document.getElementById('noteDiagnosis').value,
repair_notes: document.getElementById('noteRepair').value,
delivery_notes: document.getElementById('noteDelivery').value
};
var payload;
if (isMechanic) {
payload = {
diagnosis_notes: document.getElementById('noteDiagnosis').value,
repair_notes: document.getElementById('noteRepair').value
};
} else {
payload = {
reception_notes: document.getElementById('noteReception').value,
diagnosis_notes: document.getElementById('noteDiagnosis').value,
repair_notes: document.getElementById('noteRepair').value,
delivery_notes: document.getElementById('noteDelivery').value
};
}
api('PUT', '/' + currentOrderId, payload)
.then(function() {
alert('Notas guardadas');
if (currentOrder) {
currentOrder.reception_notes = payload.reception_notes;
currentOrder.diagnosis_notes = payload.diagnosis_notes;
currentOrder.repair_notes = payload.repair_notes;
currentOrder.delivery_notes = payload.delivery_notes;
if ('reception_notes' in payload) currentOrder.reception_notes = payload.reception_notes;
if ('diagnosis_notes' in payload) currentOrder.diagnosis_notes = payload.diagnosis_notes;
if ('repair_notes' in payload) currentOrder.repair_notes = payload.repair_notes;
if ('delivery_notes' in payload) currentOrder.delivery_notes = payload.delivery_notes;
}
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function saveMechanicName() {
if (!currentOrderId) return;
var input = document.getElementById('mechanicNameInput');
if (!input) return;
var payload = { mechanic_name: input.value.trim() || null };
api('PUT', '/' + currentOrderId, payload)
.then(function() {
alert('Mecánico asignado guardado');
if (currentOrder) currentOrder.mechanic_name = payload.mechanic_name;
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function reserveItem(itemId) {
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
.then(function() {
@@ -923,10 +1047,12 @@ var Workshop = (function() {
box.style.display = 'block';
return;
}
var orderTier = currentOrder ? (currentOrder.customer_price_tier || 1) : 1;
box.innerHTML = items.map(function(it) {
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + (it.price_1 || 0) + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
var price = priceForTier(it.price_1, orderTier);
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + price + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
'<div><strong>' + esc(it.name) + '</strong></div>' +
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(it.price_1)) + '</small>' +
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(price)) + '</small>' +
'</div>';
}).join('');
box.style.display = 'block';
@@ -954,20 +1080,28 @@ var Workshop = (function() {
function addSelectedItem() {
if (!currentOrderId) return;
var qty = parseInt(document.getElementById('newItemQty').value, 10) || 1;
var qty = parseFloat(document.getElementById('newItemQty').value) || 1;
var status = document.getElementById('newItemStatus').value || 'por_revisar';
var mechanicId = parseInt(document.getElementById('newItemMechanic').value, 10) || null;
var observations = document.getElementById('newItemObs').value.trim();
if (selectedInventoryItem) {
var price = hidePrices ? selectedInventoryItem.unit_price : (parseFloat(document.getElementById('newItemPrice').value) || selectedInventoryItem.unit_price || 0);
api('POST', '/' + currentOrderId + '/items', {
inventory_id: selectedInventoryItem.id,
part_number: selectedInventoryItem.part_number,
name: selectedInventoryItem.name,
quantity: qty,
unit_cost: selectedInventoryItem.unit_cost,
unit_price: selectedInventoryItem.unit_price,
status: 'pending'
unit_price: price,
status: status,
mechanic_id: mechanicId,
observations: observations
}).then(function() {
selectedInventoryItem = null;
document.getElementById('newItemSearch').value = '';
document.getElementById('newItemQty').value = '1';
if (!hidePrices) document.getElementById('newItemPrice').value = '';
document.getElementById('newItemObs').value = '';
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
return;
@@ -975,19 +1109,36 @@ var Workshop = (function() {
// Fallback: manual placeholder (no inventory link)
var name = document.getElementById('newItemSearch').value.trim();
if (!name) return;
if (!confirm('No seleccionaste una refacción del inventario. ¿Agregar como concepto manual? No podrá reservarse.')) return;
var manualPrice = hidePrices ? 0 : (parseFloat(document.getElementById('newItemPrice').value) || 0);
api('POST', '/' + currentOrderId + '/items', {
name: name,
quantity: qty,
unit_price: 0,
status: 'pending'
unit_price: manualPrice,
status: status,
mechanic_id: mechanicId,
observations: observations
}).then(function() {
document.getElementById('newItemSearch').value = '';
document.getElementById('newItemQty').value = '1';
if (!hidePrices) document.getElementById('newItemPrice').value = '';
document.getElementById('newItemObs').value = '';
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
}
function editItemInline(itemId) {
if (!currentOrder || !currentOrder.items) return;
var it = currentOrder.items.find(function(x) { return x.id === itemId; });
if (!it) return;
var newStatus = prompt('Nuevo estado (' + Object.keys(ITEM_STATUS_LABELS).join(', ') + '):', it.status);
if (!newStatus || !ITEM_STATUS_LABELS[newStatus]) return;
var newObs = prompt('Observaciones:', it.observations || '');
var payload = {status: newStatus, observations: newObs != null ? newObs : it.observations};
api('PUT', '/items/' + itemId, payload)
.then(function() { openDetail(currentOrderId); })
.catch(function(e) { alert('Error: ' + e.message); });
}
function addLabor() {
var desc = document.getElementById('laborDesc').value.trim();
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
@@ -1020,6 +1171,18 @@ var Workshop = (function() {
}).catch(function(e) { alert('Error: ' + e.message); });
}
function convertToRemission() {
if (!currentOrderId) return;
if (!confirm('¿Generar nota de remisión desde esta orden? Se reservarán las refacciones y quedará pendiente de cobro.')) return;
api('POST', '/' + currentOrderId + '/convert-to-remission', {})
.then(function(r) {
alert('Nota de remisión creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total));
closeDetailModal();
loadSummary();
loadOrders();
}).catch(function(e) { alert('Error: ' + e.message); });
}
function deleteOrder() {
if (!currentOrderId) return;
if (!confirm('¿Eliminar esta orden de servicio? Se ocultará del taller pero las reservas de inventario y la venta asociada (si existe) no se verán afectadas.')) return;
@@ -1058,10 +1221,22 @@ var Workshop = (function() {
// ─── New order ───
function openNewOrderModal() {
populateSelect('noBranch', branches, function(b) { return {value: b.id, text: b.name}; });
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; });
populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; });
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
populateMechanicSelect('noMechanic', null);
document.getElementById('noMechanicName').value = '';
document.getElementById('noEstimatedCost').value = '';
var noCustomer = document.getElementById('noCustomer');
noCustomer.onchange = function() {
var cid = parseInt(noCustomer.value, 10);
var c = customers.find(function(x) { return x.id === cid; });
if (c) {
document.getElementById('noWorkshopName').value = c.name || '';
document.getElementById('noCustomerPhone').value = c.phone || '';
document.getElementById('noCustomerAddress').value = c.address || '';
}
};
document.getElementById('newOrderModal').classList.add('is-open');
}
@@ -1074,19 +1249,27 @@ var Workshop = (function() {
function submitNewOrder() {
var customerId = document.getElementById('noCustomer').value;
var branchId = document.getElementById('noBranch').value;
if (!customerId) return alert('Selecciona un cliente');
if (!branchId) return alert('Selecciona una sucursal');
var delivery = document.getElementById('noDelivery').value;
var mechanicVal = document.getElementById('noMechanic').value;
var estimatedCostVal = document.getElementById('noEstimatedCost').value;
var payload = {
branch_id: parseInt(branchId, 10),
customer_id: parseInt(customerId, 10),
vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null,
employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null,
priority: document.getElementById('noPriority').value,
estimated_completion: document.getElementById('noEstimatedCompletion').value || null,
mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null,
workshop_name: document.getElementById('noWorkshopName').value.trim() || null,
customer_address: document.getElementById('noCustomerAddress').value.trim() || null,
customer_phone: document.getElementById('noCustomerPhone').value.trim() || null,
vehicle_description: document.getElementById('noVehicleDescription').value.trim() || null,
reception_notes: document.getElementById('noNotes').value,
delivery_method: delivery || null,
courier_id: delivery === 'courier' ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
is_direct: document.getElementById('noDirect').checked
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
mechanic_name: document.getElementById('noMechanicName').value.trim() || null,
estimated_cost: estimatedCostVal ? parseFloat(estimatedCostVal) : null,
is_direct: document.getElementById('noDirect').checked,
requires_invoice: document.getElementById('noRequiresInvoice').checked
};
api('POST', '', payload).then(function() {
closeNewOrderModal();
@@ -1168,18 +1351,13 @@ var Workshop = (function() {
.then(function(r) { return r.json(); })
.then(function(d) { customers = (d.data || d.customers || []); })
.catch(function() {});
// Vehicles
fetch('/pos/api/fleet/vehicles?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) { vehicles = (d.data || []); })
.catch(function() { vehicles = []; });
// Employees
fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) { employees = (d.data || d.employees || []); })
.catch(function() { employees = []; });
// Couriers
fetch('/pos/api/couriers?per_page=500', {headers: headers()})
fetch('/pos/api/logistics/couriers?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
couriers = (d.data || d.couriers || []);
@@ -1192,6 +1370,11 @@ var Workshop = (function() {
.then(function(d) {
branches = (d.data || []);
populateBranchFilter();
var noBranch = document.getElementById('noBranch');
var userBranch = (window.POS_USER || {}).branch_id;
if (noBranch && userBranch && branches.some(function(b) { return b.id == userBranch; })) {
noBranch.value = userBranch;
}
})
.catch(function() { branches = []; });
}
@@ -1235,11 +1418,14 @@ var Workshop = (function() {
changeStatus: changeStatus,
reserveItem: reserveItem,
saveNotes: saveNotes,
saveMechanicName: saveMechanicName,
searchItemsForSO: searchItemsForSO,
selectInventoryItem: selectInventoryItem,
addSelectedItem: addSelectedItem,
editItemInline: editItemInline,
addLabor: addLabor,
convertToSale: convertToSale,
convertToRemission: convertToRemission,
deleteOrder: deleteOrder,
printOrder: printOrder,
openNewOrderModal: openNewOrderModal,

View File

@@ -6,7 +6,7 @@
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
const VERSION = 33;
const VERSION = 40;
const CACHE_NAME = 'nexus-pos-v' + VERSION;
const APP_SHELL = [

View File

@@ -492,11 +492,11 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/accounting.v9.js?v=33" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -314,12 +314,12 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/catalog.js?v=33" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>

View File

@@ -15,7 +15,39 @@
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/config.css?v=33"></head>
<link rel="stylesheet" href="/pos/static/css/config.css?v=34">
<style>
.cfg-tabs {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-1);
position: sticky;
top: 0;
z-index: 10;
flex-wrap: wrap;
}
.cfg-tab-btn {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
}
.cfg-tab-btn:hover { background: var(--color-surface-3); }
.cfg-tab-btn.active {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.settings-section[data-tab] { display: none !important; }
.settings-section[data-tab].active { display: block !important; }
</style>
</head>
<body>
@@ -138,11 +170,20 @@
<!-- Scrollable Content -->
<div class="content-scroll">
<!-- Config tabs -->
<div class="cfg-tabs" id="configTabs">
<button class="cfg-tab-btn active" data-tab="general" onclick="Config.switchTab('general')">General</button>
<button class="cfg-tab-btn" data-tab="fiscal" onclick="Config.switchTab('fiscal')">Fiscal</button>
<button class="cfg-tab-btn" data-tab="catalog" onclick="Config.switchTab('catalog')">Catálogo</button>
<button class="cfg-tab-btn" data-tab="employees" onclick="Config.switchTab('employees')">Empleados</button>
<button class="cfg-tab-btn cfg-tab-btn--permissions" data-tab="permissions" onclick="Config.switchTab('permissions')" style="display:none;">Permisos</button>
</div>
<!-- ===============================================================
SECTION 1: APARIENCIA / TEMA
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
@@ -205,7 +246,7 @@
<!-- ===============================================================
SECTION 2: DATOS DE LA EMPRESA
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
@@ -256,7 +297,7 @@
<!-- ===============================================================
SECTION 3: PERSONALIZACIÓN DE TICKET
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
@@ -328,7 +369,7 @@
<!-- ===============================================================
SECTION 4: MÓDULOS E INTEGRACIONES
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
@@ -380,6 +421,16 @@
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Notas de remisión en mostrador</span>
<span class="toggle-row__desc">Permite generar notas de remisión desde el POS para cobrar posteriormente en caja</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-counter-remission" />
<span class="toggle__slider"></span>
</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
</div>
@@ -389,13 +440,13 @@
<!-- ===============================================================
SECTION 4: USUARIOS Y PERMISOS
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="employees">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div>
<div class="settings-section__title">Usuarios y Permisos</div>
<div class="settings-section__title">Empleados</div>
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
</div>
</div>
@@ -432,7 +483,7 @@
<!-- ===============================================================
SECTION 4: IMPRESORAS
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
@@ -458,7 +509,7 @@
<!-- ===============================================================
SECTION 5: SUCURSALES
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="fiscal">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
@@ -477,7 +528,7 @@
<!-- ===============================================================
SECTION 6: PARÁMETROS FISCALES
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="fiscal">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@@ -613,7 +664,7 @@
<!-- ===============================================================
SECTION 7: PREFERENCIAS DEL SISTEMA
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -691,7 +742,7 @@
<!-- ===============================================================
SECTION 8: VEHICLE COMPATIBILITY SOURCE
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="catalog">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@@ -724,7 +775,7 @@
<!-- ===============================================================
SECTION 9: MARCAS DE PARTES PERMITIDAS
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="catalog">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
@@ -785,6 +836,47 @@
</div>
</div>
<!-- ===============================================================
SECTION: PERMISOS POR ROL
=============================================================== -->
<div class="settings-section" data-tab="permissions">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div>
<div class="settings-section__title">Permisos por Rol</div>
<div class="settings-section__desc">Define los permisos predeterminados para cada rol del sistema</div>
</div>
</div>
<div class="settings-card">
<div class="form-group" style="max-width:320px;">
<label class="form-label">Rol</label>
<select class="form-input" id="cfg-perm-role" onchange="Config.renderRolePermissions()">
<option value="">Selecciona un rol</option>
<option value="admin">Administrador</option>
<option value="cashier">Cajero</option>
<option value="counter">Mostrador</option>
<option value="warehouse">Almacén</option>
<option value="accountant">Contador</option>
<option value="workshop">Taller</option>
<option value="mechanic">Mecánico</option>
</select>
</div>
<div id="role-permissions-container" style="margin-top:var(--space-4);">
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>
</div>
<div style="margin-top:var(--space-4);">
<button class="btn btn--primary" id="btn-save-role-permissions" onclick="Config.saveRolePermissions()">Guardar permisos del rol</button>
<span id="role-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
</div>
</div>
</div>
</div><!-- /content-scroll -->
</main>
</div><!-- /app-shell -->
@@ -893,6 +985,7 @@
<option value="">-- Seleccionar --</option>
<option value="admin">Administrador</option>
<option value="cashier">Cajero</option>
<option value="counter">Mostrador</option>
<option value="warehouse">Almacenista</option>
<option value="accountant">Contador</option>
<option value="workshop">Taller</option>
@@ -922,13 +1015,13 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/config.js?v=35" defer></script>
<script src="/pos/static/js/config.js?v=39" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -650,11 +650,11 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/customers.js?v=34" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>

View File

@@ -158,6 +158,16 @@
Ventas Históricas
</a>
<a href="/pos/remission-notes" class="nav-link">
<span class="nav-link__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 3h12v10H2z"/>
<path d="M5 7h6M5 10h4" stroke-width="1.2"/>
</svg>
</span>
Notas de Remisión
</a>
<div class="sidebar__section-label">Gestión</div>
<a href="/pos/marketplace" class="nav-link">
@@ -595,15 +605,15 @@
<script src="/pos/static/js/chart.umd.min.js" defer></script>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=33" defer></script>
<script src="/pos/static/js/dashboard.js?v=33" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js?v=34',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>

View File

@@ -148,12 +148,12 @@
</main>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/diagrams.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -302,11 +302,11 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/fleet.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -192,7 +192,7 @@
<h1 class="page-header__title">Inventario</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Importar CSV
</button>
@@ -204,7 +204,7 @@
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
Sincronizar
</button>
<button class="btn btn--primary" onclick="showCreateModal()">
<button class="btn btn--primary" id="btnHeaderNewProduct" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
</button>
@@ -336,7 +336,7 @@
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
</button>
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
<button class="btn btn--primary btn--sm" id="btnStockNewProduct" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
</button>
@@ -1018,6 +1018,9 @@
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body">
<div style="margin-bottom:12px;">
<button class="btn btn--ghost btn--sm" onclick="downloadBulkImportTemplate()" type="button">📥 Descargar plantilla CSV</button>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
@@ -1039,8 +1042,8 @@
</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
<strong>Columnas esperadas:</strong>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, sku_secondary, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, sku_secundario, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
</div>
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
</div>
@@ -1058,13 +1061,13 @@
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">&times;</button>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
<script src="/pos/static/js/inventory.js?v=34" defer></script>
<script src="/pos/static/js/inventory.js?v=37" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -1063,11 +1063,11 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/invoicing.js?v=33" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -387,7 +387,7 @@
setTimeout(function() {
var role = (result.data.employee.role || '').toLowerCase();
if (role === 'workshop' || role === 'mechanic') {
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
window.location.href = '/pos/workshop';
} else {
window.location.href = '/pos/catalog';

View File

@@ -342,11 +342,11 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=33" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>

View File

@@ -16,7 +16,7 @@
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<script src="/pos/static/js/native-bridge.js" defer></script>
<link rel="stylesheet" href="/pos/static/css/pos.css?v=33"></head>
<link rel="stylesheet" href="/pos/static/css/pos.css?v=34"></head>
<body class="pos-shell" id="appBody">
@@ -123,7 +123,6 @@
</div>
</div>
<button class="btn-change-customer" onclick="POS.clearCustomer()" aria-label="Cambiar cliente">Cambiar</button>
<button class="btn-change-customer" onclick="POS.creditSale()" aria-label="Venta a credito" style="margin-left:4px;background:var(--color-primary);color:#fff;border-color:var(--color-primary);">Crédito</button>
</div>
</div>
@@ -199,15 +198,29 @@
</div>
</div>
<!-- Courier selector for counter remission notes -->
<div class="form-field" id="courierSelectField" style="display:none; margin-bottom: 12px;">
<label class="form-label" for="remissionCourier">Repartidor</label>
<select class="form-input" id="remissionCourier">
<option value="">-- Sin repartidor --</option>
</select>
</div>
<!-- COBRAR Button -->
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
<span>COBRAR</span>
</button>
<!-- Counter remission button (shown for counter role when feature enabled) -->
<button class="btn-cobrar" id="btnRemission" onclick="POS.createRemissionNote()" aria-label="Generar nota de remision" style="display:none;background:var(--color-secondary);">
<span>NOTA DE REMISIÓN</span>
</button>
<!-- Secondary Actions -->
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
<button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button>
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
<button class="btn-secondary-action" id="btnPayRemission" onclick="POS.openPayRemissionModal()" title="Cobrar nota de remision" style="display:none;">Cobrar Nota</button>
<button class="btn-secondary-action" onclick="POS.createLayaway()" title="Apartado (requiere cliente)">Apartado</button>
<button class="btn-secondary-action" onclick="POS.createServiceOrder()" title="Orden de servicio (F7)">Orden Taller</button>
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
@@ -295,6 +308,15 @@
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
Mixto
</button>
<button class="pago-tab" data-method="credito" onclick="POS.selectPaymentMethod('credito', this)">
Crédito
</button>
<button class="pago-tab" data-method="cheque" onclick="POS.selectPaymentMethod('cheque', this)">
Cheque
</button>
<button class="pago-tab" data-method="pendiente" onclick="POS.selectPaymentMethod('pendiente', this)">
Pendiente
</button>
</div>
<!-- TAB: Efectivo -->
@@ -358,6 +380,35 @@
</div>
</div>
<!-- TAB: Crédito -->
<div class="tab-content" id="creditPayment">
<div class="form-group">
<label class="form-label">Venta a crédito</label>
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Se registrará como venta a crédito para el cliente seleccionado y se agregará a su cuenta.</p>
</div>
</div>
<!-- TAB: Cheque -->
<div class="tab-content" id="chequePayment">
<div class="form-group">
<label class="form-label">Monto</label>
<input type="text" class="form-input form-input-lg" id="chequeAmount" readonly />
</div>
<div class="form-group">
<label class="form-label">No. de cheque / referencia</label>
<input type="text" class="form-input" id="chequeRef" placeholder="No. de cheque" />
</div>
<div class="form-hint">Verificar que el cheque sea válido antes de confirmar</div>
</div>
<!-- TAB: Pendiente -->
<div class="tab-content" id="pendingPayment">
<div class="form-group">
<label class="form-label">Pago pendiente</label>
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">La venta quedará pendiente de pago. Podrá cobrarse más tarde desde el listado de ventas o notas de remisión.</p>
</div>
</div>
<!-- CFDI Checkbox -->
<div class="cfdi-check">
<input type="checkbox" id="cfdiCheck" />
@@ -373,6 +424,68 @@
</div>
</div>
<!-- ================================================================
PAY REMISSION NOTE MODAL
================================================================ -->
<div class="modal-overlay" id="payRemissionModal">
<div class="modal-pago">
<div class="modal-header">
<h3>Cobrar Nota de Remisión</h3>
<button class="modal-close" onclick="POS.closePayRemissionModal()">&#x2715;</button>
</div>
<div class="form-row" style="padding: 16px; gap: 8px;">
<input class="form-input" type="number" id="payRemissionFolio" placeholder="Folio de la nota (NR-XXXX)" style="flex:1;" />
<button class="btn btn-primary" onclick="POS.searchRemissionToPay()">Buscar</button>
</div>
<div id="payRemissionDetail" style="padding: 0 16px 16px; max-height: 220px; overflow-y: auto;"></div>
<div id="payRemissionActions" style="display:none; padding: 0 16px 16px;">
<div class="form-row" style="gap: 8px; margin-bottom: 8px;">
<select class="form-input" id="payRemissionMethod" onchange="POS.updatePayRemissionMethod()">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
<option value="mixto">Mixto</option>
</select>
</div>
<div id="payRemissionCash" class="form-row" style="gap: 8px; margin-bottom: 8px;">
<input class="form-input" type="number" id="payRemissionReceived" placeholder="Recibido" step="0.01" />
</div>
<div id="payRemissionRef" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none;">
<input class="form-input" type="text" id="payRemissionReference" placeholder="Referencia" />
</div>
<div id="payRemissionMixed" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none; flex-direction: column;">
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
<select class="form-input" style="flex:1;">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
</select>
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
</div>
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
<select class="form-input" style="flex:1;">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
</select>
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
</div>
</div>
<div class="modal-footer" style="padding:0;">
<button class="btn btn-ghost" onclick="POS.closePayRemissionModal()">Cancelar</button>
<button class="btn btn-primary" id="btnConfirmPayRemission" onclick="POS.confirmPayRemission()">Confirmar Pago</button>
</div>
</div>
<div id="payRemissionResult" style="padding: 0 16px 16px;"></div>
</div>
</div>
<!-- ================================================================
CANCEL SALE CONFIRMATION MODAL
================================================================ -->
@@ -613,14 +726,14 @@
<!-- ================================================================
JAVASCRIPT
================================================================ -->
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/push.js" defer></script>
<script src="/pos/static/js/printer.js" defer></script>
<script src="/pos/static/js/pos.js?v=33" defer></script>
<script src="/pos/static/js/pos.js?v=41" defer></script>
<script>
// Cancel sale button wiring

View File

@@ -14,10 +14,10 @@
<link rel="stylesheet" href="/pos/static/css/quotations.css"></head>
<body>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<div class="page">
<h1 class="page-title">Cotizaciones</h1>

View File

@@ -0,0 +1,436 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notas de Remisión — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34" />
<link rel="stylesheet" href="/pos/static/css/remission_notes.css?v=1" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
</head>
<body>
<!-- THEME BAR -->
<header class="theme-bar" role="banner">
<div class="theme-bar__left">
<div class="theme-bar__store">
<span class="theme-bar__dot"></span>
Nexus Autoparts
</div>
<div class="theme-bar__sep"></div>
<span class="theme-bar__label">Sucursal Centro &mdash; Usuario: H. García</span>
</div>
<div class="theme-bar__right">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
<span class="theme-btn__swatch"></span>
Industrial
</button>
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
<span class="theme-btn__swatch"></span>
Moderno
</button>
</div>
</header>
<!-- APP SHELL -->
<div class="app-shell">
<!-- SIDEBAR -->
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
<div class="sidebar__brand">
<div class="brand-logo">NA</div>
<div class="brand-name">
<span class="brand-name__primary">Nexus</span>
<span class="brand-name__sub">Autoparts POS</span>
</div>
</div>
<nav class="sidebar__nav">
<div class="nav-section-label">Principal</div>
<a class="nav-item" href="/pos/dashboard">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<a class="nav-item" href="/pos/sale">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span>POS</span>
</a>
<a class="nav-item" href="/pos/catalog">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span>Catálogo</span>
</a>
<a class="nav-item" href="/pos/inventory">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Inventario</span>
</a>
<div class="nav-section-label">Gestión</div>
<a class="nav-item" href="/pos/customers">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>Clientes</span>
</a>
<a class="nav-item is-active" href="/pos/remission-notes" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span>Notas de Remisión</span>
</a>
<a class="nav-item" href="/pos/invoicing">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span>Facturación</span>
</a>
<a class="nav-item" href="/pos/reports">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>Reportes</span>
</a>
<div class="nav-section-label">Sistema</div>
<a class="nav-item" href="/pos/config">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
<span>Configuración</span>
</a>
</nav>
<div class="sidebar__footer">
<div class="sidebar__user-avatar">HG</div>
<div class="sidebar__user-info">
<div class="sidebar__user-name">Hugo García</div>
<div class="sidebar__user-role">Administrador</div>
</div>
</div>
</aside>
<!-- MAIN CONTENT -->
<main class="main" role="main">
<div class="page-header">
<div class="page-header__title-group">
<span class="page-header__eyebrow">Ventas</span>
<h1 class="page-header__title">Notas de Remisión</h1>
</div>
</div>
<div class="page-content">
<!-- Filters -->
<div class="filters-card">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="filterFolio" placeholder="Buscar folio NR-XXXX..." />
</div>
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="filterCustomer" placeholder="Cliente..." />
</div>
<select class="select-filter" id="filterStatus">
<option value="">Todos los estados</option>
<option value="pending_payment" selected>Pendientes</option>
<option value="completed">Pagadas</option>
<option value="cancelled">Canceladas</option>
</select>
<select class="select-filter" id="filterCourier">
<option value="">Todos los repartidores</option>
</select>
<input type="date" class="select-filter" id="dateFrom" />
<input type="date" class="select-filter" id="dateTo" />
<div class="toolbar__spacer"></div>
<button class="btn btn--primary" onclick="loadData(1)">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Buscar
</button>
</div>
</div>
<!-- Table -->
<div class="table-wrapper">
<table class="data-table" id="remissionTable">
<thead>
<tr>
<th>Folio</th>
<th>Fecha</th>
<th>Cliente</th>
<th>Vendedor</th>
<th>Repartidor</th>
<th style="text-align:right">Total</th>
<th>Estado</th>
<th style="text-align:right">Acciones</th>
</tr>
</thead>
<tbody id="remissionTableBody">
<tr><td colspan="8" style="text-align:center;padding:var(--space-8);">Cargando...</td></tr>
</tbody>
</table>
<div class="table-footer">
<div class="pagination" id="pagination"></div>
</div>
</div>
</div>
</main>
</div>
<!-- Ticket Modal -->
<div class="modal-overlay" id="ticketModal" onclick="if(event.target===this) closeTicketModal()">
<div class="modal">
<div class="modal__header">
<h3 class="modal__title">Vista previa</h3>
<button class="modal__close" onclick="closeTicketModal()">&times;</button>
</div>
<div class="modal__body">
<div id="ticketContent" class="ticket-preview"></div>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="closeTicketModal()">Cerrar</button>
<button class="btn btn--primary" onclick="printTicket()">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Imprimir
</button>
</div>
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script>
const token = localStorage.getItem('pos_token') || '';
let currentPage = 1;
let couriers = [];
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
async function api(url, options = {}) {
options.headers = headers();
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function statusLabel(status) {
return { pending_payment: 'Pendiente', completed: 'Pagada', cancelled: 'Cancelada' }[status] || status;
}
function statusClass(status) {
return { pending_payment: 'badge--pending_payment', completed: 'badge--completed', cancelled: 'badge--cancelled' }[status] || '';
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('filterCourier');
const current = sel.value;
sel.innerHTML = '<option value="">Todos los repartidores</option>' + couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.value = current;
} catch (e) {
console.warn('No se pudieron cargar repartidores', e);
}
}
async function loadData(page) {
currentPage = page;
const tbody = document.getElementById('remissionTableBody');
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Cargando...</td></tr>';
const params = new URLSearchParams();
params.set('sale_type', 'counter_remission');
params.set('per_page', '50');
params.set('page', String(page));
const status = document.getElementById('filterStatus').value;
if (status) params.set('status', status);
const folio = document.getElementById('filterFolio').value.trim();
if (folio) params.set('q', folio.replace(/^NR-/i, ''));
const customer = document.getElementById('filterCustomer').value.trim();
if (customer) params.set('customer', customer);
const courier = document.getElementById('filterCourier').value;
if (courier) params.set('courier_id', courier);
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
if (from) params.set('date_from', from);
if (to) params.set('date_to', to);
try {
const res = await api('/pos/api/sales?' + params.toString());
render(res.data || [], res.pagination || {});
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-error);">Error: ${e.message}</td></tr>`;
}
}
function render(rows, pagination) {
const tbody = document.getElementById('remissionTableBody');
const pag = document.getElementById('pagination');
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="8">
<div class="empty-state">
<div class="empty-state__title">No hay notas de remisión</div>
<div class="empty-state__subtitle">Ajusta los filtros o genera una nueva nota desde el POS.</div>
</div>
</td></tr>`;
pag.innerHTML = '';
return;
}
tbody.innerHTML = rows.map(r => {
const canPay = r.status === 'pending_payment';
return `
<tr>
<td class="td--mono">NR-${r.id}</td>
<td>${new Date(r.created_at).toLocaleString('es-MX')}</td>
<td class="td--primary">${r.customer_name || 'Público General'}</td>
<td>${r.employee_name || '-'}</td>
<td>${r.courier_name || '-'}</td>
<td class="td--amount" style="text-align:right">${fmt(r.total)}</td>
<td><span class="badge ${statusClass(r.status)}">${statusLabel(r.status)}</span></td>
<td style="text-align:right">
<div style="display:flex;justify-content:flex-end;gap:var(--space-2);">
<button class="action-btn action-btn--ghost" onclick="viewTicket(${r.id})" title="Imprimir">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</button>
${canPay ? `<button class="action-btn action-btn--primary" onclick="payNote(${r.id}, ${r.total})" title="Cobrar">
<svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M6 14h.01"/></svg>
</button>` : ''}
</div>
</td>
</tr>
`;
}).join('');
if (pagination.total_pages > 1) {
pag.innerHTML = `
<button class="page-btn" onclick="loadData(${pagination.page - 1})" ${pagination.page <= 1 ? 'disabled' : ''}>&larr;</button>
<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Página ${pagination.page} de ${pagination.total_pages}</span>
<button class="page-btn" onclick="loadData(${pagination.page + 1})" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>&rarr;</button>
`;
} else {
pag.innerHTML = '';
}
}
async function viewTicket(saleId) {
try {
const data = await api('/pos/api/sales/' + saleId + '/print-remission', { method: 'POST', body: '{}' });
const dateStr = new Date(data.date).toLocaleString('es-MX');
const itemsHtml = (data.items || []).map(it => `
<div class="ticket-preview__item"><span>${it.quantity} x ${it.name}</span><span>${fmt(it.subtotal)}</span></div>
`).join('');
document.getElementById('ticketContent').innerHTML = `
<div class="ticket-preview__center ticket-preview__bold">${data.business_name || 'NEXUS AUTOPARTS'}</div>
<div class="ticket-preview__center">${data.business_rfc || ''}</div>
<div class="ticket-preview__center">${data.business_address || ''}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__center ticket-preview__bold">NOTA DE REMISIÓN</div>
<div class="ticket-preview__center">${data.folio}</div>
<div class="ticket-preview__center">${dateStr}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__line"><span>Cliente:</span><span>${data.customer || 'Público General'}</span></div>
<div class="ticket-preview__line"><span>Vendedor:</span><span>${data.employee || '-'}</span></div>
${data.courier ? `<div class="ticket-preview__line"><span>Repartidor:</span><span>${data.courier}</span></div>` : ''}
<div class="ticket-preview__items">${itemsHtml}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__line"><span>Subtotal:</span><span>${fmt(data.subtotal)}</span></div>
${data.discount_total ? `<div class="ticket-preview__line"><span>Descuento:</span><span>-${fmt(data.discount_total)}</span></div>` : ''}
<div class="ticket-preview__line"><span>IVA:</span><span>${fmt(data.tax_total)}</span></div>
<div class="ticket-preview__line ticket-preview__bold"><span>TOTAL:</span><span>${fmt(data.total)}</span></div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__footer ticket-preview__bold">PENDIENTE DE PAGO</div>
<div class="ticket-preview__footer" style="font-size:0.75rem;opacity:0.8;">Presente esta nota en caja para pagar</div>
`;
document.getElementById('ticketModal').classList.add('is-open');
} catch (e) {
alert('Error: ' + e.message);
}
}
function closeTicketModal() {
document.getElementById('ticketModal').classList.remove('is-open');
}
function printTicket() {
const w = window.open('', '_blank');
w.document.write('<html><head><title>Nota de Remisión</title></head><body>' + document.getElementById('ticketContent').innerHTML + '</body></html>');
w.document.close();
w.print();
}
async function payNote(saleId, total) {
const method = prompt(`Cobrar nota NR-${saleId}\nTotal: ${fmt(total)}\n\nForma de pago: efectivo / transferencia / tarjeta`, 'efectivo');
if (!method) return;
const reference = ['transferencia', 'tarjeta'].includes(method) ? prompt('Referencia:') : '';
try {
await api('/pos/api/sales/' + saleId + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: method,
amount_paid: total,
reference: reference || ''
})
});
alert('Nota cobrada correctamente');
loadData(currentPage);
} catch (e) {
alert('Error al cobrar: ' + e.message);
}
}
(async function init() {
if (!token) {
document.getElementById('remissionTableBody').innerHTML = `<tr><td colspan="8">
<div class="empty-state">
<div class="empty-state__title">Inicia sesión</div>
<div class="empty-state__subtitle">Se requiere autenticación para ver las notas de remisión.</div>
</div>
</td></tr>`;
return;
}
await loadCouriers();
loadData(1);
})();
</script>
</body>
</html>

View File

@@ -229,6 +229,14 @@
</svg>
Histórico
</button>
<button class="tab-btn" onclick="switchTab('cortes', this)">
<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="1" y="3" width="13" height="10" rx="1"/>
<path d="M4 7h7M4 10h5"/>
<circle cx="11" cy="10" r="1.5" fill="currentColor"/>
</svg>
Cortes de caja
</button>
</div>
<!-- ==================================================================
@@ -355,6 +363,38 @@
<!-- Sales detail table -->
<div class="table-card mb-5" id="historico-detalle"></div>
</div>
<!-- ==================================================================
TAB 6: CORTES DE CAJA
================================================================== -->
<div class="tab-panel" id="panel-cortes">
<!-- Filter Bar -->
<div class="filter-bar">
<span class="filter-bar__label">Desde</span>
<input type="date" class="filter-input" id="cortes-date-from" />
<span class="filter-bar__label">Hasta</span>
<input type="date" class="filter-input" id="cortes-date-to" />
<span id="cortes-employee-filter">
<span class="filter-bar__label">Cajero</span>
<select class="filter-select" id="cortes-employee">
<option value="">Todos</option>
</select>
</span>
<div class="filter-bar__spacer"></div>
<button class="btn btn-primary btn-sm" onclick="Reports.loadCortes()">Generar</button>
</div>
<!-- KPI Cards (dynamic) -->
<div class="kpi-grid" id="cortes-kpis"></div>
<!-- Cortes detail table -->
<div class="table-card mb-5" id="cortes-detalle"></div>
<!-- Detail of sales for the selected cash cut -->
<div class="table-card mb-5" id="corte-ventas-detalle" style="display:none;"></div>
</div>
<!-- End panels -->
@@ -365,12 +405,12 @@
</div>
<!-- End app-shell -->
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/reports.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/reports.js?v=35" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -126,8 +126,8 @@
</div>
</div>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/supplier_catalog.js?v=33" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>

View File

@@ -131,12 +131,12 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
</script>
<!-- Sidebar -->
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/whatsapp2.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>

View File

@@ -15,7 +15,7 @@
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=42">
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=44">
<style>
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
@@ -55,7 +55,7 @@
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
</div>
<div class="summary-card__body">
<div class="summary-card__label">Recibidos</div>
<div class="summary-card__label">Por revisar</div>
<div class="summary-card__value" id="statReceived">--</div>
</div>
</div>
@@ -73,7 +73,7 @@
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
</div>
<div class="summary-card__body">
<div class="summary-card__label">Listos</div>
<div class="summary-card__label">Por entregar</div>
<div class="summary-card__value" id="statReady">--</div>
</div>
</div>
@@ -96,20 +96,28 @@
</select>
<select class="form-input" id="filterStatus">
<option value="">Todos los estatus</option>
<option value="received">Recibido</option>
<option value="diagnosis">Diagn&oacute;stico</option>
<option value="waiting_parts">Espera refacciones</option>
<option value="repair">En reparaci&oacute;n</option>
<option value="quality_check">Control calidad</option>
<option value="ready">Listo</option>
<option value="delivered">Entregado</option>
<option value="cancelled">Cancelado</option>
<option value="por_revisar">Por revisar</option>
<option value="en_revision">En revisi&oacute;n</option>
<option value="revisada">Revisada</option>
<option value="cotizada">Cotizada</option>
<option value="por_autorizar">Por autorizar</option>
<option value="autorizada">Autorizada</option>
<option value="autorizacion_parcial">Autorizaci&oacute;n parcial</option>
<option value="en_reparacion">En reparaci&oacute;n</option>
<option value="reparada">Reparada</option>
<option value="por_entregar">Por entregar</option>
<option value="entregado">Entregado</option>
<option value="por_enviar">Por enviar</option>
<option value="enviado">Enviado</option>
<option value="por_facturar">Por facturar</option>
<option value="facturada">Facturada</option>
<option value="por_recolectar">Por recolectar</option>
<option value="cancelada">Cancelada</option>
</select>
<select class="form-input" id="filterDelivery">
<option value="">Todas las v&iacute;as de entrega</option>
<option value="pickup">Pasa cliente</option>
<option value="pickup">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
<label class="toolbar-toggle">
<input type="checkbox" id="filterDirect" />
@@ -137,15 +145,16 @@
<tr>
<th>Sucursal</th>
<th>Orden</th>
<th>Cliente</th>
<th>Veh&iacute;culo</th>
<th class="restricted-hide">Cliente</th>
<th class="restricted-hide">Taller</th>
<th class="restricted-hide">Veh&iacute;culo</th>
<th>Estatus</th>
<th class="price-col">Total</th>
<th class="price-col restricted-hide">Total</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="listBody">
<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
<tr><td colspan="8" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
</tbody>
</table>
</div>
@@ -184,6 +193,10 @@
</div>
<div class="modal__body">
<form id="newOrderForm" class="form-grid">
<div class="form-field">
<label class="form-label" for="noBranch">Sucursal</label>
<select class="form-input" id="noBranch" required></select>
</div>
<div class="form-field">
<label class="form-label" for="noCustomer">Cliente</label>
<div style="display:flex;gap:var(--space-2);">
@@ -192,29 +205,26 @@
</div>
</div>
<div class="form-field">
<label class="form-label" for="noVehicle">Veh&iacute;culo</label>
<div style="display:flex;gap:var(--space-2);">
<select class="form-input" id="noVehicle" style="flex:1;"></select>
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewVehicleModalFromNewOrder()">+ Nuevo</button>
</div>
<label class="form-label" for="noWorkshopName">Taller</label>
<input class="form-input" id="noWorkshopName" placeholder="Nombre del taller" />
</div>
<div class="form-field">
<label class="form-label" for="noMechanic">Mec&aacute;nico asignado</label>
<select class="form-input" id="noMechanic"></select>
<label class="form-label" for="noCustomerPhone">Tel&eacute;fono</label>
<input class="form-input" id="noCustomerPhone" placeholder="3312345678" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="noCustomerAddress">Direcci&oacute;n</label>
<input class="form-input" id="noCustomerAddress" placeholder="Calle, n&uacute;mero, colonia" />
</div>
<div class="form-field">
<label class="form-label" for="noPriority">Prioridad</label>
<select class="form-input" id="noPriority">
<option value="normal">Normal</option>
<option value="high">Alta</option>
<option value="urgent">Urgente</option>
</select>
<label class="form-label" for="noVehicleDescription">Veh&iacute;culo</label>
<input class="form-input" id="noVehicleDescription" placeholder="Ej. Versa 2020" />
</div>
<div class="form-field">
<label class="form-label" for="noDelivery">V&iacute;a de entrega</label>
<select class="form-input" id="noDelivery">
<option value=""></option>
<option value="pickup">Pasa cliente</option>
<option value="pickup">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
@@ -224,21 +234,31 @@
<select class="form-input" id="noCourier"></select>
</div>
<div class="form-field">
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
<label class="form-label" for="noMechanic">Mec&aacute;nico asignado (usuario)</label>
<select class="form-input" id="noMechanic"></select>
</div>
<div class="form-field">
<label class="form-label" for="noMileage">Kilometraje</label>
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
<label class="form-label" for="noMechanicName">Mec&aacute;nico asignado (nombre libre)</label>
<input class="form-input" type="text" id="noMechanicName" placeholder="Ej. Juan Pérez" />
</div>
<div class="form-field form-field--span2">
<div class="form-field">
<label class="form-label" for="noEstimatedCost">Presupuesto</label>
<input class="form-input" type="number" id="noEstimatedCost" step="0.01" placeholder="0.00" />
</div>
<div class="form-field">
<label class="toolbar-toggle">
<input type="checkbox" id="noRequiresInvoice" />
<span>Requiere factura</span>
</label>
</div>
<div class="form-field">
<label class="toolbar-toggle">
<input type="checkbox" id="noDirect" />
<span>Orden directa (sin inventario)</span>
<span>Orden directa</span>
</label>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="noNotes">Notas de recepci&oacute;n</label>
<label class="form-label" for="noNotes">Observaciones</label>
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div>
</form>
@@ -270,63 +290,59 @@
</div>
<input type="hidden" id="eoCustomerId" />
</div>
<div class="form-field form-field--span2" style="position:relative;">
<label class="form-label" for="eoVehicleSearch">Veh&iacute;culo</label>
<div style="display:flex;gap:var(--space-2);">
<div style="position:relative;flex:1;">
<input class="form-input" id="eoVehicleSearch" autocomplete="off" placeholder="Buscar por placa..." oninput="Workshop.searchVehiclesForSO()" />
<div id="eoVehicleResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:180px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>
</div>
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewVehicleModal('edit')">+ Nuevo</button>
</div>
<input type="hidden" id="eoVehicleId" />
</div>
<div class="form-field">
<label class="form-label" for="eoBranch">Sucursal</label>
<select class="form-input" id="eoBranch"></select>
</div>
<div class="form-field">
<label class="form-label" for="eoMechanic">Mec&aacute;nico asignado</label>
<select class="form-input" id="eoMechanic"><option value="">— Ninguno —</option></select>
<label class="form-label" for="eoWorkshopName">Taller</label>
<input class="form-input" id="eoWorkshopName" placeholder="Nombre del taller" />
</div>
<div class="form-field">
<label class="form-label" for="eoPriority">Prioridad</label>
<select class="form-input" id="eoPriority">
<option value="low">Baja</option>
<option value="normal">Normal</option>
<option value="high">Alta</option>
<option value="urgent">Urgente</option>
</select>
</div>
<div class="form-field">
<label class="form-label" for="eoFuelLevel">Nivel de combustible</label>
<select class="form-input" id="eoFuelLevel">
<option value=""></option>
<option value="empty">Vac&iacute;o</option>
<option value="quarter">1/4</option>
<option value="half">1/2</option>
<option value="three_quarters">3/4</option>
<option value="full">Lleno</option>
</select>
</div>
<div class="form-field">
<label class="form-label" for="eoMileageIn">Kilometraje entrada</label>
<input class="form-input" type="number" id="eoMileageIn" />
</div>
<div class="form-field">
<label class="form-label" for="eoMileageOut">Kilometraje salida</label>
<input class="form-input" type="number" id="eoMileageOut" />
</div>
<div class="form-field">
<label class="form-label" for="eoEstimatedCompletion">Entrega estimada</label>
<input class="form-input" type="datetime-local" id="eoEstimatedCompletion" />
</div>
<div class="form-field">
<label class="form-label" for="eoEstimatedCost">Costo estimado</label>
<input class="form-input" type="number" id="eoEstimatedCost" step="0.01" />
<label class="form-label" for="eoCustomerPhone">Tel&eacute;fono</label>
<input class="form-input" id="eoCustomerPhone" placeholder="3312345678" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="eoNotes">Notas de recepci&oacute;n</label>
<label class="form-label" for="eoCustomerAddress">Direcci&oacute;n</label>
<input class="form-input" id="eoCustomerAddress" placeholder="Calle, n&uacute;mero, colonia" />
</div>
<div class="form-field">
<label class="form-label" for="eoVehicleDescription">Veh&iacute;culo</label>
<input class="form-input" id="eoVehicleDescription" placeholder="Ej. Versa 2020" />
</div>
<div class="form-field">
<label class="form-label" for="eoDelivery">V&iacute;a de entrega</label>
<select class="form-input" id="eoDelivery">
<option value=""></option>
<option value="pickup">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
</div>
<div class="form-field" id="eoCourierField" style="display:none;">
<label class="form-label" for="eoCourier">Motociclista</label>
<select class="form-input" id="eoCourier"></select>
</div>
<div class="form-field">
<label class="form-label" for="eoEstimatedCost">Presupuesto</label>
<input class="form-input" type="number" id="eoEstimatedCost" step="0.01" />
</div>
<div class="form-field">
<label class="form-label" for="eoMechanic">Mec&aacute;nico asignado (usuario)</label>
<select class="form-input" id="eoMechanic"></select>
</div>
<div class="form-field">
<label class="form-label" for="eoMechanicName">Mec&aacute;nico asignado (nombre libre)</label>
<input class="form-input" type="text" id="eoMechanicName" placeholder="Ej. Juan Pérez" />
</div>
<div class="form-field">
<label class="toolbar-toggle">
<input type="checkbox" id="eoRequiresInvoice" />
<span>Requiere factura</span>
</label>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="eoNotes">Observaciones</label>
<textarea class="form-input" id="eoNotes" rows="3"></textarea>
</div>
</form>
@@ -477,13 +493,13 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=36" defer></script>
<script src="/pos/static/js/app-init.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=5" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/workshop.js?v=42" defer></script>
<script src="/pos/static/js/workshop.js?v=55" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>

View File

@@ -0,0 +1,79 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const http = require('http');
function apiLogin() {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
grant_type: 'password',
client_secret: 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
client_id: 2,
username: 'IVAN@flechasyventiladores-rached.com',
password: 'Nexus01'
});
const req = http.request({
hostname: 'appapi.flechasyventiladores-rached.com',
path: '/api/seguridad/login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'http://app.flechasyventiladores-rached.com',
'Referer': 'http://app.flechasyventiladores-rached.com/',
'Content-Length': Buffer.byteLength(payload)
}
}, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
(async () => {
const outDir = path.resolve(__dirname, '../data/rached_import/har');
fs.mkdirSync(outDir, { recursive: true });
const loginResp = await apiLogin();
const sesion = JSON.stringify(loginResp.datos);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const allRequests = [];
await page.route('**/*', route => {
const req = route.request();
allRequests.push({ method: req.method(), url: req.url(), headers: req.headers(), postData: req.postData() });
route.continue();
});
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
await page.evaluate(s => { localStorage.setItem('sesion', s); }, sesion);
const routes = [
'/administracion/servicios/ordenesservicio',
'/administracion/catalogos/clientes',
'/administracion/catalogos/articulos',
'/administracion/catalogos/mecanicos',
'/administracion/catalogos/sucursales',
'/administracion/catalogos/motociclistas',
'/administracion/catalogos/viasentrega',
'/administracion/catalogos/telefonostipos',
'/administracion/catalogos/direccionestipos',
];
for (const r of routes) {
await page.goto('http://app.flechasyventiladores-rached.com' + r, { waitUntil: 'networkidle' });
await page.waitForTimeout(5000);
}
fs.writeFileSync(path.join(outDir, 'all_requests.json'), JSON.stringify(allRequests, null, 2));
await page.screenshot({ path: path.join(outDir, 'last_screen.png'), fullPage: true });
await browser.close();
console.log('Captured', allRequests.length, 'total requests');
})();

View File

@@ -0,0 +1,442 @@
#!/usr/bin/env python3
"""Import Rached legacy workshop data into the Nexus tenant_refaccionaria_rached DB."""
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
import psycopg2
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / 'data' / 'rached_import'
TENANT_DB = 'tenant_refaccionaria_rached'
# Load DB URL from environment or use localhost defaults
DB_URL = os.environ.get(
'TENANT_DB_URL',
f'postgresql://postgres@localhost/{TENANT_DB}'
)
def normalize_name(s):
if not s:
return ''
return re.sub(r'[^a-z0-9]', '', s.lower().replace('sucursal', '').strip())
def load_json(name):
with open(DATA_DIR / f'{name}.json', encoding='utf-8') as f:
return json.load(f)
def load_detail_json(cid):
path = DATA_DIR / 'client_details' / f'client_{cid}.json'
if path.exists():
with open(path, encoding='utf-8') as f:
return json.load(f)
return None
def connect():
return psycopg2.connect(DB_URL)
ORDER_STATUS_MAP = {
1: 'por_recolectar',
2: 'por_revisar',
3: 'en_revision',
4: 'revisada',
5: 'cotizada',
6: 'por_autorizar',
7: 'autorizada',
8: 'autorizacion_parcial',
9: 'cancelada',
10: 'en_reparacion',
11: 'reparada',
12: 'por_entregar',
13: 'enviado',
14: 'entregado',
15: 'por_facturar',
16: 'facturada',
17: 'por_enviar',
}
ITEM_STATUS_MAP = {
1: 'por_revisar',
2: 'revisando',
3: 'revisado',
4: 'cotizado',
5: 'por_autorizar',
6: 'autorizado',
7: 'cancelado',
8: 'en_reparacion',
9: 'reparado',
10: 'por_entregar',
11: 'enviado',
12: 'entregado',
13: 'por_enviar',
}
def get_default_branch(cur):
cur.execute("SELECT id FROM branches WHERE is_main = true LIMIT 1")
row = cur.fetchone()
if row:
return row[0]
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
return cur.fetchone()[0]
def ensure_branches(cur):
cur.execute("SELECT id, name FROM branches")
existing = {normalize_name(name): id for id, name in cur.fetchall()}
catalog = load_json('catalog_sucursales')['datos']
mapping = {}
for s in catalog:
name = (s.get('nombre') or '').strip()
# Skip the erroneous Rached branch
if name.upper() == 'CREMALLERAS':
continue
key = normalize_name(name)
if key in existing:
mapping[s['id']] = existing[key]
else:
cur.execute(
"INSERT INTO branches (name, is_active) VALUES (%s, true) RETURNING id",
(s['nombre'],)
)
new_id = cur.fetchone()[0]
existing[key] = new_id
mapping[s['id']] = new_id
print(f"Created branch {s['nombre']} -> {new_id}")
return mapping
def ensure_customers(cur, branch_map):
# collect unique client ids referenced in orders
client_ids = set()
for f in DATA_DIR.glob('details/order_*.json'):
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
cid = d.get('idCliente')
if cid:
client_ids.add(cid)
# load existing customers by name
cur.execute("SELECT id, name FROM customers")
existing_by_name = {name.strip().lower(): id for id, name in cur.fetchall()}
client_map = {}
default_branch = get_default_branch(cur)
for cid in sorted(client_ids):
detail = load_detail_json(cid)
datos = detail.get('datos', {}) if detail else {}
# Name: prefer 'taller' if meaningful, else full name
taller = (datos.get('taller') or '').strip()
nombre = (datos.get('nombre') or '').strip()
ap1 = (datos.get('primerApellido') or '').strip()
ap2 = (datos.get('segundoApellido') or '').strip()
if taller:
name = taller
else:
name = ' '.join([nombre, ap1, ap2]).strip()
if not name:
name = f"Cliente Rached {cid}"
if name.lower() in existing_by_name:
client_map[cid] = existing_by_name[name.lower()]
continue
# phone / address
phone = None
for t in datos.get('telefonos', []):
num = t.get('numero')
if num:
phone = str(num)
break
address = None
for drec in datos.get('direcciones', []):
address = drec.get('completa')
if address:
break
cur.execute(
"""
INSERT INTO customers (branch_id, name, phone, address, price_tier, is_active)
VALUES (%s, %s, %s, %s, 2, true) RETURNING id
""",
(default_branch, name, phone, address)
)
new_id = cur.fetchone()[0]
existing_by_name[name.lower()] = new_id
client_map[cid] = new_id
print(f"Created customer {cid}: {name}")
return client_map
def ensure_employees(cur, branch_map, role, catalog, existing_names=None):
default_branch = get_default_branch(cur)
emp_map = {}
if existing_names is None:
cur.execute("SELECT id, name FROM employees")
existing_names = {name.strip().lower(): id for id, name in cur.fetchall()}
for e in catalog:
name = (e.get('nombre') or '').strip()
if not name:
continue
key = name.lower()
if key in existing_names:
emp_map[e['id']] = existing_names[key]
continue
is_active = bool(e.get('activo', True))
cur.execute(
"""
INSERT INTO employees (name, role, branch_id, is_active)
VALUES (%s, %s, %s, %s) RETURNING id
""",
(name, role, default_branch, is_active)
)
new_id = cur.fetchone()[0]
existing_names[key] = new_id
emp_map[e['id']] = new_id
print(f"Created employee {role} {e['id']}: {name}")
return emp_map
def ensure_couriers(cur, catalog, tenant_id=31):
cur.execute("SELECT id, name FROM couriers WHERE tenant_id = %s", (tenant_id,))
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
courier_map = {}
for c in catalog:
name = (c.get('nombre') or '').strip()
if not name:
continue
key = name.lower()
if key in existing:
courier_map[c['id']] = existing[key]
continue
cur.execute(
"INSERT INTO couriers (tenant_id, name, code, is_active) VALUES (%s, %s, %s, true) RETURNING id",
(tenant_id, name, f"MOT-{c['id']}")
)
new_id = cur.fetchone()[0]
existing[key] = new_id
courier_map[c['id']] = new_id
print(f"Created courier {c['id']}: {name}")
return courier_map
def ensure_articles(cur, branch_map):
"""Rached articles are kept as free-text lines, not inventory products."""
catalog = load_json('catalog_articulos')
article_map = {}
for a in catalog:
name = (a.get('nombre') or '').strip()
if name:
article_map[a['id']] = name
return article_map
def ensure_users(cur, branch_map, order_details):
# Map Rached user ids to employees
default_branch = get_default_branch(cur)
cur.execute("SELECT id, name FROM employees")
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
user_map = {}
for d in order_details:
datos = d.get('datos', {})
uid = datos.get('idUsuario')
name = (datos.get('usuarioNombre') or '').strip()
if not uid or not name:
continue
key = name.lower()
if key in existing:
user_map[uid] = existing[key]
continue
cur.execute(
"INSERT INTO employees (name, role, branch_id, is_active) VALUES (%s, %s, %s, true) RETURNING id",
(name, 'workshop', default_branch)
)
new_id = cur.fetchone()[0]
existing[key] = new_id
user_map[uid] = new_id
print(f"Created user {uid}: {name}")
return user_map
def import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map):
order_files = sorted(DATA_DIR.glob('details/order_*.json'))
imported = 0
skipped = 0
for f in order_files:
d = json.loads(f.read_text(encoding='utf-8'))
datos = d.get('datos', {})
order_number = datos.get('numero')
if not order_number:
continue
cur.execute("SELECT id FROM service_orders WHERE order_number = %s", (order_number,))
if cur.fetchone():
skipped += 1
continue
branch_id = branch_map.get(datos.get('idSucursal'))
customer_id = client_map.get(datos.get('idCliente'))
status = ORDER_STATUS_MAP.get(datos.get('idEstatus'), 'por_revisar')
# delivery method
via = datos.get('idViaEntrega')
mot_rec = datos.get('idMotociclistaRecoleccion')
mot_ent = datos.get('idMotociclistaEntrega')
courier_id = None
if via == 1:
delivery_method = 'pickup'
elif via == 2:
if mot_ent:
delivery_method = 'courier'
courier_id = courier_map.get(mot_ent)
elif mot_rec:
delivery_method = 'courier'
courier_id = courier_map.get(mot_rec)
else:
delivery_method = 'delivery'
else:
delivery_method = None
# customer address/phone from order overrides if present? order detail only has ids.
# Prefill from customer record (already stored in customers)
workshop_name = None
customer_phone = None
customer_address = None
if customer_id:
cur.execute("SELECT name, phone, address FROM customers WHERE id = %s", (customer_id,))
row = cur.fetchone()
if row:
workshop_name, customer_phone, customer_address = row
created_by = user_map.get(datos.get('idUsuario'))
fecha = datos.get('fecha')
created_at = datetime.strptime(fecha, '%Y-%m-%d %H:%M:%S') if fecha else datetime.now()
cur.execute(
"""
INSERT INTO service_orders
(tenant_id, branch_id, customer_id, order_number, status,
workshop_name, customer_address, customer_phone, vehicle_description,
reception_notes, estimated_cost, final_cost,
delivery_method, courier_id, is_direct, requires_invoice,
created_by, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
31, # tenant_id for Rached
branch_id,
customer_id,
order_number,
status,
workshop_name,
customer_address,
customer_phone,
datos.get('vehiculo'),
datos.get('observaciones'),
datos.get('presupuesto') or 0,
datos.get('total') or 0,
delivery_method,
courier_id,
bool(datos.get('ordenDirecta')),
bool(datos.get('requiereFactura')),
created_by,
created_at,
created_at,
)
)
so_id = cur.fetchone()[0]
# status history
cur.execute(
"""
INSERT INTO service_order_status_history (service_order_id, new_status, changed_by, notes, created_at)
VALUES (%s, %s, %s, %s, %s)
""",
(so_id, status, created_by, 'Importado desde app Rached', created_at)
)
# items (free-text lines; not linked to inventory)
for it in datos.get('detalles', []):
articulo = it.get('articulo', {})
art_id = articulo.get('id')
art_name = article_map.get(art_id) or articulo.get('nombre') or 'Concepto'
mech_id = mech_map.get(it.get('idMecanico'))
item_status = ITEM_STATUS_MAP.get(it.get('idEstatusDetalle'), 'por_revisar')
qty = it.get('cantidad', 1)
price = it.get('precio') or 0
cur.execute(
"""
INSERT INTO service_order_items
(service_order_id, inventory_id, part_number, name, quantity,
unit_cost, unit_price, status, mechanic_id, observations)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
so_id,
None,
None,
art_name,
qty,
price,
price,
item_status,
mech_id,
it.get('observaciones'),
)
)
imported += 1
if imported % 100 == 0:
print(f"Imported {imported} orders...")
return imported, skipped
def main():
print(f"Importing Rached workshop data into {TENANT_DB}")
conn = connect()
cur = conn.cursor()
try:
branch_map = ensure_branches(cur)
print(f"Branch map: {branch_map}")
client_map = ensure_customers(cur, branch_map)
print(f"Customers to import: {len(client_map)}")
mech_catalog = load_json('catalog_mecanicos')
mech_map = ensure_employees(cur, branch_map, 'mechanic', mech_catalog)
print(f"Mechanics map: {len(mech_map)}")
courier_catalog = load_json('catalog_motociclistas')
courier_map = ensure_couriers(cur, courier_catalog, tenant_id=31)
print(f"Couriers map: {len(courier_map)}")
article_map = ensure_articles(cur, branch_map)
print(f"Articles map: {len(article_map)}")
order_details = [json.loads(f.read_text(encoding='utf-8')) for f in DATA_DIR.glob('details/order_*.json')]
user_map = ensure_users(cur, branch_map, order_details)
print(f"Users map: {len(user_map)}")
imported, skipped = import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map)
print(f"Imported: {imported}, Skipped (already exist): {skipped}")
conn.commit()
except Exception as e:
conn.rollback()
print(f"ERROR: {e}")
raise
finally:
cur.close()
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,32 @@
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
await page.evaluate(() => {
document.getElementById('correo').removeAttribute('pattern');
document.getElementById('password').removeAttribute('pattern');
});
await page.fill('#correo', 'IVAN@flechasyventiladores-rached.com');
await page.fill('#password', 'Nexus01');
await page.evaluate(() => {
['correo','password'].forEach(id => {
const el = document.getElementById(id);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
});
});
await page.waitForTimeout(1000);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/login_filled.png', fullPage: true });
fs.writeFileSync('/home/Autopartes/data/rached_import/login_filled.html', await page.content());
// click enabled submit button if any
await page.click('button[color="primary"]');
await page.waitForTimeout(8000);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/after_login.png', fullPage: true });
fs.writeFileSync('/home/Autopartes/data/rached_import/after_login.html', await page.content());
console.log('done');
await browser.close();
})();

View File

@@ -0,0 +1,14 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
await page.waitForTimeout(5000);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/login.png', fullPage: true });
fs.writeFileSync('/home/Autopartes/data/rached_import/login.html', await page.content());
console.log('done');
await browser.close();
})();

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Scrape Rached legacy catalog endpoints discovered via UI."""
import json
import os
import time
import requests
from pathlib import Path
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
USER = 'IVAN@flechasyventiladores-rached.com'
PASS = 'Nexus01'
HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'http://app.flechasyventiladores-rached.com',
'Referer': 'http://app.flechasyventiladores-rached.com/',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}
def login():
url = f'{BASE_API}/api/seguridad/login'
payload = {
'grant_type': 'password',
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
'client_id': 2,
'username': USER,
'password': PASS,
}
resp = requests.post(url, headers=HEADERS, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get('mensaje'):
raise RuntimeError(f'Login failed: {data["mensaje"]}')
return data['datos']['token']
def api_headers(token):
return {**HEADERS, 'Authorization': f'Bearer {token}'}
def fetch_get(token, path, params=None):
url = f'{BASE_API}{path}'
resp = requests.get(url, headers=api_headers(token), params=params, timeout=30)
resp.raise_for_status()
return resp.json()
def fetch_post(token, path, payload=None, params=None):
url = f'{BASE_API}{path}'
resp = requests.post(url, headers=api_headers(token), json=payload or {}, params=params, timeout=30)
resp.raise_for_status()
return resp.json()
def save(name, data):
path = OUT_DIR / f'{name}.json'
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
return path
def fetch_list_all(token, path, method='GET', payload=None):
"""Fetch a list endpoint that may return either a list or Laravel pagination."""
all_items = []
page = 1
while True:
try:
if method == 'GET':
data = fetch_get(token, f'{path}?page={page}')
else:
data = fetch_post(token, f'{path}?page={page}', payload)
except Exception as e:
print(f' {path} page {page} error: {e}')
break
datos = data.get('datos', {})
if isinstance(datos, list):
# Non-paginated full list
all_items.extend(datos)
print(f' {path}: +{len(datos)} (total {len(all_items)})')
break
items = datos.get('data', [])
all_items.extend(items)
print(f' {path} page {page}: +{len(items)} (total {len(all_items)}/{datos.get("total", "?")})')
if datos.get('current_page', 1) >= datos.get('last_page', 1):
break
page += 1
time.sleep(0.6)
return all_items
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
log_path = OUT_DIR / 'scrape_catalogs.log'
def log(msg):
print(msg)
with open(log_path, 'a', encoding='utf-8') as f:
f.write(msg + '\n')
log('Logging in...')
token = login()
log('Logged in.')
# Simple catalog endpoints (GET, single page or paginated)
simple_gets = {
'catalog_sucursales': '/api/catalogos/sucursales',
'catalog_viasentrega': '/api/catalogos/viasentrega/listado',
'catalog_order_statuses': '/api/catalogos/ordenesservicioestatus/listado',
'catalog_item_statuses': '/api/catalogos/ordenesserviciodetalleestatus/listado',
'catalog_directas': '/api/catalogos/ordenesDirectas',
'catalog_telefonostipos': '/api/catalogos/telefonostipos/listado',
'catalog_direccionestipos': '/api/catalogos/direccionestipos/listado',
}
for name, path in simple_gets.items():
try:
data = fetch_get(token, path)
save(name, data)
count = len(data.get('datos', []) if isinstance(data.get('datos'), list) else [])
log(f'{name}: {count} items')
except Exception as e:
log(f'{name} error: {e}')
time.sleep(0.6)
# List endpoints (GET or POST)
list_gets = {
'catalog_articulos': '/api/catalogos/articulos/listado',
'catalog_mecanicos': '/api/catalogos/mecanicos/listado',
'catalog_motociclistas': '/api/catalogos/motociclistas/listado',
}
for name, path in list_gets.items():
items = fetch_list_all(token, path, method='GET')
save(name, items)
log(f'{name}: {len(items)} items')
# Client list
try:
client_items = fetch_list_all(token, '/api/catalogos/clientes/listado', method='POST',
payload={'taller': '', 'nombreCompleto': '', 'idClienteEstatus': '-1'})
save('catalog_clientes', client_items)
log(f'catalog_clientes: {len(client_items)} items')
# Fetch full client details for clients referenced in orders
orders_path = OUT_DIR / 'orders_list.json'
if orders_path.exists():
order_ids = {o['idCliente'] for o in json.loads(orders_path.read_text(encoding='utf-8')) if o.get('idCliente')}
log(f'Fetching details for {len(order_ids)} unique order clients...')
client_details = {}
for idx, cid in enumerate(sorted(order_ids), 1):
detail_path = OUT_DIR / 'client_details' / f'client_{cid}.json'
if detail_path.exists():
client_details[cid] = json.loads(detail_path.read_text(encoding='utf-8'))
continue
try:
data = fetch_get(token, f'/api/catalogos/clientes/{cid}')
client_details[cid] = data.get('datos')
detail_path.parent.mkdir(parents=True, exist_ok=True)
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
except Exception as e:
log(f' client {cid} detail error: {e}')
if idx % 30 == 0:
time.sleep(1)
else:
time.sleep(0.2)
save('catalog_client_details', client_details)
log(f'catalog_client_details: {len(client_details)} items')
except Exception as e:
log(f'catalog_clientes error: {e}')
log('Done.')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Fetch full client details for clients referenced in Rached service orders."""
import json
import os
import time
import requests
from pathlib import Path
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
PASS = 'Nexus01'
HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'http://app.flechasyventiladores-rached.com',
'Referer': 'http://app.flechasyventiladores-rached.com/',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
}
def login():
url = f'{BASE_API}/api/seguridad/login'
payload = {
'grant_type': 'password',
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
'client_id': 2,
'username': 'IVAN@flechasyventiladores-rached.com',
'password': PASS,
}
resp = requests.post(url, headers=HEADERS, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get('mensaje'):
raise RuntimeError(f'Login failed: {data["mensaje"]}')
return data['datos']['token']
def fetch_client(token, cid):
url = f'{BASE_API}/api/catalogos/clientes/{cid}'
resp = requests.get(url, headers={**HEADERS, 'Authorization': f'Bearer {token}'}, timeout=30)
resp.raise_for_status()
return resp.json()
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
detail_dir = OUT_DIR / 'client_details'
detail_dir.mkdir(exist_ok=True)
# collect unique client ids from order details
client_ids = set()
for f in OUT_DIR.glob('details/order_*.json'):
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
cid = d.get('idCliente')
if cid:
client_ids.add(cid)
print(f'Found {len(client_ids)} unique clients from order details')
token = login()
print('Logged in')
results = {}
for idx, cid in enumerate(sorted(client_ids), 1):
out_path = detail_dir / f'client_{cid}.json'
if out_path.exists():
data = json.loads(out_path.read_text(encoding='utf-8'))
else:
try:
data = fetch_client(token, cid)
except Exception as e:
print(f'[{idx}/{len(client_ids)}] client {cid} error: {e}')
continue
out_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
results[cid] = data.get('datos')
print(f'[{idx}/{len(client_ids)}] client {cid} saved')
time.sleep(0.4 if idx % 30 else 1.5)
summary_path = OUT_DIR / 'catalog_client_details.json'
summary_path.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8')
print(f'Saved {len(results)} client details to {summary_path}')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Retry fetching missing Rached client details with slower rate."""
import json
import os
import time
import requests
from pathlib import Path
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
PASS = 'Nexus01'
HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'http://app.flechasyventiladores-rached.com',
'Referer': 'http://app.flechasyventiladores-rached.com/',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
}
def login():
url = f'{BASE_API}/api/seguridad/login'
payload = {
'grant_type': 'password',
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
'client_id': 2,
'username': 'IVAN@flechasyventiladores-rached.com',
'password': PASS,
}
resp = requests.post(url, headers=HEADERS, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get('mensaje'):
raise RuntimeError(f'Login failed: {data["mensaje"]}')
return data['datos']['token']
def fetch_client(token, cid, attempts=5):
url = f'{BASE_API}/api/catalogos/clientes/{cid}'
for attempt in range(attempts):
resp = requests.get(url, headers={**HEADERS, 'Authorization': f'Bearer {token}'}, timeout=30)
if resp.status_code == 429:
wait = 5 + attempt * 3
print(f' client {cid} 429, waiting {wait}s')
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError(f'client {cid} rate limited')
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
detail_dir = OUT_DIR / 'client_details'
detail_dir.mkdir(exist_ok=True)
needed = set()
for f in OUT_DIR.glob('details/order_*.json'):
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
cid = d.get('idCliente')
if cid:
needed.add(cid)
saved = set(int(p.name.split('_')[1].split('.')[0]) for p in detail_dir.glob('client_*.json'))
missing = sorted(needed - saved)
print(f'Need {len(needed)}, have {len(saved)}, missing {len(missing)}')
if not missing:
return
token = login()
results = json.loads((OUT_DIR / 'catalog_client_details.json').read_text(encoding='utf-8')) if (OUT_DIR / 'catalog_client_details.json').exists() else {}
for idx, cid in enumerate(missing, 1):
try:
data = fetch_client(token, cid)
except Exception as e:
print(f'[{idx}/{len(missing)}] client {cid} final error: {e}')
continue
detail_path = detail_dir / f'client_{cid}.json'
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
results[str(cid)] = data.get('datos')
print(f'[{idx}/{len(missing)}] client {cid} saved')
time.sleep(1.2)
(OUT_DIR / 'catalog_client_details.json').write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding='utf-8')
print(f'Saved {len(results)} client details')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env node
/**
* Scrape service orders from Rached's legacy web app.
* Run with:
* RACHED_USER='IVAN' RACHED_PASS='Nexus01' node scripts/scrape_rached_workshop.js
*/
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const OUT_DIR = process.env.OUT_DIR || path.join(__dirname, '..', 'data', 'rached_import');
const USER = process.env.RACHED_USER || 'IVAN';
const PASS = process.env.RACHED_PASS || 'Nexus01';
const BASE_APP = 'http://app.flechasyventiladores-rached.com';
const BASE_API = 'http://appapi.flechasyventiladores-rached.com';
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1440, height: 900 }
});
const page = await context.newPage();
// ── LOGIN ──
console.log('Logging in...');
await page.goto(`${BASE_APP}/login`);
await page.waitForSelector('#correo', { timeout: 10000 });
await page.fill('#correo', USER);
await page.fill('#password', PASS);
await page.click('button:has-text("Aceptar")');
await page.waitForURL('**/administracion/**', { timeout: 20000 });
console.log('Logged in.');
// ── ORDERS LIST ──
console.log('Navigating to orders list...');
const allOrders = [];
let pageNum = 1;
let respPromise = page.waitForResponse(
r => r.url().includes('/api/servicios/ordenes/listado') && r.status() === 200,
{ timeout: 30000 }
);
await page.goto(`${BASE_APP}/administracion/servicios/ordenesservicio`);
while (true) {
if (pageNum > 1) {
const nextBtn = page.locator('button.mat-paginator-navigation-next');
const isDisabled = await nextBtn.evaluate(el => el.disabled).catch(() => true);
if (isDisabled) {
console.log('No more pages.');
break;
}
respPromise = page.waitForResponse(
r => r.url().includes('/api/servicios/ordenes/listado') && r.status() === 200,
{ timeout: 30000 }
);
await nextBtn.click();
}
const resp = await respPromise;
const payload = await resp.json();
const datos = payload.datos || {};
const orders = datos.data || [];
allOrders.push(...orders);
fs.writeFileSync(
path.join(OUT_DIR, `orders_page_${pageNum}.json`),
JSON.stringify(payload, null, 2)
);
console.log(`Page ${pageNum}: ${orders.length} orders (total ${allOrders.length}/${datos.total || '?'})`);
if (datos.current_page >= datos.last_page) break;
pageNum++;
await sleep(800);
}
fs.writeFileSync(
path.join(OUT_DIR, 'orders_list.json'),
JSON.stringify(allOrders, null, 2)
);
console.log(`Collected ${allOrders.length} orders summary.`);
await page.waitForSelector('mat-row', { timeout: 10000 });
await sleep(3000);
if (!allOrders.length) {
await browser.close();
return;
}
// ── ORDER DETAILS ──
const detailDir = path.join(OUT_DIR, 'details');
fs.mkdirSync(detailDir, { recursive: true });
let targetId = null;
await page.route('**/api/servicios/ordenes/*', async (route, request) => {
if (targetId && request.url().match(/\/ordenes\/\d+$/)) {
const url = new URL(request.url());
const parts = url.pathname.split('/');
parts[parts.length - 1] = String(targetId);
url.pathname = parts.join('/');
await route.continue({ url: url.toString() });
return;
}
await route.continue();
});
async function clickFirstEditButton() {
await page.evaluate(() => {
const row = document.querySelector('mat-row');
if (!row) return;
const btn = row.querySelector('button.mat-icon-button i.fa-edit');
if (btn) btn.closest('button').click();
});
}
const limit = parseInt(process.env.LIMIT || '0', 10);
const ordersToFetch = limit > 0 ? allOrders.slice(0, limit) : allOrders;
for (let i = 0; i < ordersToFetch.length; i++) {
const o = ordersToFetch[i];
targetId = o.id;
const detailPath = path.join(detailDir, `order_${targetId}.json`);
if (fs.existsSync(detailPath)) {
process.stdout.write(`\rDetail ${i + 1}/${ordersToFetch.length}: ${targetId} (already saved) `);
continue;
}
let attempts = 0;
while (attempts < 3) {
attempts++;
try {
const respPromise = page.waitForResponse(
r => r.url().includes(`/api/servicios/ordenes/${targetId}`) && r.status() === 200,
{ timeout: 20000 }
);
await clickFirstEditButton();
const resp = await respPromise;
const detail = await resp.json();
fs.writeFileSync(
path.join(detailDir, `order_${targetId}.json`),
JSON.stringify(detail, null, 2)
);
process.stdout.write(`\rDetail ${i + 1}/${ordersToFetch.length}: ${targetId} `);
// close modal if opened
await page.keyboard.press('Escape').catch(() => {});
await sleep(300);
break;
} catch (e) {
console.error(`\nError fetching detail for ${targetId} (attempt ${attempts}):`, e.message);
await page.keyboard.press('Escape').catch(() => {});
await sleep(1000);
}
}
}
console.log('\nDetail extraction complete.');
await browser.close();
console.log(`Output saved to ${OUT_DIR}`);
}
main().catch(e => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Scrape service orders from Rached legacy web app using their internal API."""
import json
import os
import sys
import time
import requests
from pathlib import Path
BASE_APP = 'http://app.flechasyventiladores-rached.com'
BASE_API = 'http://appapi.flechasyventiladores-rached.com'
OUT_DIR = Path(os.environ.get('OUT_DIR', Path(__file__).resolve().parent.parent / 'data' / 'rached_import'))
USER = os.environ.get('RACHED_USER', 'IVAN')
PASS = os.environ.get('RACHED_PASS', 'Nexus01')
LIMIT = int(os.environ.get('LIMIT', '0'))
HEADERS = {
'Accept': 'application/json, text/plain, */*',
'X-Requested-With': 'XMLHttpRequest',
'Origin': BASE_APP,
'Referer': f'{BASE_APP}/',
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}
def login():
url = f'{BASE_API}/api/seguridad/login'
payload = {
'grant_type': 'password',
'client_secret': 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
'client_id': 2,
'username': 'IVAN@flechasyventiladores-rached.com',
'password': PASS,
}
resp = requests.post(url, headers=HEADERS, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get('mensaje'):
raise RuntimeError(f'Login failed: {data["mensaje"]}')
return data['datos']['token']
def api_headers(token):
return {**HEADERS, 'Authorization': f'Bearer {token}'}
def fetch_list(token, page):
url = f'{BASE_API}/api/servicios/ordenes/listado?page={page}'
resp = requests.post(url, headers=api_headers(token))
resp.raise_for_status()
return resp.json()
def fetch_detail(token, order_id):
url = f'{BASE_API}/api/servicios/ordenes/{order_id}'
resp = requests.get(url, headers=api_headers(token))
resp.raise_for_status()
return resp.json()
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
log_path = OUT_DIR / 'scrape_py.log'
def log(msg):
print(msg)
with open(log_path, 'a', encoding='utf-8') as f:
f.write(msg + '\n')
log('Logging in...')
token = login()
log('Logged in.')
# ── List ──
list_path = OUT_DIR / 'orders_list.json'
if list_path.exists():
all_orders = json.loads(list_path.read_text(encoding='utf-8'))
log(f'Loaded {len(all_orders)} orders from existing list.')
else:
all_orders = []
page = 1
while True:
for attempt in range(3):
try:
data = fetch_list(token, page)
break
except Exception as e:
log(f'List page {page} attempt {attempt+1} error: {e}')
time.sleep(2)
else:
log(f'Failed to fetch list page {page} after 3 attempts.')
break
datos = data.get('datos', {})
orders = datos.get('data', [])
all_orders.extend(orders)
(OUT_DIR / f'orders_page_{page}.json').write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding='utf-8'
)
log(f'Page {page}: {len(orders)} orders (total {len(all_orders)}/{datos.get("total", "?")})')
if datos.get('current_page', 1) >= datos.get('last_page', 1):
break
page += 1
time.sleep(1)
list_path.write_text(json.dumps(all_orders, indent=2, ensure_ascii=False), encoding='utf-8')
log(f'Collected {len(all_orders)} orders summary.')
if not all_orders:
log('No orders to fetch details for.')
return
# ── Details ──
detail_dir = OUT_DIR / 'details'
detail_dir.mkdir(exist_ok=True)
target_orders = all_orders[:LIMIT] if LIMIT > 0 else all_orders
total = len(target_orders)
for i, o in enumerate(target_orders, start=1):
oid = o['id']
detail_path = detail_dir / f'order_{oid}.json'
if detail_path.exists():
log(f'[{i}/{total}] {oid} already saved')
continue
for attempt in range(3):
try:
data = fetch_detail(token, oid)
detail_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
log(f'[{i}/{total}] {oid} saved')
break
except Exception as e:
log(f'[{i}/{total}] {oid} attempt {attempt+1} error: {e}')
time.sleep(2)
else:
log(f'[{i}/{total}] {oid} FAILED after 3 attempts')
time.sleep(1)
log('Done.')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,25 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
await page.goto('/pos/login2?tenant=31');
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
await page.click('.user-avatar-btn[data-id="33"]');
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
await page.click('#btn-login');
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
await page.goto('/pos/sale');
await page.waitForTimeout(1500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/cashier_pos_default.png', fullPage: true });
console.log('Errors:', errors.length);
errors.forEach(e => console.log(' -', e));
await browser.close();
})();

View File

@@ -0,0 +1,37 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
await page.goto('/pos/login2?tenant=31');
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
await page.click('.user-avatar-btn[data-id="32"]');
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
await page.click('#btn-login');
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
await page.goto('/pos/catalog');
await page.waitForTimeout(1500);
const info = await page.evaluate(() => ({
title: document.title,
bodyClass: document.body.className,
scripts: Array.from(document.querySelectorAll('script[src]')).map(s => s.src).filter(s => s.includes('sidebar') || s.includes('app-init'))
}));
console.log('Page info:', JSON.stringify(info, null, 2));
await page.screenshot({ path: '/home/Autopartes/data/rached_import/counter_sidebar_catalog.png', fullPage: true });
// Try dashboard
await page.goto('/pos/dashboard');
await page.waitForTimeout(1500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/counter_dashboard_redirect.png', fullPage: true });
console.log('URL after dashboard:', page.url());
console.log('Errors:', errors.length);
errors.forEach(e => console.log(' -', e));
await browser.close();
})();

View File

@@ -0,0 +1,31 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
await page.goto('/pos/login2?tenant=31');
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
await page.click('.user-avatar-btn[data-id="1"]');
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
await page.click('#btn-login');
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
await page.goto('/pos/inventory');
await page.waitForTimeout(1500);
await page.click('button:has-text("Entradas")');
await page.waitForTimeout(500);
await page.click('button:has-text("Nueva Entrada")');
await page.waitForTimeout(500);
await page.fill('#purchaseItemSearch', 'CVBT2170');
await page.waitForTimeout(800);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/inventory_entrada_alias.png', fullPage: true });
console.log('Errors:', errors.length);
errors.forEach(e => console.log(' -', e));
await browser.close();
})();

View File

@@ -0,0 +1,25 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
await page.goto('/pos/login2?tenant=31');
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
await page.click('.user-avatar-btn[data-id="31"]');
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
await page.click('#btn-login');
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
await page.goto('/pos/sale');
await page.waitForTimeout(2000);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/pos_counter_debug.png', fullPage: true });
console.log('URL:', page.url());
console.log('Errors:', errors.length);
errors.forEach(e => console.log(' -', e));
await browser.close();
})();

View File

@@ -0,0 +1,28 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
await page.goto('/pos/login2?tenant=31');
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
await page.click('.user-avatar-btn[data-id="1"]');
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
await page.click('#btn-login');
await page.waitForURL('**/pos/catalog', { timeout: 10000 });
await page.goto('/pos/reports');
await page.waitForTimeout(1500);
await page.click('button:has-text("Cortes de caja")');
await page.waitForTimeout(1500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/reports_cortes_tab.png', fullPage: true });
console.log('Errors:', errors.length);
errors.forEach(e => console.log(' -', e));
await browser.close();
})();

View File

@@ -0,0 +1,35 @@
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
page.on('console', msg => { errors.push('CONSOLE(' + msg.type() + '): ' + msg.text()); });
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
await page.goto('/pos/login2?tenant=31');
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
await page.click('.user-avatar-btn[data-id="29"]');
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
await page.click('#btn-login');
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_list.png', fullPage: true });
await page.click('button:has-text("Ver")');
await page.waitForTimeout(500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_detail.png', fullPage: true });
await page.click('button:has-text("Cerrar")');
await page.waitForTimeout(300);
await page.click('#btnNewOrder');
await page.waitForTimeout(800);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_counter_new.png', fullPage: true });
console.log('Errors:', errors.length);
errors.forEach(e => console.log(' -', e));
await browser.close();
})();

View File

@@ -0,0 +1,34 @@
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: 'http://localhost:5001', serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('PAGE: ' + e.message));
page.on('console', msg => { if (msg.type() === 'error') errors.push('CONSOLE: ' + msg.text()); });
page.on('response', res => { if (res.status() >= 400) errors.push(`HTTP ${res.status()}: ${res.url()}`); });
await page.goto('/pos/login2?tenant=31');
await page.waitForSelector('.user-avatar-btn', { timeout: 10000 });
await page.click('.user-avatar-btn[data-id="2"]');
for (const digit of '1234') await page.click(`.pin-key[data-digit="${digit}"]`);
await page.click('#btn-login');
await page.waitForURL('**/pos/workshop', { timeout: 10000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_list.png', fullPage: true });
await page.click('button:has-text("Ver")');
await page.waitForTimeout(500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_detail.png', fullPage: true });
await page.click('button:has-text("Artículos"), .so-tabs__btn:has-text("Artículos")');
await page.waitForTimeout(500);
await page.screenshot({ path: '/home/Autopartes/data/rached_import/workshop_restricted_articles.png', fullPage: true });
console.log('Errors:', errors.length);
errors.forEach(e => console.log(' -', e));
await browser.close();
})();