Compare commits

..

1 Commits

Author SHA1 Message Date
7f753376e3 cambios para corregir bug visual de los temas oscuro y blanco 2026-06-24 10:54:33 -06:00
118 changed files with 1374 additions and 11807 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,140 +0,0 @@
# Auditoría y Mejoras del Sistema Nexus POS — 15 de junio de 2026
## Resumen ejecutivo
Se realizó una revisión completa del sistema Nexus POS enfocada en corregir errores críticos y mayores, eliminar código muerto, cerrar brechas de seguridad menores y estandarizar la experiencia de usuario. También se aplicaron optimizaciones de rendimiento en consultas frecuentes y se mejoró la accesibilidad de los formularios.
- **Rama/base:** `main`
- **Tenant de pruebas:** `tenant_refaccionaria_la_casita` (`tenant_id = 33`)
- **Servicio:** `nexus-pos.service` (puerto 5001)
- **Estado final:** operativo, tests pasando
---
## 1. Problemas críticos corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| `@require_auth` sin paréntesis en `tasks_bp` | Se cambió al decorador correcto | `pos/blueprints/tasks_bp.py` |
| Permiso inexistente `accounting.read` | Se reemplazó por `accounting.view` | `pos/blueprints/accounting_bp.py` |
| `historical_sales.html` no cargaba `api.js` | Se incluyó el helper compartido | `pos/templates/historical_sales.html`, `pos/static/js/api.js` |
| `featureProximamente` no estaba disponible en `customers.js` | Se expuso la función en el módulo | `pos/static/js/customers.js` |
| `pos_engine` usaba `threading` sin importar y la caja sin `FOR UPDATE` | Se agregó `import threading` y lock pesimista de caja | `pos/services/pos_engine.py` |
| Race condition en stock de ventas | Se agregó `SELECT ... FOR UPDATE` sobre `inventory_stock` | `pos/services/pos_engine.py` |
---
## 2. Problemas mayores corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| N+1 en `complete_layaway` | Se cargan todos los items en una sola consulta | `pos/services/pos_engine.py` |
| Race conditions en folios CFDI y números de póliza | Se usó `pg_advisory_xact_lock(hashtext(prefix))` | `pos/services/cfdi_queue.py`, `pos/services/accounting_engine.py` |
| N+1 en flotilla (schedules/history) | Se crearon endpoints bulk `/vehicles/schedules` y `/vehicles/history` | `pos/blueprints/fleet_bp.py`, `pos/static/js/fleet.js` |
| N+1 en dashboard (ventas recientes) y `global_invoice` | Se consolidaron consultas | `pos/blueprints/dashboard_stats_bp.py`, `pos/services/global_invoice.py` |
| `client_secret` de MercadoLibre expuesto en frontend | Se movió el flujo OAuth a backend con endpoint `/connect/init` | `pos/blueprints/marketplace_external_bp.py`, `pos/static/js/marketplace_external.js` |
| Botones/filtros sin handler en config, customers y accounting | Se conectaron eventos y handlers faltantes | `pos/static/js/config.js`, `pos/static/js/customers.js`, `pos/static/js/accounting.v9.js`, `pos/templates/*.html` |
| Botones/filtros sin handler en invoicing | Se implementaron filtros, export CSV y acciones | `pos/static/js/invoicing.js`, `pos/templates/invoicing.html` |
---
## 3. Mejoras de rendimiento
- **Locks atómicos:** los folios de órdenes de servicio, CFDI y pólizas contables ahora se generan bajo `pg_advisory_xact_lock` para evitar duplicados bajo concurrencia.
- **Consultas bulk:** flotilla y dashboard redujeron drásticamente el número de queries al backend.
- **Virtual scroll:** se mantiene en tablas grandes (clientes, programas de mantenimiento, historial) para renderizado eficiente.
- **Cache busting:** se actualizaron los query strings estáticos a `?v=32` y el Service Worker a `v32` para forzar la actualización de assets en navegadores y PWA.
---
## 4. Mejoras de UX y accesibilidad
- **Formularios accesibles:** se agregaron `aria-label`, `id` y `<label>` explícitos en:
- POS: pago mixto, toggle de costo/margen, campos de vehículo del modal de cliente.
- Clientes: búsqueda y filtros.
- Catálogo: búsqueda y filtro de niveles.
- Facturación: búsquedas de facturas, notas de crédito y complementos de pago.
- **Estados de carga consistentes:** se añadió `renderLoadingState()` en `pos-utils.js` con estilo en `pos-ui.css`, y se aplicó en:
- Clientes (`customers.js`)
- Facturación (`invoicing.js`)
- Flotilla (`fleet.js`): vehículos, programas, historial y alertas.
- **Empty states normalizados:** los errores y listas vacías ahora usan `renderEmptyState()` en lugar de mensajes inline inconsistentes.
---
## 5. Limpieza de código
- Se eliminaron las versiones obsoletas `pos/static/js/accounting.js` y `pos/static/js/accounting.min.js`.
- Se consolidó la lógica contable en `pos/static/js/accounting.v9.js`.
- Se removieron `console.log`/`console.error` de depuración de `accounting.v9.js`.
- Se limpió encabezado comentado de `accounting.v9.js`.
---
## 6. Archivos añadidos
```text
pos/migrations/v4.10_fleet_permissions.sql
pos/migrations/v4.7_workshop_business.sql
pos/migrations/v4.8_workshop_permissions.sql
pos/migrations/v4.9_workshop_customers_view.sql
pos/static/js/accounting.v9.js
pos/static/js/api.js
scripts/allocate_existing_customer_payments.py
```
---
## 7. Estadísticas del cambio
```text
54 archivos modificados
+2485 / -1300 líneas aproximadas
```
---
## 8. Validación
```bash
# Sintaxis de JavaScript
node --check pos/static/js/pos-utils.js
node --check pos/static/js/customers.js
node --check pos/static/js/invoicing.js
node --check pos/static/js/fleet.js
node --check pos/static/js/accounting.v9.js
node --check pos/static/js/pos.js
node --check pos/static/js/catalog.js
# Tests de backend
python3 -m pytest pos/tests/test_service_order_integration.py \
pos/tests/test_bulk_import.py \
pos/tests/test_facturapi_service.py -q
# Resultado: 35 passed
```
Servicio `nexus-pos.service` verificado como **activo**.
---
## 9. Instrucciones de despliegue
1. Aplicar migraciones SQL nuevas según el orden numérico:
- `v4.7_workshop_business.sql`
- `v4.8_workshop_permissions.sql`
- `v4.9_workshop_customers_view.sql`
- `v4.10_fleet_permissions.sql`
2. Reiniciar el servicio para cargar cambios de backend:
```bash
sudo systemctl restart nexus-pos.service
```
3. Refrescar la PWA en los navegadores/clientes para que `sw.js` actualice el cache a `v32`.
---
## 10. Notas para el equipo
- Los permisos de flotilla (`fleet.view/create/edit/delete`) ya están sembrados para `owner`/`admin` y `view` para `accountant`/`workshop`.
- El Service Worker se sirve correctamente en `/pos/sw.js` desde `static/pwa/sw.js`.
- El helper global `api()` fue creado para páginas standalone como `historical_sales.html`.
- No se realizaron mutaciones de git no solicitadas; el push a Gitea se hace como parte de esta entrega.

View File

@@ -74,7 +74,7 @@ server {
}
location = / {
return 302 /pos/login2;
return 302 /pos/login;
}
location / {

View File

@@ -1,4 +1,4 @@
from flask import Flask, request, redirect, make_response
from flask import Flask
from json_provider import OrjsonProvider
@@ -10,20 +10,12 @@ def create_app():
from middleware_tenant import resolve_tenant
app.before_request(resolve_tenant)
# 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, 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
from flask import send_from_directory
return send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript')
# Register blueprints
from blueprints.auth_bp import auth_bp
@@ -140,23 +132,11 @@ def create_app():
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
@app.route('/pos/login')
def pos_login_legacy():
# Redirect to the new login path to bypass any stale browser/SW cache
# of the old /pos/login page that did not send employee_id.
response = make_response(redirect('/pos/login2'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
@app.route('/pos/login2')
def pos_login():
response = make_response(render_template('login.html',
return render_template('login.html',
tenant_id=getattr(g, 'tenant_id', None),
tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None)))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
@app.route('/pos/supplier-catalog')
def supplier_catalog_page():
@@ -188,11 +168,7 @@ def create_app():
@app.route('/pos/dashboard')
def pos_dashboard():
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
return render_template('dashboard.html')
@app.route('/pos/config')
def pos_config():
@@ -234,14 +210,6 @@ 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

@@ -7,10 +7,8 @@ NUMERIC(14,2) in the database.
"""
import json
import csv
import io
from datetime import date, datetime, timedelta
from flask import Blueprint, request, jsonify, g, Response
from datetime import date, datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.accounting_engine import create_manual_entry
@@ -567,140 +565,21 @@ def balance_sheet():
@accounting_bp.route('/aging', methods=['GET'])
@require_auth('accounting.view')
def aging_report():
"""Antiguedad de saldos.
"""Antiguedad de saldos (accounts receivable aging).
Returns individual credit sales with outstanding balance when type=receivable
(default), or purchase orders payable to suppliers when type=payable.
Groups outstanding credit sales by age:
- Corriente (not yet due)
- 1-30 dias
- 31-60 dias
- 61-90 dias
- 90+ dias
"""
report_type = request.args.get('type', 'receivable')
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
rows = []
if report_type == 'payable':
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.id, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected_date = r[4]
status = r[5]
vendor_name = r[7]
paid = 0 # TODO: sum supplier payments when that table is added
balance = round(total - paid, 2)
if balance <= 0:
continue
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
if days_overdue > 0:
po_status = 'overdue'
label = 'Vencida'
elif paid > 0:
po_status = 'partial'
label = 'Parcial'
else:
po_status = 'pending'
label = 'Pendiente'
rows.append({
'po_id': po_id,
'invoice': invoice,
'vendor_name': vendor_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': expected_date.isoformat() if expected_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': po_status,
'status_label': label,
})
else:
# Accounts receivable: credit sales to customers
cur.execute("""
SELECT s.id, s.total, s.created_at, s.status,
c.id, c.name, c.rfc,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
for r in cur.fetchall():
sale_id = r[0]
total = float(r[1]) if r[1] else 0
created_at = r[2]
status = r[3]
customer_name = r[5]
payments_total = float(r[7]) if r[7] else 0
paid = payments_total
balance = round(total - paid, 2)
if balance <= 0:
continue
due_date = created_at + timedelta(days=30) if created_at else None
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
if days_overdue > 0:
sale_status = 'overdue'
label = 'Vencida'
elif paid > 0:
sale_status = 'partial'
label = 'Parcial'
else:
sale_status = 'pending'
label = 'Vigente'
rows.append({
'sale_id': sale_id,
'invoice': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': sale_status,
'status_label': label,
})
totals = {
'count': len(rows),
'total': round(sum(r['total'] for r in rows), 2),
'paid': round(sum(r['paid'] for r in rows), 2),
'balance': round(sum(r['balance'] for r in rows), 2),
}
cur.close()
conn.close()
return jsonify({'data': rows, 'totals': totals})
@accounting_bp.route('/aging-summary', methods=['GET'])
@require_auth('accounting.view')
def aging_summary():
"""Customer-level accounts receivable summary (used by reports)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
s.id as sale_id,
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
s.created_at,
s.id as sale_id, s.total, s.created_at,
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
FROM customers c
JOIN sales s ON s.customer_id = c.id
@@ -713,11 +592,6 @@ def aging_summary():
customers = {}
for r in cur.fetchall():
cust_id = r[0]
balance = round(float(r[6]) if r[6] else 0, 2)
if balance <= 0:
continue
days = r[8] or 0
if cust_id not in customers:
customers[cust_id] = {
'id': r[0], 'name': r[1], 'rfc': r[2],
@@ -727,25 +601,29 @@ def aging_summary():
'total': 0,
}
if days <= 0:
customers[cust_id]['corriente'] += balance
elif days <= 30:
customers[cust_id]['d1_30'] += balance
elif days <= 60:
customers[cust_id]['d31_60'] += balance
elif days <= 90:
customers[cust_id]['d61_90'] += balance
else:
customers[cust_id]['d90_plus'] += balance
amount = float(r[6]) if r[6] else 0
days = r[8] or 0
customers[cust_id]['total'] += balance
if days <= 0:
customers[cust_id]['corriente'] += amount
elif days <= 30:
customers[cust_id]['d1_30'] += amount
elif days <= 60:
customers[cust_id]['d31_60'] += amount
elif days <= 90:
customers[cust_id]['d61_90'] += amount
else:
customers[cust_id]['d90_plus'] += amount
customers[cust_id]['total'] += amount
result = list(customers.values())
# Round all amounts
for c in result:
c['credit_balance'] = round(c['total'], 2)
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
c[key] = round(c[key], 2)
# Totals row
totals = {
'corriente': round(sum(c['corriente'] for c in result), 2),
'd1_30': round(sum(c['d1_30'] for c in result), 2),
@@ -753,7 +631,6 @@ def aging_summary():
'd61_90': round(sum(c['d61_90'] for c in result), 2),
'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
'total': round(sum(c['total'] for c in result), 2),
'count': len(result),
}
cur.close()
@@ -761,161 +638,6 @@ def aging_summary():
return jsonify({'data': result, 'totals': totals})
@accounting_bp.route('/aging/export', methods=['GET'])
@require_auth('accounting.view')
def export_aging():
"""Export receivables or payables to CSV/PDF."""
report_type = request.args.get('type', 'receivable')
fmt = request.args.get('format', 'csv').lower()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
if report_type == 'payable':
# Payables always CSV for now
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected = r[4]
status = r[5]
vendor = r[6]
paid = 0
balance = round(total - paid, 2)
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
writer.writerow([
invoice, vendor,
created_at.strftime('%Y-%m-%d') if created_at else '',
expected.strftime('%Y-%m-%d') if expected else '',
total, paid, balance, days_overdue, status_label
])
cur.close(); conn.close()
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
)
# Receivables: CSV or PDF
cur.execute("""
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
c.name,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
rows = []
for r in cur.fetchall():
sale_id = r[0]
subtotal = float(r[1]) if r[1] else 0
tax = float(r[2]) if r[2] else 0
total = float(r[3]) if r[3] else 0
created_at = r[4]
customer_name = r[5]
payments_total = float(r[6]) if r[6] else 0
balance = round(total - payments_total, 2)
if balance <= 0:
continue
rows.append({
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
'customer': customer_name,
'subtotal': subtotal,
'tax': tax,
'total': total,
'balance': balance,
})
cur.close(); conn.close()
if fmt == 'pdf':
from fpdf import FPDF
class ReceivablesPDF(FPDF):
def header(self):
self.set_font('Arial', 'B', 14)
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
self.set_font('Arial', '', 10)
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
self.ln(4)
def footer(self):
self.set_y(-15)
self.set_font('Arial', 'I', 8)
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
pdf = ReceivablesPDF('L', 'mm', 'A4')
pdf.add_page()
pdf.set_font('Arial', 'B', 10)
pdf.set_fill_color(230, 230, 230)
col_widths = [30, 95, 35, 30, 35, 35]
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
for i, h in enumerate(headers):
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
pdf.ln()
pdf.set_font('Arial', '', 9)
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
for row in rows:
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
totals['subtotal'] += row['subtotal']
totals['tax'] += row['tax']
totals['total'] += row['total']
totals['balance'] += row['balance']
pdf.set_font('Arial', 'B', 9)
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
pdf_bytes = bytes(pdf.output(dest='S'))
return Response(
pdf_bytes,
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
)
# CSV fallback
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
for row in rows:
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
)
# ─── Fiscal Periods ────────────────────────────────
@accounting_bp.route('/periods', methods=['GET'])
@@ -1022,7 +744,7 @@ def close_period():
@accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.view')
@require_auth('accounting.read')
def api_accounting_stats():
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
conn = get_tenant_conn(g.tenant_id)

View File

@@ -5,10 +5,9 @@ import jwt
import bcrypt
import time
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify, g, make_response
from flask import Blueprint, request, jsonify, g
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')
@@ -60,7 +59,6 @@ def login_pin():
# Subdomain-resolved tenant takes priority over body param
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
pin = data.get('pin', '')
employee_id = data.get('employee_id')
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
# Optional: branch_id from the device for PIN search optimization
device_branch_id = data.get('branch_id')
@@ -91,26 +89,39 @@ def login_pin():
matched_employee = None
if not employee_id:
_record_attempt(device_id, False)
cur.close()
conn.close()
return jsonify({'error': 'Empleado no seleccionado'}), 400
# Verify the PIN only for the selected employee.
if device_branch_id:
# Try branch employees first (fast path for known devices)
cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL
""", (employee_id,))
emp = cur.fetchone()
if emp:
WHERE e.is_active = true AND e.pin IS NOT NULL AND e.branch_id = %s
""", (device_branch_id,))
for emp in cur.fetchall():
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
}
break
if not matched_employee:
# Fallback: check ALL active employees (covers owners, admins, roaming staff)
cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.is_active = true AND e.pin IS NOT NULL
""")
employees = cur.fetchall()
for emp in employees:
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
}
break
if not matched_employee:
_record_attempt(device_id, False)
@@ -146,77 +157,9 @@ def login_pin():
}
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
response = make_response(jsonify({
'token': token,
'employee': matched_employee,
'permissions': permissions
}))
# Cookie used by server-side route guards; JS can also read it for quick checks.
response.set_cookie(
'pos_role', matched_employee['role'],
path='/pos', samesite='Lax', httponly=False
)
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']
},
'employee': matched_employee,
'permissions': permissions
})
@@ -254,7 +197,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', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
employees.append({
'id': row[0],
'name': name,

View File

@@ -3,26 +3,16 @@
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from middleware import require_auth
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()
@require_auth('pos.sell')
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}
@@ -94,7 +84,7 @@ def open_register():
@cashregister_bp.route('/current', methods=['GET'])
@require_auth()
@require_auth('pos.sell')
def current_register():
"""Get the current open register for this employee."""
conn = get_tenant_conn(g.tenant_id)
@@ -125,10 +115,8 @@ def current_register():
@cashregister_bp.route('/movement', methods=['POST'])
@require_auth()
@require_auth('pos.sell')
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}
@@ -285,10 +273,8 @@ def _compute_register_summary(conn, register_id):
@cashregister_bp.route('/cut-x', methods=['GET'])
@require_auth()
@require_auth('pos.sell')
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()
@@ -315,10 +301,8 @@ def cut_x():
@cashregister_bp.route('/cut-z', methods=['POST'])
@require_auth()
@require_auth('pos.sell')
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)
@@ -395,18 +379,12 @@ def cut_z():
@cashregister_bp.route('/history', methods=['GET'])
@require_auth()
@require_auth('pos.view')
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()
@@ -424,9 +402,6 @@ 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)
@@ -601,89 +576,3 @@ 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,7 +1,6 @@
# /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
@@ -9,188 +8,6 @@ from tenant_db import get_tenant_conn
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
# Default permission set per role. Can be overridden per tenant via role_permissions config.
_DEFAULT_ROLE_PERMISSIONS = {
'owner': [], # owner bypasses permission checks
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices',
'workshop.view', 'workshop.edit',
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create',
'workshop.view', 'workshop.edit', 'workshop.add_items',
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
'counter': ['pos.remission', 'pos.view',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view',
'fleet.view'],
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
'mechanic': ['workshop.view'],
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
'customers.view', 'customers.create'],
}
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
_WORKSHOP_STATUSES = [
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
'por_entregar', 'entregado', 'por_enviar', 'enviado',
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
]
# Workshop-specific actions configurable per role.
_WORKSHOP_ACTIONS = [
{'key': 'create_order', 'label': 'Crear órdenes'},
{'key': 'edit_order', 'label': 'Editar órdenes'},
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
{'key': 'add_items', 'label': 'Agregar artículos'},
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
{'key': 'change_status', 'label': 'Cambiar estatus'},
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
{'key': 'view_prices', 'label': 'Ver precios/costos'},
{'key': 'view_notes', 'label': 'Ver bitácora'},
]
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
_DEFAULT_WORKSHOP_PERMISSIONS = {
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
'workshop': {
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
'actions': ['change_status', 'add_labor', 'view_notes']
},
'mechanic': {
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
'actions': ['change_status', 'add_labor', 'view_notes']
},
}
_AVAILABLE_PERMISSIONS = [
{'module': 'Dashboard', 'permissions': [
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
]},
{'module': 'Punto de Venta', 'permissions': [
{'key': 'pos.sell', 'label': 'Realizar ventas'},
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
{'key': 'pos.remission', 'label': 'Notas de remisión'},
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
]},
{'module': 'Inventario', 'permissions': [
{'key': 'inventory.view', 'label': 'Ver inventario'},
{'key': 'inventory.create', 'label': 'Crear artículos'},
{'key': 'inventory.edit', 'label': 'Editar artículos'},
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
]},
{'module': 'Catálogo', 'permissions': [
{'key': 'catalog.view', 'label': 'Ver catálogo'},
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
]},
{'module': 'Clientes', 'permissions': [
{'key': 'customers.view', 'label': 'Ver clientes'},
{'key': 'customers.create', 'label': 'Crear clientes'},
{'key': 'customers.edit', 'label': 'Editar clientes'},
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
]},
{'module': 'Taller', 'permissions': [
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
]},
{'module': 'Facturación', 'permissions': [
{'key': 'invoicing.view', 'label': 'Ver facturas'},
{'key': 'invoicing.create', 'label': 'Crear facturas'},
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
]},
{'module': 'Configuración', 'permissions': [
{'key': 'config.view', 'label': 'Ver configuración'},
{'key': 'config.edit', 'label': 'Editar configuración'},
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
]},
{'module': 'Contabilidad', 'permissions': [
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
]},
{'module': 'Reportes', 'permissions': [
{'key': 'reports.view', 'label': 'Ver reportes'},
{'key': 'reports.financial', 'label': 'Reportes financieros'},
]},
{'module': 'Flotillas', 'permissions': [
{'key': 'fleet.view', 'label': 'Ver flotillas'},
{'key': 'fleet.create', 'label': 'Crear flotillas'},
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
]},
]
def _get_role_permissions(conn, role):
"""Return configured permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return list(configured.get(role, []))
except (ValueError, TypeError):
pass
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
def _get_workshop_permissions(conn, role):
"""Return configured workshop permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return configured[role]
except (ValueError, TypeError):
pass
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
def _get_all_workshop_permissions(conn):
"""Return effective workshop permissions for every known role."""
result = {}
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
result[role] = _get_workshop_permissions(conn, role)
return result
@config_bp.route('/branches', methods=['GET'])
@require_auth()
def list_branches():
@@ -333,58 +150,8 @@ def update_branch(branch_id):
return jsonify({'ok': True, 'message': 'Branch updated'})
@config_bp.route('/branches/<int:branch_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_branch(branch_id):
"""Hard-delete a branch. Only owner/admin can delete; main branch cannot be deleted.
Related records keep their data but lose the branch reference; stock and count
rows tied exclusively to the branch are removed.
"""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar sucursales'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT is_main FROM branches WHERE id = %s", (branch_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Branch not found'}), 404
if row[0]:
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar la sucursal principal'}), 403
# Remove branch-specific stock and count rows first.
cur.execute("DELETE FROM inventory_stock WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM inventory_stock_summary WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM physical_counts WHERE branch_id = %s", (branch_id,))
# Nullify every other FK reference back to branches.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.branches'::regclass
AND con.contype = 'f'
""")
for tbl, col in cur.fetchall():
if tbl in ('inventory_stock', 'inventory_stock_summary', 'physical_counts'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (branch_id,))
cur.execute("DELETE FROM branches WHERE id = %s", (branch_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Sucursal eliminada'})
@config_bp.route('/employees', methods=['GET'])
@require_auth()
@require_auth('config.view')
def list_employees():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -433,7 +200,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', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
@@ -448,8 +215,26 @@ 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 (configurable per tenant)
for perm in _get_role_permissions(conn, data['role']):
# 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'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
'catalog.view', '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'],
}
for perm in role_permissions.get(data['role'], []):
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
@@ -465,105 +250,6 @@ 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('/role-permissions/workshop', methods=['GET'])
@require_auth('config.view')
def get_workshop_permissions_config():
"""Return the configured workshop permissions (statuses + actions) per role."""
conn = get_tenant_conn(g.tenant_id)
try:
return jsonify({
'roles': _get_all_workshop_permissions(conn),
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
'actions': _WORKSHOP_ACTIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
@require_auth('config.edit')
def save_workshop_permissions_config():
"""Save the workshop permission mapping per role."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, cfg in data['roles'].items():
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(cfg, dict):
return jsonify({'error': f'config for {role} must be an object'}), 400
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
@require_auth('config.edit')
def update_employee(emp_id):
@@ -609,15 +295,6 @@ def update_employee(emp_id):
params.append(emp_id)
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
# If the role changed, re-sync permissions to match the new role defaults/config.
if 'role' in data:
cur.execute("DELETE FROM employee_permissions WHERE employee_id = %s", (emp_id,))
for perm in _get_role_permissions(conn, data['role']):
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
from services.audit import log_action
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
new_value={k: v for k, v in data.items() if k != 'pin'})
@@ -628,53 +305,6 @@ def update_employee(emp_id):
return jsonify({'ok': True, 'message': 'Employee updated'})
@config_bp.route('/employees/<int:emp_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_employee(emp_id):
"""Hard-delete an employee. Foreign-key references are nulled and
dependent rows (permissions/sessions) are removed. Owners cannot be
deleted via UI to prevent locking out the tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT role FROM employees WHERE id = %s", (emp_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Employee not found'}), 404
if row[0] == 'owner':
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar una cuenta de dueno'}), 403
# Resolve every foreign-key column that points back to employees.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.employees'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
if tbl in ('employee_permissions', 'employee_sessions', 'notification_preferences'):
cur.execute(f"DELETE FROM \"{tbl}\" WHERE \"{col}\" = %s", (emp_id,))
else:
cur.execute(f"UPDATE \"{tbl}\" SET \"{col}\" = NULL WHERE \"{col}\" = %s", (emp_id,))
cur.execute("DELETE FROM employees WHERE id = %s", (emp_id,))
from services.audit import log_action
log_action(conn, 'EMPLOYEE_DELETE', 'employee', emp_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Empleado eliminado'})
@config_bp.route('/currency', methods=['GET'])
@require_auth()
def get_currency():
@@ -759,14 +389,7 @@ def get_business():
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
'rfc': cfg.get('tenant_rfc', ''),
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
'cp': cfg.get('tenant_cp', ''),
'direccion': cfg.get('tenant_direccion', ''),
'numero_exterior': cfg.get('tenant_numero_exterior', ''),
'numero_interior': cfg.get('tenant_numero_interior', ''),
'colonia': cfg.get('tenant_colonia', ''),
'ciudad': cfg.get('tenant_ciudad', ''),
'municipio': cfg.get('tenant_municipio', ''),
'estado': cfg.get('tenant_estado', ''),
'telefono': cfg.get('tenant_telefono', ''),
'email': cfg.get('tenant_email', ''),
})
@@ -782,22 +405,14 @@ def update_business():
'nombre': 'tenant_nombre',
'rfc': 'tenant_rfc',
'regimen_fiscal': 'tenant_regimen_fiscal',
'cp': 'tenant_cp',
'direccion': 'tenant_direccion',
'numero_exterior': 'tenant_numero_exterior',
'numero_interior': 'tenant_numero_interior',
'colonia': 'tenant_colonia',
'ciudad': 'tenant_ciudad',
'municipio': 'tenant_municipio',
'estado': 'tenant_estado',
'telefono': 'tenant_telefono',
'email': 'tenant_email',
# Tax params (also keep cfdi_* aliases in sync)
# Tax params
'tax_iva': 'tax_iva',
'tax_ieps': 'tax_ieps',
'invoice_serie': 'invoice_serie',
'invoice_folio': 'invoice_folio',
'cfdi_serie': 'cfdi_serie',
'default_currency': 'default_currency',
'default_payment_method': 'default_payment_method',
}
@@ -1048,7 +663,7 @@ def update_whatsapp_config():
@config_bp.route('/modules', methods=['GET'])
@require_auth()
@require_auth('config.view')
def get_modules():
"""Get enabled modules for this tenant."""
conn = get_tenant_conn(g.tenant_id)
@@ -1066,7 +681,6 @@ def get_modules():
'marketplace': _bool('module_marketplace'),
'meli': _bool('module_meli'),
'catalog': _bool('module_catalog'),
'workshop': _bool('module_workshop'),
})
@@ -1083,7 +697,6 @@ def update_modules():
'module_marketplace': 'true' if data.get('marketplace') else 'false',
'module_meli': 'true' if data.get('meli') else 'false',
'module_catalog': 'true' if data.get('catalog') else 'false',
'module_workshop': 'true' if data.get('workshop') else 'false',
}
for key, value in settings.items():
@@ -1100,78 +713,9 @@ def update_modules():
'whatsapp': data.get('whatsapp'),
'marketplace': data.get('marketplace'),
'meli': data.get('meli'),
'catalog': data.get('catalog'),
'workshop': data.get('workshop'),
}})
@config_bp.route('/counter-remission', methods=['GET'])
@require_auth()
def get_counter_remission_config():
"""Get counter remission note feature flag for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
row = cur.fetchone()
cur.close(); conn.close()
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
@config_bp.route('/counter-remission', methods=['PUT'])
@require_auth('config.edit')
def update_counter_remission_config():
"""Enable/disable counter remission notes for this tenant."""
data = request.get_json() or {}
enabled = 'true' if data.get('enabled') else 'false'
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('counter_remission_enabled', enabled))
conn.commit()
cur.close(); conn.close()
return jsonify({'enabled': enabled == 'true'})
@config_bp.route('/sales-settings', methods=['GET'])
@require_auth('pos.view')
def get_sales_settings():
"""Get sales-related settings (zero-price sales, negative stock)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')")
rows = {k: v for k, v in cur.fetchall()}
cur.close(); conn.close()
# Default allow_zero_price_sales to true to avoid breaking existing tenants.
allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes')
# Default allow_negative_stock to false (safer).
neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes')
return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg})
@config_bp.route('/sales-settings', methods=['PUT'])
@require_auth('config.edit')
def update_sales_settings():
"""Update sales-related settings."""
data = request.get_json() or {}
allow = 'true' if data.get('allow_zero_price_sales') else 'false'
neg = 'true' if data.get('allow_negative_stock') else 'false'
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('allow_zero_price_sales', allow))
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('allow_negative_stock', neg))
conn.commit()
cur.close(); conn.close()
return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'})
@config_bp.route('/onboarding-status', methods=['GET'])
@require_auth('pos.view')
def get_onboarding_status():
@@ -1201,102 +745,3 @@ def set_onboarding_status():
cur.close()
conn.close()
return jsonify({'completed': completed == 'true'})
# ─── Receipt / Ticket Customization ────────────────────────────────────────
RECEIPT_CONFIG_KEYS = [
'receipt_logo',
'receipt_store_name',
'receipt_tagline',
'receipt_rfc',
'receipt_address',
'receipt_phone',
'receipt_footer',
'receipt_thanks_message',
'receipt_show_logo',
'receipt_show_rfc',
'receipt_show_address',
'receipt_show_phone',
'receipt_show_iva_breakdown',
'receipt_show_payment_details',
'receipt_show_employee',
'receipt_paper_width',
]
@config_bp.route('/receipt', methods=['GET'])
@require_auth('pos.view')
def get_receipt_config():
"""Get receipt customization settings."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
(RECEIPT_CONFIG_KEYS,)
)
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
def _bool(key, default=False):
v = rows.get(key, 'true' if default else 'false')
return str(v).lower() == 'true'
return jsonify({
'logo': rows.get('receipt_logo', ''),
'store_name': rows.get('receipt_store_name', ''),
'tagline': rows.get('receipt_tagline', ''),
'rfc': rows.get('receipt_rfc', ''),
'address': rows.get('receipt_address', ''),
'phone': rows.get('receipt_phone', ''),
'footer': rows.get('receipt_footer', ''),
'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'),
'show_logo': _bool('receipt_show_logo', True),
'show_rfc': _bool('receipt_show_rfc', True),
'show_address': _bool('receipt_show_address', False),
'show_phone': _bool('receipt_show_phone', False),
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
'show_payment_details': _bool('receipt_show_payment_details', True),
'show_employee': _bool('receipt_show_employee', False),
'paper_width': rows.get('receipt_paper_width', '80') or '80',
})
@config_bp.route('/receipt', methods=['PUT'])
@require_auth('config.edit')
def update_receipt_config():
"""Update receipt customization settings."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
settings = {
'receipt_logo': data.get('logo', ''),
'receipt_store_name': data.get('store_name', ''),
'receipt_tagline': data.get('tagline', ''),
'receipt_rfc': data.get('rfc', ''),
'receipt_address': data.get('address', ''),
'receipt_phone': data.get('phone', ''),
'receipt_footer': data.get('footer', ''),
'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'),
'receipt_show_logo': 'true' if data.get('show_logo') else 'false',
'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false',
'receipt_show_address': 'true' if data.get('show_address') else 'false',
'receipt_show_phone': 'true' if data.get('show_phone') else 'false',
'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false',
'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false',
'receipt_show_employee': 'true' if data.get('show_employee') else 'false',
'receipt_paper_width': str(data.get('paper_width', '80') or '80'),
}
for key, value in settings.items():
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Receipt configuration updated'})

View File

@@ -10,20 +10,10 @@ from services.audit import log_action
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
def _can_view_customers():
"""Cashiers, counter and workshop employees need customer access for POS/service flows."""
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
def _can_create_customer():
"""Cashiers, counter and workshop employees can create customers on the fly."""
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
# ─── Customer CRUD ─────────────────────────────
@customers_bp.route('', methods=['GET'])
@require_auth()
@require_auth('customers.view')
def list_customers():
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
@@ -33,8 +23,6 @@ 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()
@@ -42,24 +30,10 @@ def list_customers():
per_page = min(int(request.args.get('per_page', 50)), 200)
search = request.args.get('q', '').strip()
branch_id = request.args.get('branch_id')
price_tier = request.args.get('price_tier', '').strip()
status = request.args.get('status', '').strip().lower()
where_clauses = []
where_clauses = ["c.is_active = true"]
params = []
if status == 'inactive':
where_clauses.append("c.is_active = false")
elif status == 'overdue':
where_clauses.append(
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
)
elif status == 'all':
pass # no is_active filter
else:
# Default to active customers for backwards compatibility
where_clauses.append("c.is_active = true")
if branch_id:
where_clauses.append("c.branch_id = %s")
params.append(int(branch_id))
@@ -68,20 +42,8 @@ def list_customers():
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
)
params.extend([f'%{search}%'] * 4)
if price_tier:
# Support numeric tier or Spanish labels
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
tier_val = tier_map.get(price_tier.lower())
if tier_val is None:
try:
tier_val = int(price_tier)
except ValueError:
tier_val = None
if tier_val in (1, 2, 3):
where_clauses.append("c.price_tier = %s")
params.append(tier_val)
where = " AND ".join(where_clauses) if where_clauses else "true"
where = " AND ".join(where_clauses)
# Count
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
@@ -92,8 +54,7 @@ def list_customers():
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
c.address, c.cp,
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
c.branch_id, c.is_active, c.created_at,
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
c.branch_id
FROM customers c
WHERE {where}
ORDER BY c.name
@@ -110,9 +71,6 @@ def list_customers():
'credit_balance': float(r[10]) if r[10] else 0,
'vehicle_info': r[11],
'branch_id': r[12],
'is_active': r[13],
'created_at': str(r[14]) if r[14] else None,
'last_purchase': str(r[15]) if r[15] else None,
})
cur.close()
@@ -126,10 +84,8 @@ def list_customers():
@customers_bp.route('/<int:customer_id>', methods=['GET'])
@require_auth()
@require_auth('customers.view')
def get_customer(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer details with credit info, vehicle history, and recent purchases."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -179,71 +135,19 @@ def get_customer(customer_id):
float(customer['credit_limit']) - float(customer['credit_balance']), 2
)
# Fleet vehicles assigned to this customer
cur.execute("""
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
FROM fleet_vehicles
WHERE customer_id = %s
ORDER BY is_active DESC, created_at DESC
""", (customer_id,))
customer['fleet_vehicles'] = []
for r in cur.fetchall():
customer['fleet_vehicles'].append({
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
})
cur.close()
conn.close()
return jsonify(customer)
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
@require_auth()
def get_customer_purchases(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Return full purchase history for a customer."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
e.name as employee_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
WHERE s.customer_id = %s
ORDER BY s.created_at DESC
""", (customer_id,))
purchases = []
for r in cur.fetchall():
purchases.append({
'id': r[0],
'total': float(r[1]) if r[1] else 0,
'payment_method': r[2],
'sale_type': r[3],
'status': r[4],
'created_at': str(r[5]),
'employee_name': r[6],
})
cur.close()
conn.close()
return jsonify({'data': purchases})
@customers_bp.route('', methods=['POST'])
@require_auth()
@require_auth('customers.create')
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
@@ -336,57 +240,9 @@ def update_customer(customer_id):
return jsonify({'message': 'Customer updated'})
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
@require_auth('customers.delete')
def delete_customer(customer_id):
"""Hard-delete a customer.
Related records (sales, service orders, vehicles, etc.) keep their data but
lose the customer reference. Layaways for this customer are removed because
they require a customer_id.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
# Remove layaway dependencies first, then the layaways themselves
cur.execute("DELETE FROM layaway_items WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaway_payments WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaways WHERE customer_id = %s", (customer_id,))
# Nullify every other FK reference back to customers.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.customers'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
# Cascade-delete tables and already-handled layaways are skipped.
if tbl in ('customer_activities', 'customer_tag_assignments', 'loyalty_points', 'loyalty_redemptions', 'layaways'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (customer_id,))
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
log_action(conn, 'CUSTOMER_DELETE', 'customer', customer_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Cliente eliminado'})
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
@require_auth()
@require_auth('customers.view')
def customer_statement(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Account statement: sales (invoices), payments, running balance.
Query params:
@@ -485,10 +341,8 @@ def customer_statement(customer_id):
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
@require_auth()
@require_auth('customers.view')
def customer_vehicles(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer's vehicle list with last purchases per vehicle.
Vehicle info is stored as JSONB in customers.vehicle_info:
@@ -585,32 +439,6 @@ def record_customer_payment(customer_id):
UPDATE customers SET credit_balance = %s WHERE id = %s
""", (new_balance, customer_id))
# Allocate the customer payment to oldest unpaid credit sales so the
# aging report and per-sale balances reflect the remaining debt.
remaining = amount
cur.execute("""
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s
AND s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
ORDER BY s.created_at
""", (customer_id,))
for sale_id, balance in cur.fetchall():
if remaining <= 0:
break
pay = round(min(remaining, float(balance)), 2)
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, pay,
f'Abono cliente #{customer_id}'))
remaining = round(remaining - pay, 2)
# Record cash movement on register if cash payment
if register_id and payment_method == 'efectivo':
cur.execute("""

View File

@@ -118,70 +118,3 @@ def get_employee_stats():
finally:
cur.close()
conn.close()
@dashboard_stats_bp.route('/credit-alerts', methods=['GET'])
@require_auth()
def credit_alerts():
"""Credit sales that are overdue or due within the next 7 days."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
SELECT s.id,
c.name as customer_name,
s.total,
s.created_at,
s.created_at + INTERVAL '30 days' as due_date,
COALESCE(SUM(sp.amount), 0) as paid
FROM sales s
JOIN customers c ON c.id = s.customer_id
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, c.name, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days'
ORDER BY due_date
LIMIT 50
""")
rows = []
now = datetime.utcnow()
for r in cur.fetchall():
sale_id = r[0]
customer_name = r[1]
total = float(r[2]) if r[2] else 0
paid = float(r[5]) if r[5] else 0
balance = round(total - paid, 2)
created_at = r[3]
due_date = r[4]
days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None
if days_until_due is None:
continue
status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current')
label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente')
rows.append({
'sale_id': sale_id,
'folio': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'days_until_due': days_until_due,
'total': total,
'paid': paid,
'balance': balance,
'status': status,
'status_label': label,
})
return jsonify({
'data': rows,
'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'),
'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'),
})
finally:
cur.close()
conn.close()

View File

@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
# ─── Vehicles CRUD ─────────────────────────────
@fleet_bp.route('/vehicles', methods=['GET'])
@require_auth('fleet.view')
@require_auth()
def list_vehicles():
"""List fleet vehicles with pagination and search.
@@ -83,7 +83,7 @@ def list_vehicles():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
@require_auth('fleet.view')
@require_auth()
def get_vehicle(vehicle_id):
"""Vehicle detail with maintenance schedules and recent logs."""
conn = get_tenant_conn(g.tenant_id)
@@ -155,128 +155,16 @@ def get_vehicle(vehicle_id):
return jsonify(vehicle)
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
@require_auth('fleet.view')
def list_all_schedules():
"""Return all active maintenance schedules joined with vehicle data.
Replaces the N+1 pattern of fetching schedules per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
where_clauses = ["v.is_active = true", "s.is_active = true"]
params = []
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
s.interval_months, s.last_done_at, s.last_done_km,
s.next_due_at, s.next_due_km, s.notes,
v.plate, v.make, v.model, v.current_mileage, v.color
FROM fleet_maintenance_schedules s
JOIN fleet_vehicles v ON v.id = s.vehicle_id
WHERE {where}
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
""", params)
schedules = []
for r in cur.fetchall():
schedules.append({
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
'interval_km': r[3], 'interval_months': r[4],
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
'notes': r[9],
'vehicle': {
'plate': r[10], 'make': r[11], 'model': r[12],
'current_mileage': r[13], 'color': r[14]
}
})
cur.close(); conn.close()
return jsonify({'data': schedules})
@fleet_bp.route('/vehicles/history', methods=['GET'])
@require_auth('fleet.view')
def list_all_history():
"""Return recent maintenance logs for all vehicles joined with vehicle data.
Replaces the N+1 pattern of fetching logs per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
limit = min(int(request.args.get('limit', 200)), 500)
where_clauses = ["v.is_active = true"]
params = [limit]
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
e.name as employee_name,
v.plate, v.make, v.model, v.color
FROM fleet_maintenance_logs l
JOIN fleet_vehicles v ON v.id = l.vehicle_id
LEFT JOIN employees e ON l.employee_id = e.id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT %s
""", params)
logs = []
for r in cur.fetchall():
logs.append({
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
'maintenance_type': r[3], 'mileage_at': r[4],
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
'employee_name': r[9],
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
})
cur.close(); conn.close()
return jsonify({'data': logs})
@fleet_bp.route('/vehicles', methods=['POST'])
@require_auth('fleet.create')
@require_auth()
def create_vehicle():
"""Create a fleet vehicle.
Body: {customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
Brand and model are required; plate is optional; VIN is not used.
Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
"""
data = request.get_json() or {}
if not data.get('make') or not data.get('model'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
if not data.get('plate') and not data.get('vin'):
return jsonify({'error': 'plate or vin is required'}), 400
branch_id = data.get('branch_id', g.branch_id)
@@ -284,23 +172,17 @@ def create_vehicle():
cur = conn.cursor()
try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute("""
INSERT INTO fleet_vehicles
(branch_id, customer_id, plate, make, model, year,
(branch_id, plate, vin, make, model, year,
current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
branch_id, data['customer_id'], data.get('plate'),
branch_id, data.get('plate'), data.get('vin'),
data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'),
data.get('color'), data.get('owner_name'), data.get('notes'),
))
vehicle_id = cur.fetchone()[0]
@@ -319,7 +201,7 @@ def create_vehicle():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
@require_auth('fleet.edit')
@require_auth()
def update_vehicle(vehicle_id):
"""Update vehicle fields including mileage.
@@ -363,7 +245,7 @@ def update_vehicle(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
@require_auth('fleet.delete')
@require_auth()
def deactivate_vehicle(vehicle_id):
"""Soft-delete: set is_active = false."""
conn = get_tenant_conn(g.tenant_id)
@@ -384,7 +266,7 @@ def deactivate_vehicle(vehicle_id):
# ─── Maintenance Schedules ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
@require_auth('fleet.view')
@require_auth()
def list_schedules(vehicle_id):
"""Maintenance schedules for a vehicle."""
conn = get_tenant_conn(g.tenant_id)
@@ -417,7 +299,7 @@ def list_schedules(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
@require_auth('fleet.create')
@require_auth()
def create_schedule(vehicle_id):
"""Create maintenance schedule for a vehicle.
@@ -462,7 +344,7 @@ def create_schedule(vehicle_id):
# ─── Maintenance Logs ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
@require_auth('fleet.create')
@require_auth()
def record_maintenance(vehicle_id):
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
@@ -545,7 +427,7 @@ def record_maintenance(vehicle_id):
# ─── Alerts ─────────────────────────────
@fleet_bp.route('/alerts', methods=['GET'])
@require_auth('fleet.view')
@require_auth()
def fleet_alerts():
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
conn = get_tenant_conn(g.tenant_id)
@@ -590,7 +472,7 @@ def fleet_alerts():
# ─── Stats ─────────────────────────────
@fleet_bp.route('/stats', methods=['GET'])
@require_auth('fleet.view')
@require_auth()
def fleet_stats():
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
conn = get_tenant_conn(g.tenant_id)

View File

@@ -103,13 +103,8 @@ def list_items():
# branch_id no longer filters inventory rows (shared catalog).
# It is used only to show per-branch stock.
if 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}%'])
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}%'])
if category:
where_clauses.append("i.category_id = %s")
params.append(int(category))
@@ -426,7 +421,7 @@ def bulk_import_items():
'skip' ignores missing compat; 'reject' requires all compat.
Expected CSV columns (case-insensitive):
sku/part_number, name, brand, price, stock, cost,
sku_secondary, description, category, make, model, year, engine, engine_code
location, description, category, make, model, year, engine, engine_code
Optional compat columns: make, model, year, engine, engine_code
"""
from services.qwen_fitment import get_vehicle_fitment
@@ -450,17 +445,8 @@ def bulk_import_items():
try:
ext = os.path.splitext(file.filename)[1].lower()
if ext == '.csv':
raw = file.stream.read()
decoded = None
for enc in ('utf-8-sig', 'cp1252', 'latin-1'):
try:
decoded = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if decoded is None:
return jsonify({'error': 'Unable to detect CSV encoding. Please save the file as UTF-8.'}), 400
reader = csv.DictReader(io.StringIO(decoded))
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
reader = csv.DictReader(stream)
rows = list(reader)
elif ext in ('.xls', '.xlsx', '.xlsm'):
try:
@@ -504,7 +490,6 @@ 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',
@@ -535,10 +520,6 @@ def bulk_import_items():
db_name = db_name_row[0] if db_name_row else None
mcur.close(); mconn.close()
# Pre-fetch category name -> id mapping
cur.execute("SELECT id, name FROM categories")
category_map = {str(r[1]).strip().lower(): r[0] for r in cur.fetchall()}
for row_num, row in enumerate(rows, start=1):
part_number = str(row.get('part_number', '')).strip()
name = str(row.get('name', '')).strip()
@@ -567,10 +548,7 @@ def bulk_import_items():
cost = _to_decimal(row.get('cost'), 0)
location = str(row.get('location', '')).strip()
description = str(row.get('description', '')).strip()
category_name = str(row.get('category', '')).strip()
category_id = category_map.get(category_name.lower()) if category_name else None
if category_name and category_id is None:
warnings.append(f'Row {row_num}: categoria "{category_name}" no encontrada')
category = str(row.get('category', '')).strip()
# Check if item already exists (catalog is shared across branches)
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
@@ -588,10 +566,10 @@ def bulk_import_items():
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
location = COALESCE(NULLIF(%s,''), location),
description = COALESCE(NULLIF(%s,''), description),
category_id = COALESCE(%s, category_id)
category = COALESCE(NULLIF(%s,''), category)
WHERE id = %s
""",
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
(name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
)
was_inserted = False
# Record stock adjustment for existing item if stock > 0
@@ -604,11 +582,11 @@ def bulk_import_items():
cur.execute(
"""
INSERT INTO inventory
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, unit)
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA')
(part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
)
item_id = cur.fetchone()[0]
was_inserted = True
@@ -621,20 +599,7 @@ def bulk_import_items():
created_ids.append(item_id)
created += 1
# ---------- 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 ----------
# ---------- 2. Vehicle compatibility ----------
make = str(row.get('make', '')).strip()
model = str(row.get('model', '')).strip()
year_str = str(row.get('year', '')).strip()
@@ -2306,22 +2271,6 @@ def list_inventory_subcategories(category_id):
conn.close()
@inventory_bp.route('/categories/all', methods=['GET'])
@require_auth()
def list_all_inventory_categories():
"""Return all active categories (flat, with parent_id) for selectors."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, parent_id FROM categories WHERE is_active = true ORDER BY name"
)
rows = cur.fetchall()
return jsonify({'categories': [{'id': r[0], 'name': r[1], 'parent_id': r[2]} for r in rows]})
finally:
conn.close()
# ─── Global Tier Discounts ───────────────────────
@inventory_bp.route('/tier-discounts', methods=['GET'])

View File

@@ -12,7 +12,6 @@ from flask import Blueprint, g, jsonify, request
from middleware import require_auth
from services import facturapi_service
from services.audit import log_action
from services.facturapi_service import FacturapiError
from services.cfdi_facturapi_builder import (
build_egreso_payload,
build_ingreso_payload,
@@ -44,16 +43,9 @@ def _get_issuer_config(cur, branch_id=None):
result = {
"rfc": config.get("tenant_rfc", ""),
"razon_social": config.get("tenant_razon_social", ""),
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"),
"regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
"cp": config.get("tenant_cp", "00000"),
"direccion": config.get("tenant_direccion", ""),
"exterior": config.get("tenant_numero_exterior", ""),
"interior": config.get("tenant_numero_interior", ""),
"colonia": config.get("tenant_colonia", ""),
"ciudad": config.get("tenant_ciudad", ""),
"municipio": config.get("tenant_municipio", ""),
"estado": config.get("tenant_estado", ""),
"serie": config.get("cfdi_serie") or config.get("invoice_serie", "A"),
"serie": config.get("cfdi_serie", "A"),
"facturapi_key": config.get("cfdi_facturapi_key", ""),
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
}
@@ -493,7 +485,7 @@ def get_sale_pdf(sale_id):
@invoicing_bp.route("/stats", methods=["GET"])
@require_auth("invoicing.view")
@require_auth("invoicing.read")
def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id)
@@ -675,7 +667,6 @@ def facturapi_setup():
(result["org_id"],),
)
if result.get("api_key"):
cur.execute(
"""
INSERT INTO tenant_config (key, value)
@@ -691,30 +682,18 @@ def facturapi_setup():
cur.close()
conn.close()
status_conn = get_tenant_conn(g.tenant_id)
try:
status_cur = status_conn.cursor()
status = facturapi_service.get_org_status(_get_issuer_config(status_cur))
status["org_id"] = result["org_id"]
if not result.get("legal_updated"):
status["error"] = status.get("error") or "Datos fiscales no pudieron configurarse automáticamente. Configúralos en el dashboard de Facturapi."
status_cur.close()
status_conn.close()
except Exception:
status_conn.close()
raise
return jsonify(status)
return jsonify(
{
"org_id": result["org_id"],
"message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
}
)
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 400
except FacturapiError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()

View File

@@ -24,8 +24,6 @@ Routes:
POST /pos/api/marketplace-ext/webhook/meli
"""
import urllib.parse
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn, get_master_conn
@@ -83,49 +81,6 @@ def get_config():
conn.close()
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
@require_auth()
def init_meli_connect():
"""Store client credentials server-side and return the MercadoLibre auth URL.
The frontend no longer keeps the client_secret in localStorage.
"""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
client_id = data.get("client_id", "").strip()
client_secret = data.get("client_secret", "").strip()
category = data.get("category", "").strip()
shipping = data.get("shipping", "").strip()
if not client_id or not client_secret:
return jsonify({"error": "client_id and client_secret required"}), 400
base = _get_public_base_url().rstrip("/")
redirect_uri = f"{base}/pos/marketplace-external/callback"
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.save_meli_config(conn, {
"meli_client_id": client_id,
"meli_client_secret": client_secret,
"meli_default_category_id": category,
"meli_shipping_mode": shipping,
})
finally:
conn.close()
auth_url = (
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
f"&client_id={urllib.parse.quote(client_id)}"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
"&scope=read+write+offline_access"
)
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
@marketplace_ext_bp.route("/connect", methods=["POST"])
@require_auth()
def connect_meli():
@@ -135,21 +90,12 @@ def connect_meli():
data = request.get_json() or {}
code = data.get("code")
client_id = data.get("client_id")
client_secret = data.get("client_secret")
redirect_uri = data.get("redirect_uri", "")
if not code:
return jsonify({"error": "code required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
client_id = data.get("client_id") or cfg.get("meli_client_id")
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
finally:
conn.close()
if not client_id or not client_secret:
return jsonify({"error": "ML credentials not configured"}), 400
if not code or not client_id or not client_secret:
return jsonify({"error": "code, client_id and client_secret required"}), 400
try:
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)

View File

@@ -13,8 +13,7 @@ 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,
create_remission_note, pay_pending_sale
get_price_for_customer, get_margin_info
)
from services.inventory_engine import get_stock
from services.audit import log_action
@@ -23,53 +22,6 @@ 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 _tenant_allows_zero_price(conn):
"""Return True if the tenant allows selling items at $0. Defaults to True."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
row = cur.fetchone()
cur.close()
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
def _validate_zero_price(conn, items):
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
if _tenant_allows_zero_price(conn):
return
for item in items:
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 1) or 1)
discount_pct = float(item.get('discount_pct', 0) or 0)
line_total = unit_price * quantity * (1 - discount_pct / 100)
if line_total <= 0:
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
def _validate_stock_availability(conn, items, branch_id):
"""Raise ValueError if stock is insufficient and the tenant forbids negative stock."""
if _tenant_allows_negative_stock(conn):
return
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1) or 1)
if not inv_id:
continue
available = get_stock(conn, inv_id, branch_id)
if available < qty:
name = item.get('name') or item.get('part_number') or inv_id
raise ValueError(f'Sin stock suficiente para {name}. Disponible: {available}, solicitado: {qty}')
def _enrich_items(cur, items, customer_id=None):
"""Look up inventory data for items that lack unit_price/tax_rate.
@@ -151,11 +103,20 @@ def create_sale():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
# Verify stock availability per item for the active branch
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
try:
_validate_stock_availability(conn, data.get('items', []), branch_id)
_validate_zero_price(conn, data.get('items', []))
sale = process_sale(conn, data)
conn.commit()
conn.close()
@@ -170,80 +131,8 @@ 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)
try:
_validate_stock_availability(conn, data.get('items', []), branch_id)
_validate_zero_price(conn, data.get('items', []))
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()
@require_auth('pos.view')
def list_sales():
"""List sales with filters.
@@ -257,13 +146,6 @@ 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()
@@ -278,10 +160,6 @@ 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:
@@ -299,18 +177,6 @@ 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))
@@ -329,14 +195,12 @@ 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.courier_id,
s.status, s.created_at,
e.name as employee_name,
c.name as customer_name,
co.name as courier_name
c.name as customer_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
@@ -355,9 +219,7 @@ 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]),
'courier_id': r[15],
'employee_name': r[16], 'customer_name': r[17],
'courier_name': r[18],
'employee_name': r[15], 'customer_name': r[16],
})
cur.close()
@@ -370,75 +232,6 @@ def list_sales():
})
@pos_bp.route('/sales/recent', methods=['GET'])
@require_auth('pos.view')
def recent_sales():
"""Return recent sales with their items in a single response.
Query params:
date_from: YYYY-MM-DD (defaults to today)
date_to: YYYY-MM-DD (defaults to today)
limit: int (default 10, max 50)
"""
from datetime import date
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
date_from = request.args.get('date_from') or str(date.today())
date_to = request.args.get('date_to') or date_from
limit = min(int(request.args.get('limit', 10)), 50)
where_clauses = [
"s.created_at >= %s",
"s.created_at < %s::date + interval '1 day'",
"s.status != 'cancelled'"
]
params = [date_from, date_to]
if g.branch_id:
where_clauses.append("s.branch_id = %s")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.customer_id, s.payment_method, s.total, s.status, s.created_at,
c.name as customer_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY s.created_at DESC
LIMIT %s
""", params + [limit])
sales = []
sale_ids = []
for r in cur.fetchall():
sale_ids.append(r[0])
sales.append({
'id': r[0], 'customer_id': r[1], 'payment_method': r[2],
'total': float(r[3]) if r[3] else 0, 'status': r[4],
'created_at': str(r[5]), 'customer_name': r[6],
'items': []
})
if sale_ids:
cur.execute("""
SELECT sale_id, name, quantity
FROM sale_items
WHERE sale_id = ANY(%s)
ORDER BY sale_id, id
""", (sale_ids,))
for r in cur.fetchall():
for sale in sales:
if sale['id'] == r[0]:
sale['items'].append({'name': r[1], 'quantity': r[2]})
break
cur.close(); conn.close()
return jsonify({'data': sales})
@pos_bp.route('/historical-sales', methods=['GET'])
@require_auth('pos.view')
def list_historical_sales():
@@ -476,16 +269,6 @@ def list_historical_sales():
where = " AND ".join(where_clauses)
# Defensive: the historical_sales table is created on demand by imports.
# If it does not exist yet, return an empty list instead of a 500.
cur.execute("SELECT to_regclass('historical_sales')")
if cur.fetchone()[0] is None:
cur.close(); conn.close()
return jsonify({
'data': [],
'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 0}
})
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
total = cur.fetchone()[0]
@@ -527,13 +310,9 @@ def list_historical_sales():
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
@require_auth()
@require_auth('pos.view')
def get_sale(sale_id):
"""Get sale detail with items."""
# Allow POS users or accounting users to view receivable/sale detail.
if g.employee_role != 'owner' and not ({'pos.view', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -599,17 +378,12 @@ def get_sale(sale_id):
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
@require_auth()
@require_auth('pos.sell')
def api_cancel_sale(sale_id):
"""Cancel a sale. Requires mandatory reason.
Body: {reason: str}
"""
# Allow POS sellers or accounting staff to cancel tickets from the
# receivables / accounting view.
if g.employee_role != 'owner' and not ({'pos.sell', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
data = request.get_json() or {}
reason = data.get('reason', '').strip()
@@ -746,8 +520,6 @@ def create_quotation():
# Enrich items with inventory data (price, tax, etc.)
try:
enriched = _enrich_items(cur, items, data.get('customer_id'))
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
@@ -910,23 +682,9 @@ def list_quotations():
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
@require_auth('pos.sell')
def delete_quotation(quot_id):
"""Delete a quotation, release its stock reservations and remove its items."""
"""Delete a quotation and its items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Release reserved stock before deleting items
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
# Continue with deletion even if release fails (e.g. no reservations)
pass
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
deleted = cur.rowcount
@@ -1136,8 +894,6 @@ def update_quotation(quot_id):
try:
enriched = _enrich_items(cur, items, data.get('customer_id'))
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
@@ -1230,8 +986,6 @@ def patch_quotation(quot_id):
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
old_status = row[1]
fields = []
params = []
if 'customer_id' in data:
@@ -1243,10 +997,9 @@ def patch_quotation(quot_id):
if 'valid_until' in data:
fields.append('valid_until = %s')
params.append(data['valid_until'])
new_status = data.get('status')
if new_status and new_status in ('active', 'cancelled', 'expired'):
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
fields.append('status = %s')
params.append(new_status)
params.append(data['status'])
if not fields:
cur.close(); conn.close()
@@ -1254,20 +1007,6 @@ def patch_quotation(quot_id):
params.append(quot_id)
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
# Release reservations when cancelling or expiring
if new_status in ('cancelled', 'expired') and old_status not in ('cancelled', 'expired', 'converted'):
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
pass
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation updated'})
@@ -1617,15 +1356,12 @@ 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,
}
try:
_validate_stock_availability(conn, items, g.branch_id)
_validate_zero_price(conn, items)
sale = process_sale(conn, sale_data)
# Mark quotation as converted
@@ -1784,8 +1520,6 @@ def create_layaway():
# Enrich items with inventory data
try:
enriched = _enrich_items(cur, items, customer_id)
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
@@ -2177,14 +1911,10 @@ def complete_layaway(layaway_id):
# Create sale_items (no inventory deduction — already reserved)
sale_items = []
inv_ids = [item['inventory_id'] for item in totals_calc['items']]
cur.execute("""
SELECT id, part_number, name, cost FROM inventory WHERE id = ANY(%s)
""", (inv_ids,))
inv_map = {r[0]: (r[1], r[2], r[3]) for r in cur.fetchall()}
for item in totals_calc['items']:
inv = inv_map.get(item['inventory_id'], ('', '', 0))
cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
(item['inventory_id'],))
inv = cur.fetchone()
cur.execute("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
@@ -2193,9 +1923,9 @@ def complete_layaway(layaway_id):
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
sale_id, item['inventory_id'],
inv[0] or '', inv[1] or '',
inv[0] if inv else '', inv[1] if inv else '',
item['quantity'], item['unit_price'],
float(inv[2]) if inv[2] else 0,
float(inv[2]) if inv and inv[2] else 0,
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal']
))
@@ -2337,7 +2067,7 @@ def create_return():
try:
# Validate sale exists and is completed
cur.execute("""
SELECT id, customer_id, total, status, branch_id, sale_type
SELECT id, customer_id, total, status, branch_id
FROM sales WHERE id = %s
""", (sale_id,))
sale = cur.fetchone()
@@ -2348,7 +2078,6 @@ def create_return():
sale_customer_id = sale[1]
sale_branch_id = sale[4] or g.branch_id
sale_type = sale[5]
# Validate each return item against original sale items
total_refund = 0
@@ -2450,10 +2179,10 @@ def create_return():
new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
# Update customer credit if the original sale was on credit
if sale_customer_id and sale_type == 'credit':
# Update customer credit if applicable
if sale_customer_id:
cur.execute("""
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) - %s
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) + %s
WHERE id = %s
""", (total_refund, sale_customer_id))
@@ -2692,79 +2421,6 @@ 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

@@ -3,21 +3,16 @@
Prefix: /pos/api/service-orders
"""
import json
from functools import wraps
from flask import Blueprint, g, jsonify, request
from middleware import require_auth
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,
delete_service_catalog_item,
delete_service_order,
get_kanban_summary,
get_service_order,
list_service_catalog,
@@ -33,136 +28,17 @@ from services.service_order_engine import (
update_status,
)
from tenant_db import get_tenant_conn
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
def _tenant_allows_zero_price(conn):
"""Return True if the tenant allows selling items at $0. Defaults to True."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
row = cur.fetchone()
cur.close()
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
def _validate_zero_price(conn, items):
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
if _tenant_allows_zero_price(conn):
return
for item in items:
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 1) or 1)
discount_pct = float(item.get('discount_pct', 0) or 0)
line_total = unit_price * quantity * (1 - discount_pct / 100)
if line_total <= 0:
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
# Roles allowed to access the workshop module at all.
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
def _can_view_workshop():
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
def _get_ws_perms():
"""Return effective workshop permissions for the current user."""
conn = get_tenant_conn(g.tenant_id)
try:
return _get_workshop_permissions(conn, g.employee_role)
finally:
conn.close()
def _visible_statuses():
if g.employee_role in ('owner', 'admin'):
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
return _get_ws_perms().get('statuses', [])
def _can_do_action(action):
if g.employee_role in ('owner', 'admin'):
return True
return action in _get_ws_perms().get('actions', [])
def _redact_order_for_restricted(order):
"""Remove customer/commercial data when the role cannot view sensitive data."""
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
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_restricted(it) for it in v]
else:
redacted[k] = v
return redacted
def _redact_item_for_restricted(item):
"""Hide sensitive item rows for restricted viewers."""
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_action(action):
def decorator(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
if not _can_do_action(action):
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
return f(*args, **kwargs)
return decorated
return decorator
@service_order_bp.route('', methods=['GET'])
@require_workshop_view
@require_auth()
def list_orders():
status = request.args.get('status')
priority = request.args.get('priority')
customer_id = request.args.get('customer_id', type=int)
employee_id = request.args.get('employee_id', type=int)
delivery_method = request.args.get('delivery_method')
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
q = request.args.get('q')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
@@ -171,65 +47,15 @@ def list_orders():
result = list_service_orders(
conn, status=status, branch_id=g.branch_id,
customer_id=customer_id, priority=priority,
employee_id=employee_id, delivery_method=delivery_method,
is_direct=is_direct, q=q, page=page, per_page=per_page
employee_id=employee_id, page=page, per_page=per_page
)
if g.employee_role not in ('owner', 'admin'):
visible = _visible_statuses()
result['data'] = [
_redact_order_for_restricted(o) for o in result.get('data', [])
if o.get('status') in visible
]
return jsonify(result)
finally:
conn.close()
@service_order_bp.route('/from-pos', methods=['POST'])
@require_auth('pos.sell')
def create_order_from_pos():
"""Create a service order directly from the POS cart (quotation)."""
data = request.get_json() or {}
items = data.get('items', [])
estimated_cost = sum(
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
for it in items
)
conn = get_tenant_conn(g.tenant_id)
try:
_validate_zero_price(conn, items)
result = create_service_order(conn, {
'tenant_id': g.tenant_id,
'branch_id': data.get('branch_id', g.branch_id),
'customer_id': data.get('customer_id'),
'vehicle_id': data.get('vehicle_id'),
'priority': data.get('priority', 'normal'),
'reception_notes': data.get('reception_notes'),
'estimated_cost': estimated_cost or None,
'estimated_completion': data.get('estimated_completion'),
'employee_id': data.get('employee_id'),
'mileage_in': data.get('mileage_in'),
'created_by': getattr(g, 'employee_id', None),
'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:
add_item(conn, so_id, item)
order = get_service_order(conn, so_id)
return jsonify(order), 201
finally:
conn.close()
@service_order_bp.route('', methods=['POST'])
@require_workshop_action('create_order')
@require_auth()
def create_order():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -247,14 +73,6 @@ def create_order():
'mileage_in': data.get('mileage_in'),
'fuel_level': data.get('fuel_level'),
'created_by': getattr(g, 'employee_id', None),
'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:
@@ -262,29 +80,22 @@ def create_order():
@service_order_bp.route('/<int:so_id>', methods=['GET'])
@require_workshop_view
@require_auth()
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
if order.get('status') not in _visible_statuses():
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
return jsonify(_redact_order_for_restricted(order))
return jsonify(order)
finally:
conn.close()
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
@require_workshop_action('edit_order')
@require_auth()
def update_order(so_id):
data = request.get_json() or {}
# 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
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_service_order(conn, so_id, data)
@@ -295,32 +106,13 @@ def update_order(so_id):
conn.close()
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
@require_workshop_action('delete_order')
def delete_order(so_id):
"""Soft-delete a service order. Restricted to owner/admin."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
delete_service_order(conn, so_id)
return jsonify({'message': 'Orden eliminada'})
except ValueError as e:
return jsonify({'error': str(e)}), 404
finally:
conn.close()
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
@require_workshop_action('change_status')
@require_auth()
def change_status(so_id):
data = request.get_json() or {}
new_status = data.get('status')
if not new_status:
return jsonify({'error': 'status is required'}), 400
if new_status not in _visible_statuses():
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
result = update_status(
@@ -338,7 +130,7 @@ def change_status(so_id):
# ─── Items (Parts) ─────────────────────────────
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
@require_workshop_action('add_items')
@require_auth()
def add_order_item(so_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -350,7 +142,7 @@ def add_order_item(so_id):
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_workshop_action('add_items')
@require_auth()
def update_order_item(item_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -364,7 +156,7 @@ def update_order_item(item_id):
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_workshop_action('add_items')
@require_auth()
def delete_order_item(item_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -377,7 +169,7 @@ def delete_order_item(item_id):
# ─── Labor ─────────────────────────────
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
@require_workshop_action('add_labor')
@require_auth()
def add_order_labor(so_id):
data = request.get_json() or {}
if not data.get('description'):
@@ -391,7 +183,7 @@ def add_order_labor(so_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
@require_workshop_action('add_labor')
@require_auth()
def update_order_labor(labor_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -405,7 +197,7 @@ def update_order_labor(labor_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
@require_workshop_action('add_labor')
@require_auth()
def delete_order_labor(labor_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -418,7 +210,7 @@ def delete_order_labor(labor_id):
# ─── Kanban Summary ─────────────────────────────
@service_order_bp.route('/kanban/summary', methods=['GET'])
@require_workshop_view
@require_auth()
def kanban_summary():
conn = get_tenant_conn(g.tenant_id)
try:
@@ -432,7 +224,7 @@ def kanban_summary():
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_workshop_action('add_items')
@require_auth()
def reserve_order_item(so_id, item_id):
"""Reserve inventory for a service order item."""
conn = get_tenant_conn(g.tenant_id)
@@ -446,7 +238,7 @@ def reserve_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
@require_workshop_action('add_items')
@require_auth()
def release_order_item(so_id, item_id):
"""Release a previous inventory reservation."""
conn = get_tenant_conn(g.tenant_id)
@@ -464,7 +256,6 @@ def release_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
@require_auth('pos.sell')
@require_workshop_action('convert_to_sale')
def convert_order_to_sale(so_id):
"""Convert a service order into a POS sale.
@@ -499,43 +290,11 @@ 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')
@require_workshop_action('convert_to_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_workshop_action('assign_mechanic')
@require_auth()
def assign_mechanic_endpoint(so_id):
"""Assign a mechanic/technician to a service order."""
data = request.get_json() or {}
@@ -553,144 +312,11 @@ def assign_mechanic_endpoint(so_id):
conn.close()
@service_order_bp.route('/customers', methods=['POST'])
@require_workshop_action('edit_order')
def create_customer_for_workshop():
"""Create a customer directly from the workshop flow."""
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'El nombre es obligatorio'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO customers
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, max_discount_pct, vehicle_info)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
data.get('branch_id', g.branch_id), data['name'], data.get('rfc'),
data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
data.get('cp'), data.get('email'), data.get('phone'), data.get('address'),
data.get('price_tier', 1), data.get('credit_limit', 0), data.get('max_discount_pct', 0),
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
))
customer_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/vehicles', methods=['POST'])
@require_workshop_action('edit_order')
def create_vehicle_for_workshop():
"""Create a fleet vehicle directly from the workshop flow.
Vehicles must be assigned to a customer. Brand and model are required;
plate is optional and VIN is not used.
"""
data = request.get_json() or {}
if not data.get('make') or not data.get('model'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute("""
INSERT INTO fleet_vehicles
(branch_id, customer_id, plate, make, model, year,
current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'),
data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), cust[0], data.get('notes'),
))
vehicle_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/inventory-search', methods=['GET'])
@require_workshop_view
def inventory_search():
"""Search active inventory for attaching parts to a service order.
Does not require inventory.view so workshop users can pick parts.
"""
q = request.args.get('q', '').strip()
if not q:
return jsonify({'data': []})
branch_id = request.args.get('branch_id', g.branch_id)
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
like = f'%{q}%'
if branch_id:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock
WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (branch_id, like, like, like))
else:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock_summary
WHERE inventory_id = i.id), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (like, like, like))
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4],
'stock': float(r[5]) if r[5] else 0,
'cost': float(r[6]) if r[6] else 0,
'price_1': float(r[7]) if r[7] else 0,
})
cur.close()
return jsonify({'data': items})
finally:
conn.close()
# ─── Service catalog (reusable labor) ─────────────
@service_order_bp.route('/service-catalog', methods=['GET'])
@require_workshop_view
@require_auth()
def list_catalog():
"""List reusable labor/service concepts."""
active_only = request.args.get('active_only', 'true').lower() != 'false'
@@ -703,7 +329,7 @@ def list_catalog():
@service_order_bp.route('/service-catalog', methods=['POST'])
@require_workshop_action('edit_order')
@require_auth()
def create_catalog_item():
"""Create a reusable labor concept."""
data = request.get_json() or {}
@@ -719,7 +345,7 @@ def create_catalog_item():
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
@require_workshop_action('edit_order')
@require_auth()
def update_catalog_item(item_id):
"""Update a reusable labor concept."""
data = request.get_json() or {}
@@ -734,7 +360,7 @@ def update_catalog_item(item_id):
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
@require_workshop_action('edit_order')
@require_auth()
def delete_catalog_item(item_id):
"""Soft-delete a reusable labor concept."""
conn = get_tenant_conn(g.tenant_id)
@@ -749,7 +375,7 @@ def delete_catalog_item(item_id):
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_workshop_action('edit_order')
@require_auth()
def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order.

View File

@@ -28,7 +28,7 @@ def enqueue_report():
@tasks_bp.route('/<task_id>/status', methods=['GET'])
@require_auth()
@require_auth
def task_status(task_id):
"""Get status of a background task."""
from celery_app import celery

View File

@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
# Check permissions
if required_permissions:
missing = set(required_permissions) - g.permissions
# owner/admin roles bypass all permission checks
if g.employee_role not in ('owner', 'admin') and missing:
# owner role bypasses all permission checks
if g.employee_role != 'owner' 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 in ('owner', 'admin') or permission in g.permissions
return g.employee_role == 'owner' or permission in g.permissions

View File

@@ -52,24 +52,6 @@ MIGRATIONS = {
"v4.3": "v4.3_facturapi.sql",
"v4.4": "v4.4_workshop.sql",
"v4.5": "v4.5_customer_max_discount.sql",
"v4.6": "v4.6_inventory_support.sql",
"v4.7": "v4.7_workshop_business.sql",
"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",
}
@@ -125,10 +107,7 @@ def apply_migration(db_name, version):
def run_migrations():
"""Apply pending migrations to all tenants."""
tenants = get_all_tenants()
def _version_key(v):
return tuple(int(x) for x in v.lstrip('v').split('.'))
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
sorted_versions = sorted(MIGRATIONS.keys())
print(f"Found {len(tenants)} active tenants")
print(f"Available migrations: {sorted_versions}")
@@ -136,9 +115,8 @@ 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_key(version) <= current_key:
if version <= current_version:
continue
print(f" Applying {version}...", end=" ")

View File

@@ -1,20 +0,0 @@
-- v4.10: add fleet RBAC permissions for existing employees.
-- Owner and admin get full fleet access; accountant and workshop get read access.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('fleet.view'),
('fleet.create'),
('fleet.edit'),
('fleet.delete')
) AS p(perm)
WHERE e.role IN ('owner', 'admin')
ON CONFLICT (employee_id, permission) DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'fleet.view'
FROM employees e
WHERE e.role IN ('accountant', 'workshop')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,24 +0,0 @@
-- v4.11_clean_workshop_mechanic_permissions.sql
-- Restringe los permisos de los empleados con rol 'workshop' y 'mechanic'
-- para que solo tengan acceso a la seccion de Taller y no vean precios.
-- Ejecutar en cada base de datos de tenant.
-- 1. Eliminar cualquier permiso que no sea de taller para estos roles
DELETE FROM employee_permissions
WHERE employee_id IN (
SELECT id FROM employees WHERE role IN ('workshop', 'mechanic')
)
AND permission NOT IN ('workshop.view', 'workshop.edit', 'workshop.add_items');
-- 2. Asegurar que tengan los permisos base de taller
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(permission)
WHERE e.role IN ('workshop', 'mechanic')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -1,11 +0,0 @@
-- v4.12_service_order_soft_delete.sql
-- Soft-delete support for service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_is_deleted
ON service_orders (is_deleted);
-- Exclude deleted orders from common queries by default via partial index helpers
-- (application filters explicitly).

View File

@@ -1,20 +0,0 @@
-- v4.13_fleet_vehicle_customer.sql
-- Link fleet vehicles to a customer.
ALTER TABLE fleet_vehicles
ADD COLUMN IF NOT EXISTS customer_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fleet_vehicles_customer_id_fkey'
) THEN
ALTER TABLE fleet_vehicles
ADD CONSTRAINT fleet_vehicles_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES customers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_customer_id
ON fleet_vehicles (customer_id);

View File

@@ -1,10 +0,0 @@
-- 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

@@ -1,18 +0,0 @@
-- 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

@@ -1,18 +0,0 @@
-- 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

@@ -1,6 +0,0 @@
-- 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

@@ -1,32 +0,0 @@
-- 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

@@ -1,11 +0,0 @@
-- 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

@@ -1,18 +0,0 @@
-- 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

@@ -1,15 +0,0 @@
-- 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

@@ -1,15 +0,0 @@
-- 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

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

View File

@@ -1,20 +0,0 @@
-- Tenant DB schema v4.6 — support tables for inventory UI
-- Product categories (hierarchical, optional)
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_active ON categories(is_active);
-- Tier-based discount configuration for price tiers 2 and 3
CREATE TABLE IF NOT EXISTS tier_discounts (
tier_id INTEGER PRIMARY KEY,
tier_name VARCHAR(50) NOT NULL,
discount_pct NUMERIC(5,2) DEFAULT 0
);

View File

@@ -1,11 +0,0 @@
-- v4.6 Workshop business fields
-- Adds delivery method, courier assignment and direct-order flag to service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS delivery_method VARCHAR(30),
ADD COLUMN IF NOT EXISTS courier_id INTEGER REFERENCES couriers(id),
ADD COLUMN IF NOT EXISTS is_direct BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_delivery_method ON service_orders(delivery_method);
CREATE INDEX IF NOT EXISTS idx_service_orders_courier_id ON service_orders(courier_id);
CREATE INDEX IF NOT EXISTS idx_service_orders_is_direct ON service_orders(is_direct);

View File

@@ -1,15 +0,0 @@
-- v4.8 Workshop permissions seed
-- Grants workshop permissions to existing admin employees so they keep access
-- after the new role-based restrictions are enforced.
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.view'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.edit'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;

View File

@@ -1,7 +0,0 @@
-- v4.9: Grant customers.view permission to existing workshop employees
-- so they can load the customer list when creating service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'customers.view'
FROM employees e
WHERE e.role = 'workshop'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -46,22 +46,18 @@ def _get_account_id(cur, code):
def _get_account_ids(cur, codes):
"""Look up multiple account IDs by code. Returns dict {code: id}."""
cur.execute(
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true",
(list(codes),)
)
rows = {row[0]: row[1] for row in cur.fetchall()}
missing = set(codes) - set(rows)
if missing:
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
return rows
result = {}
for code in codes:
result[code] = _get_account_id(cur, code)
return result
def get_next_entry_number(conn):
"""Get the next sequential journal entry number.
Uses a transaction-level advisory lock to prevent duplicate numbers
when multiple journal entries are created concurrently.
Uses a simple MAX+1 approach. For high-concurrency environments this
could be replaced with a sequence, but for single-tenant refaccionarias
the transaction-level lock from the INSERT is sufficient.
Args:
conn: psycopg2 connection to tenant DB
@@ -70,7 +66,6 @@ def get_next_entry_number(conn):
int: next entry number (starts at 1)
"""
cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
number = cur.fetchone()[0]
cur.close()

View File

@@ -29,13 +29,8 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001.
Uses a transaction-level advisory lock to avoid duplicate provisional
folios when multiple CFDIs are enqueued concurrently.
"""
"""Generate a provisional folio like PRE-00001."""
cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0]
cur.close()
@@ -106,7 +101,6 @@ def process_queue(conn, tenant_config, dry_run=False):
AND retry_count < %s
ORDER BY created_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED
""",
(MAX_RETRIES,),
)
@@ -370,7 +364,7 @@ def get_queue_status(conn, filters=None):
params.append(int(filters["sale_id"]))
if filters.get("type"):
where_clauses.append("LOWER(q.type) = LOWER(%s)")
where_clauses.append("q.type = %s")
params.append(filters["type"])
where = " AND ".join(where_clauses)
@@ -382,12 +376,8 @@ def get_queue_status(conn, filters=None):
f"""
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
q.retry_count, q.provisional_folio, q.error_message,
q.cancel_motive, q.created_at, q.stamped_at, q.external_id,
c.name as customer_name, c.rfc,
s.subtotal, s.tax_total, s.total, s.payment_method
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q
LEFT JOIN sales s ON q.sale_id = s.id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY q.created_at DESC
LIMIT %s OFFSET %s
@@ -411,12 +401,6 @@ def get_queue_status(conn, filters=None):
"created_at": str(r[9]) if r[9] else None,
"stamped_at": str(r[10]) if r[10] else None,
"external_id": r[11],
"customer_name": r[12],
"rfc": r[13],
"subtotal": float(r[14]) if r[14] else 0,
"tax_total": float(r[15]) if r[15] else 0,
"total": float(r[16]) if r[16] else 0,
"payment_method": r[17],
}
)

View File

@@ -206,7 +206,7 @@ def find_organization_by_rfc(tenant_config: dict) -> dict | None:
def create_organization(tenant_config: dict) -> dict:
"""Create or reuse a Facturapi organization, configure legal data and return a live key.
"""Create a new Facturapi organization for the tenant and return live key.
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
Uses tenant RFC/razon_social if available.
@@ -216,18 +216,8 @@ def create_organization(tenant_config: dict) -> dict:
rfc = (tenant_config.get("rfc") or "").upper().strip()
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
# 1) Reuse the organization already stored locally, if it still exists in Facturapi.
local_org_id = _get_org_id(tenant_config)
if local_org_id:
try:
get_organization(local_org_id, user_key)
org_id = local_org_id
except FacturapiError:
local_org_id = None
# 2) Try to find an existing organization by RFC (legacy / Horux-style lookup).
if not local_org_id and rfc:
existing = find_organization_by_rfc(tenant_config)
# First try to find existing org by RFC
existing = find_organization_by_rfc(tenant_config) if rfc else None
if existing:
org_id = existing["id"]
else:
@@ -236,20 +226,6 @@ def create_organization(tenant_config: dict) -> dict:
org_id = org.get("id")
if not org_id:
raise FacturapiError("Could not create organization: no id returned")
elif not local_org_id:
payload = {"name": name}
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
org_id = org.get("id")
if not org_id:
raise FacturapiError("Could not create organization: no id returned")
# Configure fiscal/legal data (required before Live mode can be used)
try:
update_organization_legal(tenant_config, org_id)
except FacturapiError:
# If legal update fails, still return org info so the caller can surface it,
# but do not generate a live key because it would be unusable.
return {"org_id": org_id, "api_key": None, "legal_updated": False}
# Generate live secret key
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
@@ -257,80 +233,7 @@ def create_organization(tenant_config: dict) -> dict:
if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key, "legal_updated": True}
def _build_legal_payload(tenant_config: dict) -> dict:
"""Build Facturapi /organizations/{id}/legal payload from tenant config.
Facturapi expects `name`, `legal_name`, `tax_system` and an `address` object
with at least `street` and `exterior` non-empty. `tax_id` is not accepted
by this endpoint (it is set from the CSD or from the organization profile).
"""
rfc = (tenant_config.get("rfc") or "").upper().strip()
legal_name = (tenant_config.get("razon_social") or "").strip()
tax_system = (tenant_config.get("regimen_fiscal") or "").strip() or "601"
zip_code = (tenant_config.get("cp") or "").strip() or "00000"
if not rfc or not legal_name:
raise FacturapiError("RFC y Razón Social son obligatorios para configurar la organización en Facturapi")
name = legal_name or rfc or "Nexus"
street = (tenant_config.get("direccion") or "").strip() or "No especificada"
exterior = (
(tenant_config.get("exterior") or "").strip()
or (tenant_config.get("numero_exterior") or "").strip()
or "S/N"
)
address = {
"zip": zip_code,
"street": street,
"exterior": exterior,
}
optional_address_fields = {
"interior": (tenant_config.get("interior") or tenant_config.get("numero_interior") or "").strip(),
"neighborhood": (tenant_config.get("colonia") or "").strip(),
"city": (tenant_config.get("ciudad") or "").strip(),
"municipality": (tenant_config.get("municipio") or "").strip(),
"state": (tenant_config.get("estado") or "").strip(),
}
for key, value in optional_address_fields.items():
if value:
address[key] = value
return {
"name": name,
"legal_name": legal_name,
"tax_system": tax_system,
"address": address,
}
def update_organization_legal(tenant_config: dict, org_id: str) -> dict:
"""Update the legal/fiscal data of a Facturapi organization.
Uses the user key (admin) because the org may not have a live key yet.
"""
user_key = _get_user_key_for_tenant(tenant_config)
payload = _build_legal_payload(tenant_config)
return _request("PUT", f"/organizations/{org_id}/legal", user_key, json_payload=payload, timeout=60)
def _get_status_key(tenant_config: dict) -> str | None:
"""Return the best key for read-only organization status checks.
Prefer the Facturapi user key because the live secret key cannot read
organization metadata until the org is production-ready.
"""
user = _get_user_key()
if user:
return user
for key in ("facturapi_key", "cfdi_facturapi_key"):
tenant_key = (tenant_config.get(key) or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
return _get_secret_key(tenant_config)
return {"org_id": org_id, "api_key": live_key}
def get_org_status(tenant_config: dict) -> dict:
@@ -346,12 +249,12 @@ def get_org_status(tenant_config: dict) -> dict:
"error": None,
}
has_secret = bool(_get_secret_key(tenant_config))
has_user = bool(_get_user_key_for_tenant(tenant_config))
if not has_secret and not has_user:
result["error"] = "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
return result
try:
api_key = get_api_key(tenant_config)
result["has_key"] = True
except FacturapiError as e:
result["error"] = str(e)
return result
org_id = _get_org_id(tenant_config)
if not org_id:
@@ -361,35 +264,20 @@ def get_org_status(tenant_config: dict) -> dict:
result["has_org_id"] = True
result["org_id"] = org_id
api_key = _get_status_key(tenant_config)
if not api_key:
result["error"] = "No Facturapi key available"
return result
def _fetch():
try:
org = get_organization(org_id, api_key)
legal = org.get("legal", {})
cert = org.get("certificate", {})
return {
result.update(
{
"configured": True,
"has_csd": bool(cert.get("has_certificate")),
"legal_name": legal.get("name") or legal.get("legal_name"),
"tax_id": legal.get("tax_id"),
"pending_steps": org.get("pending_steps", []),
}
try:
result.update(_fetch())
)
except FacturapiError as e:
# If the org reports "not configured for Live" (401) and we have a user key,
# try to backfill legal data and retry.
if e.status_code == 401 and _get_user_key_for_tenant(tenant_config):
try:
update_organization_legal(tenant_config, org_id)
result.update(_fetch())
except FacturapiError as e2:
result["error"] = str(e2)
else:
result["error"] = str(e)
return result

View File

@@ -58,18 +58,19 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
cur.close()
return []
# Load sale details with items in two bulk queries (O(1) round-trips)
# Load sale details with items
sales = []
for sale_id in sale_ids:
cur.execute("""
SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales
WHERE id = ANY(%s)
ORDER BY created_at ASC
""", (sale_ids,))
FROM sales WHERE id = %s
""", (sale_id,))
row = cur.fetchone()
if not row:
continue
sales = {}
for row in cur.fetchall():
sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
'employee_id': row[3], 'sale_type': row[4],
@@ -84,37 +85,33 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
'created_at': str(row[13]),
'items': [],
}
sales[row[0]] = sale
cur.execute("""
SELECT id, sale_id, inventory_id, part_number, name, quantity, unit_price,
SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad
FROM sale_items
WHERE sale_id = ANY(%s)
ORDER BY sale_id, id
""", (sale_ids,))
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
for r in cur.fetchall():
sale = sales.get(r[1])
if not sale:
continue
sale['items'].append({
'id': r[0], 'inventory_id': r[2], 'part_number': r[3],
'name': r[4], 'quantity': r[5],
'unit_price': float(r[6]) if r[6] else 0,
'unit_cost': float(r[7]) if r[7] else 0,
'discount_pct': float(r[8]) if r[8] else 0,
'discount_amount': float(r[9]) if r[9] else 0,
'tax_rate': float(r[10]) if r[10] else 0.16,
'tax_amount': float(r[11]) if r[11] else 0,
'subtotal': float(r[12]) if r[12] else 0,
'clave_prod_serv': r[13] or '25174800',
'clave_unidad': r[14] or 'H87',
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
'name': r[3], 'quantity': r[4],
'unit_price': float(r[5]) if r[5] else 0,
'unit_cost': float(r[6]) if r[6] else 0,
'discount_pct': float(r[7]) if r[7] else 0,
'discount_amount': float(r[8]) if r[8] else 0,
'tax_rate': float(r[9]) if r[9] else 0.16,
'tax_amount': float(r[10]) if r[10] else 0,
'subtotal': float(r[11]) if r[11] else 0,
'clave_prod_serv': r[12] or '25174800',
'clave_unidad': r[13] or 'H87',
})
sales.append(sale)
cur.close()
return list(sales.values())
return sales
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,

View File

@@ -122,14 +122,7 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
))
op_id = cur.fetchone()[0]
# Queue ML stock sync if this product has an active ML listing.
# Skip gracefully if the marketplace tables do not exist in this tenant.
cur.execute("""
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('marketplace_listings', 'meli_sync_queue')
""")
if cur.fetchone()[0] >= 2:
# Queue ML stock sync if this product has an active ML listing
cur.execute("""
INSERT INTO meli_sync_queue (inventory_id, action, status)
SELECT %s, 'stock_update', 'pending'
@@ -237,54 +230,6 @@ 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(
@@ -429,7 +374,7 @@ def get_movement_history(conn, inventory_id, limit=50):
history.append({
'id': r[0], 'type': r[1], 'quantity': r[2],
'cost': float(r[3]) if r[3] else None,
'notes': r[4], 'date': r[5].isoformat() if r[5] else None,
'notes': r[4], 'date': str(r[5]),
'employee': r[6], 'branch_id': r[7]
})
cur.close()

View File

@@ -11,13 +11,10 @@ Tax: 16% IVA per item (from item.tax_rate field).
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
import threading
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,
@@ -226,9 +223,9 @@ def process_sale(conn, sale_data):
if not items:
raise ValueError("No items in sale")
# Validate register is open and lock it to prevent concurrent close/sale races
# Validate register is open
if register_id:
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
reg = cur.fetchone()
if not reg or reg[0] != 'open':
raise ValueError("Cash register is not open")
@@ -252,17 +249,6 @@ def process_sale(conn, sale_data):
# Batch stock check
stock_map = get_stock_bulk(conn, branch_id)
# Lock per-branch stock rows and refresh stock map to prevent overselling
# on concurrent sales of the same items.
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()}
# Validate and enrich items
enriched_items = []
for item in items:
@@ -315,34 +301,24 @@ 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:
if totals['total'] > credit_available and credit_limit > 0:
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' or is_pending else 'PUE'
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
forma_pago_map = {
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '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
@@ -350,14 +326,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,%s,%s,%s,%s,%s)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%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,
status, _safe_g('device_id'), notes,
_safe_g('device_id'), notes,
currency, exchange_rate
))
sale_id, created_at = cur.fetchone()
@@ -416,21 +392,18 @@ def process_sale(conn, sale_data):
'subtotal': item['subtotal'],
})
# Record payment on cash register (skip pending sales and zero-amount rows)
if not is_pending:
# 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', '')
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:
elif register_id:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
@@ -445,16 +418,6 @@ 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={
@@ -531,8 +494,6 @@ 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,
@@ -553,342 +514,6 @@ 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.
@@ -930,15 +555,15 @@ def cancel_sale(conn, sale_id, reason):
if s_status == 'cancelled':
raise ValueError("Sale is already cancelled")
# Permission check: non-admin employees can only cancel their own docs within 30 min
# Permission check: cashiers can only cancel own sales within 30 min
role = _safe_g('employee_role', 'cashier')
emp_id = _safe_g('employee_id')
if role not in ('owner', 'admin'):
if role == 'cashier':
if s_emp_id != emp_id:
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
raise ValueError("Cashiers can only cancel their own sales")
if datetime.utcnow() - s_created > timedelta(minutes=30):
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
# Get sale items for inventory reversal
cur.execute("""
@@ -947,16 +572,7 @@ def cancel_sale(conn, sale_id, reason):
""", (sale_id,))
sale_items = cur.fetchall()
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)
# Reverse inventory: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
@@ -999,7 +615,7 @@ def cancel_sale(conn, sale_id, reason):
# Audit log
log_action(conn, 'CANCEL', 'sale', sale_id,
old_value={'status': s_status, 'total': float(s_total)},
old_value={'status': 'completed', 'total': float(s_total)},
new_value={'status': 'cancelled', 'reason': reason})
# Push notification to owner/admin (best-effort, non-blocking)

View File

@@ -8,66 +8,23 @@ from datetime import datetime
from services import inventory_engine
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')
# 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 = {
status: [s for s in ORDER_STATUSES if s != status]
for status in ORDER_STATUSES
'received': ['diagnosis', 'cancelled'],
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
'waiting_parts': ['repair', 'cancelled'],
'repair': ['quality_check', 'cancelled'],
'quality_check': ['ready', 'repair', 'cancelled'],
'ready': ['delivered', 'cancelled'],
'delivered': [],
'cancelled': [],
}
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):
"""Generate DDMMYYYY-N order number (daily sequential).
Uses a per-day advisory transaction lock to avoid duplicate order
numbers when multiple workers create orders concurrently.
"""
"""Generate SO-YYYY-NNNN order number."""
cur = conn.cursor()
today = datetime.utcnow().strftime('%d%m%Y')
prefix = f"{today}-"
# Serialize order creation per day within the current transaction.
cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (prefix,))
year = datetime.utcnow().year
prefix = f"SO-{year}-"
cur.execute("""
SELECT order_number FROM service_orders
WHERE order_number LIKE %s
@@ -80,19 +37,7 @@ def _generate_order_number(conn):
last_num = int(row[0].split('-')[-1])
new_num = last_num + 1
cur.close()
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
return f"{prefix}{new_num:04d}"
def create_service_order(conn, data):
@@ -101,11 +46,9 @@ def create_service_order(conn, data):
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, requires_invoice
employee_id, mileage_in, fuel_level, created_by
}
"""
_normalize_delivery(data)
cur = conn.cursor()
order_number = _generate_order_number(conn)
@@ -113,23 +56,16 @@ 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, 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)
employee_id, mileage_in, fuel_level, created_by)
VALUES (%s, %s, %s, %s, %s, 'received', %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('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'),
data.get('employee_id'), data.get('mileage_in'),
data.get('fuel_level'), data.get('created_by'),
))
so_id = cur.fetchone()[0]
@@ -137,7 +73,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, 'por_revisar', %s, 'Orden creada')
VALUES (%s, 'received', %s, 'Orden creada')
""", (so_id, data.get('created_by')))
conn.commit()
@@ -150,28 +86,18 @@ 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.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,
so.branch_id, so.reception_notes, so.diagnosis_notes, so.repair_notes,
so.delivery_notes, so.estimated_cost, so.final_cost,
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.requires_invoice, so.sale_id,
so.workshop_name, so.customer_address, so.customer_phone, so.vehicle_description
so.created_by, so.created_at, so.updated_at
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 employees e ON so.employee_id = e.id
LEFT JOIN employees creator ON so.created_by = creator.id
LEFT JOIN branches b ON so.branch_id = b.id
LEFT JOIN couriers co ON so.courier_id = co.id
WHERE so.id = %s AND so.is_deleted = false
WHERE so.id = %s
""", (so_id,))
row = cur.fetchone()
if not row:
@@ -181,51 +107,35 @@ 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], '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],
'vehicle_id': row[7], 'vehicle_plate': row[8], 'vehicle_make': row[9], 'vehicle_model': row[10],
'branch_id': row[11], 'reception_notes': row[12], 'diagnosis_notes': row[13],
'repair_notes': row[14], 'delivery_notes': row[15],
'estimated_cost': float(row[16]) if row[16] else None,
'final_cost': float(row[17]) if row[17] else None,
'estimated_completion': str(row[18]) if row[18] else None,
'actual_completion': str(row[19]) if row[19] else None,
'delivered_at': str(row[20]) if row[20] else None,
'mileage_in': row[21], 'mileage_out': row[22], 'fuel_level': row[23],
'employee_id': row[24], 'employee_name': row[25],
'created_by': row[26], 'created_at': str(row[27]), 'updated_at': str(row[28]),
}
# Items
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
mechanic_id, observations
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
FROM service_order_items
WHERE service_order_id = %s
ORDER BY id
""", (so_id,))
so['items'] = []
total_parts = 0.0
for r in cur.fetchall():
qty = float(r[4]) if r[4] else 0
price = float(r[6]) if r[6] else 0
so['items'].append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'quantity': qty,
'quantity': float(r[4]) if r[4] else 0,
'unit_cost': float(r[5]) if r[5] else None,
'unit_price': price,
'unit_price': float(r[6]) if r[6] else None,
'status': r[7], 'notes': r[8],
'mechanic_id': r[9], 'observations': r[10],
})
total_parts += qty * price
# Labor
cur.execute("""
@@ -235,37 +145,27 @@ def get_service_order(conn, so_id):
ORDER BY id
""", (so_id,))
so['labor'] = []
total_labor = 0.0
for r in cur.fetchall():
total = float(r[4]) if r[4] else 0
so['labor'].append({
'id': r[0], 'description': r[1],
'hours': float(r[2]) if r[2] else 0,
'hourly_rate': float(r[3]) if r[3] else 0,
'total_cost': total,
'total_cost': float(r[4]) if r[4] else 0,
'employee_id': r[5], 'status': r[6],
})
total_labor += total
so['total_parts'] = round(total_parts, 2)
so['total_labor'] = round(total_labor, 2)
so['total'] = round(total_parts + total_labor, 2)
# Status history
cur.execute("""
SELECT h.id, h.old_status, h.new_status, h.changed_by, e.name as changed_by_name,
h.notes, h.created_at
FROM service_order_status_history h
LEFT JOIN employees e ON h.changed_by = e.id
WHERE h.service_order_id = %s
ORDER BY h.created_at
SELECT id, old_status, new_status, changed_by, notes, created_at
FROM service_order_status_history
WHERE service_order_id = %s
ORDER BY created_at
""", (so_id,))
so['status_history'] = []
for r in cur.fetchall():
so['status_history'].append({
'id': r[0], 'old_status': r[1], 'new_status': r[2],
'changed_by': r[3], 'changed_by_name': r[4],
'notes': r[5], 'created_at': str(r[6]),
'changed_by': r[3], 'notes': r[4], 'created_at': str(r[5]),
})
cur.close()
@@ -273,8 +173,7 @@ def get_service_order(conn, so_id):
def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
priority=None, employee_id=None, delivery_method=None,
is_direct=None, q=None, page=1, per_page=50):
priority=None, employee_id=None, page=1, per_page=50):
cur = conn.cursor()
where_clauses = []
params = []
@@ -294,49 +193,22 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
if employee_id:
where_clauses.append("so.employee_id = %s")
params.append(employee_id)
if delivery_method:
where_clauses.append("so.delivery_method = %s")
params.append(delivery_method)
if is_direct is not 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 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)
where = " AND ".join(where_clauses) if where_clauses else "true"
cur.execute(f"""
SELECT count(*) 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
WHERE {where}
SELECT count(*) FROM service_orders so WHERE {where}
""", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT so.id, so.order_number, so.status, so.priority,
so.customer_id, c.name as customer_name,
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,
so.estimated_cost, so.final_cost,
so.delivery_method, co.name as courier_name, so.is_direct,
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
so.vehicle_id, fv.plate as vehicle_plate,
so.estimated_cost, so.estimated_completion, so.created_at
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
@@ -351,25 +223,13 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
orders = []
for r in cur.fetchall():
estimated = float(r[12]) if r[12] else 0
final = float(r[13]) if r[13] else None
orders.append({
'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3],
'customer_id': r[4], 'customer_name': r[5],
'vehicle_id': r[6], 'vehicle_plate': r[7], 'vehicle_make': r[8], 'vehicle_model': r[9],
'branch_id': r[10], 'branch_name': r[11],
'estimated_cost': estimated,
'final_cost': final,
'delivery_method': r[14], 'courier_name': r[15],
'is_direct': bool(r[16]) if r[16] is not None else False,
'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
'vehicle_id': r[6], 'vehicle_plate': r[7],
'estimated_cost': float(r[8]) if r[8] else None,
'estimated_completion': str(r[9]) if r[9] else None,
'created_at': str(r[10]),
})
cur.close()
@@ -383,7 +243,7 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
def update_status(conn, so_id, new_status, changed_by=None, notes=None):
"""Update service order status with validation."""
cur = conn.cursor()
cur.execute("SELECT status FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
cur.execute("SELECT status FROM service_orders WHERE id = %s", (so_id,))
row = cur.fetchone()
if not row:
cur.close()
@@ -397,9 +257,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
# Update status
extra_sets = []
extra_vals = []
if new_status == 'reparada':
if new_status == 'ready':
extra_sets.append("actual_completion = NOW()")
if new_status == 'entregado':
if new_status == 'delivered':
extra_sets.append("delivered_at = NOW()")
extra_sets.append("delivered_by = %s")
extra_vals.append(changed_by)
@@ -428,16 +288,14 @@ 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,
mechanic_id, observations)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
(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)
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()
@@ -447,7 +305,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', 'mechanic_id', 'observations']
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
sets = []
vals = []
for field in allowed:
@@ -529,14 +387,10 @@ 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',
allowed = ['priority', 'reception_notes', 'diagnosis_notes', 'repair_notes',
'delivery_notes', 'estimated_cost', 'estimated_completion',
'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']
'employee_id', 'mileage_out', 'fuel_level', 'final_cost']
sets = []
vals = []
for field in allowed:
@@ -565,20 +419,19 @@ def get_kanban_summary(conn, branch_id=None):
cur.execute(f"""
SELECT status, COUNT(*) as cnt
FROM service_orders
WHERE status != 'cancelled' AND is_deleted = false {branch_filter}
WHERE status != 'cancelled' {branch_filter}
GROUP BY status
""", params)
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
for r in cur.fetchall():
summary[r[0]] = r[1]
# Overdue orders (estimated_completion passed and not delivered/invoiced)
# Overdue orders (estimated_completion passed and not ready/delivered)
cur.execute(f"""
SELECT count(*) FROM service_orders
WHERE estimated_completion < NOW()
AND status NOT IN ('entregado', 'facturada', 'cancelada')
AND is_deleted = false
AND status NOT IN ('ready', 'delivered', 'cancelled')
{branch_filter}
""", params)
overdue = cur.fetchone()[0]
@@ -601,7 +454,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
cur.execute(
"""
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
so.order_number, so.branch_id
so.order_number
FROM service_order_items soi
JOIN service_orders so ON so.id = soi.service_order_id
WHERE soi.id = %s
@@ -613,7 +466,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
cur.close()
raise ValueError("Service order item not found")
so_id, inventory_id, quantity, status, order_number, branch_id = row
so_id, inventory_id, quantity, status, order_number = row
if status == "cancelled":
cur.close()
raise ValueError("Cannot reserve a cancelled item")
@@ -622,11 +475,10 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
raise ValueError("Item has no inventory linked")
qty = int(quantity)
if not _tenant_allows_negative_stock(conn):
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
if available < qty:
cur.close()
raise ValueError(f"Sin stock suficiente. Disponible: {available}, solicitado: {qty}")
raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
inventory_engine.record_operation(
conn,
@@ -891,156 +743,10 @@ 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()
cur.execute("SELECT id FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
cur.execute("SELECT id FROM service_orders WHERE id = %s", (so_id,))
if not cur.fetchone():
cur.close()
raise ValueError("Service order not found")
@@ -1054,21 +760,6 @@ def assign_mechanic(conn, so_id, employee_id):
return {"employee_id": employee_id}
def delete_service_order(conn, so_id):
"""Soft-delete a service order."""
cur = conn.cursor()
cur.execute(
"UPDATE service_orders SET is_deleted = true WHERE id = %s AND is_deleted = false",
(so_id,),
)
deleted = cur.rowcount
conn.commit()
cur.close()
if deleted == 0:
raise ValueError("Service order not found")
return {"deleted": True}
# ─── Service catalog (reusable labor concepts) ───────────────────────────────

View File

@@ -247,8 +247,7 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
'accounting.view', 'accounting.create', 'accounting.close',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices',
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'
'config.view', 'config.edit', 'config.edit_prices'
]
tenant_cur.executemany(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",

View File

@@ -16,13 +16,9 @@ ALIGN_CENTER = ESC + b'a' + b'\x01'
ALIGN_RIGHT = ESC + b'a' + b'\x02'
BOLD_ON = ESC + b'E' + b'\x01'
BOLD_OFF = ESC + b'E' + b'\x00'
EMPH_ON = ESC + b'G' + b'\x01' # Double-strike / emphasized
EMPH_OFF = ESC + b'G' + b'\x00'
DOUBLE_HEIGHT = ESC + b'!' + b'\x10'
NORMAL_SIZE = ESC + b'!' + b'\x00'
LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height
LINE_SPACING_140 = ESC + b'3' + b'\x28' # ~1.4x line spacing
LINE_SPACING_DEFAULT = ESC + b'2'
def generate_ticket(sale_data, business_info, width=80):
@@ -38,8 +34,6 @@ def generate_ticket(sale_data, business_info, width=80):
chars = 32 if width == 58 else 48 # characters per line
buf = bytearray()
buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header: business name (centered, bold, large)
buf += ALIGN_CENTER
@@ -106,10 +100,7 @@ def generate_ticket(sale_data, business_info, width=80):
buf += 'Gracias por su compra!\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n'
buf += LINE_SPACING_DEFAULT
buf += EMPH_OFF
buf += FEED + b'\x05' # Feed 5 lines before cutting
buf += CUT # Full cut
buf += PARTIAL_CUT
return bytes(buf)
@@ -128,8 +119,6 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
chars = 32 if width == 58 else 48
buf = bytearray()
buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header
buf += ALIGN_CENTER
@@ -198,10 +187,7 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n'
buf += LINE_SPACING_DEFAULT
buf += EMPH_OFF
buf += FEED + b'\x05'
buf += CUT
buf += PARTIAL_CUT
return bytes(buf)
@@ -238,8 +224,6 @@ def generate_service_order_ticket(so_data, business_info, width=80):
chars = 32 if width == 58 else 48
buf = bytearray()
buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header
buf += ALIGN_CENTER
@@ -354,9 +338,6 @@ def generate_service_order_ticket(so_data, business_info, width=80):
buf += "No es comprobante fiscal\n".encode("cp437", errors="replace")
buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace")
buf += b"\n\n\n"
buf += LINE_SPACING_DEFAULT
buf += EMPH_OFF
buf += FEED + b'\x05'
buf += CUT
buf += PARTIAL_CUT
return bytes(buf)

View File

@@ -1243,45 +1243,3 @@
.summary-strip { grid-template-columns: 1fr; }
.finance-grid { grid-template-columns: 1fr; }
}
/* =========================================================================
MODAL DETALLE CUENTA POR COBRAR
========================================================================= */
.modal-overlay {
position: fixed; inset: 0;
background: var(--overlay-backdrop);
display: flex; align-items: center; justify-content: center;
z-index: var(--z-modal);
opacity: 0; pointer-events: none;
transition: var(--transition-normal);
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal-pago {
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); box-shadow: var(--shadow-xl);
width: 560px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
transform: translateY(20px); transition: var(--transition-normal);
}
.modal-overlay.open .modal-pago { transform: translateY(0); }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
font-family: var(--font-heading); font-size: var(--text-h4);
font-weight: var(--heading-weight-primary); color: var(--color-text-primary);
}
.modal-close {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid var(--color-border);
border-radius: var(--radius-md); cursor: pointer;
color: var(--color-text-muted); font-size: 18px; transition: var(--transition-fast);
}
.modal-close:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
.modal-footer {
display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3);
padding: var(--space-4) var(--space-6); border-top: 1px solid var(--color-border);
}

View File

@@ -1186,8 +1186,6 @@
.badge--green { background: rgba(34,197,94,0.15); color: var(--color-success); }
.badge--yellow { background: rgba(234,179,8,0.15); color: #eab308; }
.badge--purple { background: rgba(168,85,247,0.15); color: #a855f7; }
.badge--orange { background: rgba(249,115,22,0.15); color: #f97316; }
.badge--teal { background: rgba(20,184,166,0.15); color: #14b8a6; }
/* Toast notification */
.cfg-toast {

View File

@@ -1209,16 +1209,6 @@
color: #000;
}
.action-btn--danger {
color: var(--color-error);
border-color: var(--color-error);
}
.action-btn--danger:hover {
background-color: var(--color-error);
color: #fff;
}
.action-btn__icon {
width: 20px;
height: 20px;

View File

@@ -117,16 +117,6 @@
.empty-state__action {
margin-top: var(--space-2, 0.5rem);
}
.empty-state__icon svg {
display: block;
}
.empty-state__icon[aria-hidden="true"],
.empty-state__icon .empty-state__spinner {
animation: empty-spin 1s linear infinite;
}
@keyframes empty-spin {
to { transform: rotate(360deg); }
}
/* ═══════════════════════════════════════════════════════════════
4. UNIFIED INPUT FOCUS RING

View File

@@ -713,23 +713,18 @@
}
.pago-tabs {
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;
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
}
.pago-tab {
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;
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);
}
.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; }
.pago-tab:hover { color: var(--color-text-primary); }
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
.tab-content { padding: var(--space-6); display: none; }
.tab-content.active { display: block; }
@@ -906,10 +901,8 @@
font-family: 'Courier New', 'Consolas', monospace;
font-size: 11px; line-height: 1.4; padding: 12px; text-align: left;
border: 1px dashed #ccc;
box-sizing: border-box;
}
.ticket-80 { width: 80mm; max-width: 80mm; }
.ticket-58 { width: 48mm; max-width: 48mm; }
.ticket-80 { width: 302px; }
.ticket .store-name { font-size: 14px; font-weight: bold; text-align: center; margin-bottom: 2px; }
.ticket .store-tagline { font-size: 9px; text-align: center; color: #555; margin-bottom: 4px; }
.ticket .store-info { font-size: 9px; text-align: center; color: #333; margin-bottom: 6px; line-height: 1.3; }
@@ -928,26 +921,17 @@
gap: 8px; align-items: baseline; font-size: 10px; margin-bottom: 3px;
}
.ticket-80 .item-line-wide .qty { font-weight: bold; min-width: 24px; text-align: right; }
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: break-word; }
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ticket-80 .item-line-wide .price { text-align: right; min-width: 55px; }
.ticket-80 .item-line-wide .subtotal { text-align: right; font-weight: bold; min-width: 60px; }
.ticket-line { page-break-inside: avoid; }
@media print {
@page { margin: 0; size: 72mm auto; }
html, body { height: auto !important; overflow: visible !important; }
body > * { display: none !important; }
.ticket-print-area,
.ticket-print-area * { display: block !important; }
.ticket-print-area {
position: static !important; width: 72mm !important; margin: 0 !important; padding: 0 !important;
}
.ticket, .ticket-80 { width: 72mm !important; max-width: 72mm !important; border: none !important; box-shadow: none !important; padding: 3mm !important; font-size: 9pt !important; }
.ticket-58 { width: 48mm !important; max-width: 48mm !important; }
.ticket * { word-break: break-word !important; }
.ticket .item-line-wide { display: grid !important; grid-template-columns: auto 1fr auto auto !important; gap: 2mm !important; font-size: 8pt !important; }
body * { display: none !important; }
.ticket-print-area, .ticket-print-area * { display: block !important; }
.ticket-print-area { position: fixed; top: 0; left: 0; }
.ticket { border: none; box-shadow: none; padding: 4px; }
.ticket .item-line-wide { display: grid !important; }
.ticket .ticket-row, .ticket .folio-line, .ticket .total-line { display: flex !important; }
.ticket .name { white-space: normal !important; }
}
/* =====================================================================

View File

@@ -1,195 +0,0 @@
/* 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

@@ -767,257 +767,3 @@ body {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════════
LIST VIEW, FILTERS & TABS
═══════════════════════════════════════════════════════════════ */
.workshop-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4) var(--space-6);
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
[data-theme="industrial"] .workshop-toolbar {
background: var(--color-surface-1);
}
.workshop-filters {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.workshop-filters .form-input {
min-width: 160px;
width: auto;
}
.toolbar-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-primary);
font-size: var(--text-body-sm);
cursor: pointer;
user-select: none;
}
.toolbar-toggle input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.view-switch {
display: inline-flex;
background: var(--color-bg-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.view-switch__btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: transparent;
border: none;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
cursor: pointer;
transition: var(--transition-fast);
}
.view-switch__btn svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2;
}
.view-switch__btn.is-active {
background: var(--color-primary);
color: #fff;
}
.workshop-list {
flex: 1;
overflow: auto;
padding: var(--space-4) var(--space-6);
}
.workshop-table {
width: 100%;
}
.workshop-table th,
.workshop-table td {
white-space: nowrap;
}
.workshop-table td:nth-child(2) {
color: var(--color-primary);
}
.list-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
margin-top: var(--space-4);
}
.pagination-info {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
/* ── Detail tabs ── */
.so-detail-header {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-4);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.so-detail-header__row {
font-size: var(--text-body-sm);
}
.so-detail-info {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.so-tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.so-tabs__btn {
padding: var(--space-2) var(--space-4);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
font-size: var(--text-body);
cursor: pointer;
transition: var(--transition-fast);
}
.so-tabs__btn.is-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.so-tab-panel {
animation: fadeIn 0.2s ease;
}
.bitacora-table {
width: 100%;
}
/* ═══════════════════════════════════════════════════════════════
BADGE STATUS COLORS
═══════════════════════════════════════════════════════════════ */
.badge--received { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.badge--diagnosis { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--waiting_parts { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--repair { background: rgba(245, 166, 35, 0.18); color: #d97706; }
.badge--quality_check { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--ready { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.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; }
.badge--urgent { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
/* ═══════════════════════════════════════════════════════════════
MODAL FOOTER
═══════════════════════════════════════════════════════════════ */
.modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.modal__footer .so-detail__actions {
margin-right: auto;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 1024px) {
.so-detail-info {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.workshop-toolbar {
flex-direction: column;
align-items: stretch;
}
.workshop-filters {
flex-direction: column;
align-items: stretch;
}
.workshop-filters .form-input {
width: 100%;
}
.so-detail-info {
grid-template-columns: 1fr;
}
}

View File

@@ -1,9 +1,9 @@
// /home/Autopartes/pos/static/js/accounting.js
// Accounting module — wired to design-system HTML IDs
// Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre
const Accounting = (() => {
const API = '/pos/api/accounting';
let accountsList = [];
function token() {
return localStorage.getItem('pos_token') || '';
@@ -14,8 +14,7 @@ const Accounting = (() => {
}
async function api(path, opts = {}) {
const url = path.startsWith('/pos/api/') ? path : `${API}${path}`;
const res = await fetch(url, { headers: headers(), ...opts });
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Request failed');
@@ -26,12 +25,6 @@ const Accounting = (() => {
function fmt(n) {
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ---- Auth check ----
function checkAuth() {
@@ -42,30 +35,6 @@ const Accounting = (() => {
return true;
}
async function loadChartOfAccounts() {
if (accountsList.length) return;
try {
const res = await api('/accounts');
accountsList = (res.data || []).filter(a => a.is_active);
populateEntryAccountSelects();
} catch (e) {
console.warn('No se pudieron cargar cuentas contables:', e);
}
}
function accountSelectHtml(selectedId) {
if (!accountsList.length) {
return '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select>';
}
let html = '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Selecciona cuenta</option>';
accountsList.forEach(a => {
const selected = selectedId && String(selectedId) === String(a.id) ? ' selected' : '';
html += `<option value="${a.id}"${selected}>${esc(a.code)} - ${esc(a.name)}</option>`;
});
html += '</select>';
return html;
}
// ---- Tab switching (matches design system onclick="switchTab('xxx')") ----
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -121,36 +90,25 @@ const Accounting = (() => {
try {
const res = await api('/aging');
let rows = res.data || [];
const statusFilter = document.getElementById('cxc-status-filter');
const selected = statusFilter ? statusFilter.value : 'all';
if (selected !== 'all') {
rows = rows.filter(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
return status === selected;
});
}
const rows = res.data || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por cobrar.</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente');
const actionLabel = r.balance > 0 ? 'Cobrar' : 'Ver';
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
return `<tr>
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
<td class="td--primary">${r.customer_name || r.name || '-'}</td>
<td class="td--primary">${r.name || r.customer_name || '-'}</td>
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--amount">$${fmt(r.total)}</td>
<td class="td--amount">$${fmt(r.paid || 0)}</td>
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
<td>${statusBadge(status, label)}</td>
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.showReceivableDetail(${r.sale_id})">${esc(actionLabel)}</button></td>
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
</tr>`;
}).join('');
@@ -165,90 +123,6 @@ const Accounting = (() => {
}
}
// ---- Receivable detail / cancel ticket ----
async function showReceivableDetail(saleId) {
try {
const sale = await api('/pos/api/sales/' + saleId);
if (!sale || sale.error) {
alert('No se pudo cargar el detalle de la venta');
return;
}
const itemsHtml = (sale.items || []).map(function (item) {
return '<tr>' +
'<td>' + esc(item.part_number || '-') + '</td>' +
'<td>' + esc(item.name) + '</td>' +
'<td style="text-align:right">' + item.quantity + '</td>' +
'<td style="text-align:right">$' + fmt(item.unit_price) + '</td>' +
'<td style="text-align:right">$' + fmt(item.subtotal) + '</td>' +
'</tr>';
}).join('');
const paid = (sale.payments || []).reduce(function (sum, p) { return sum + (p.amount || 0); }, 0) + (sale.amount_paid || 0);
const balance = (sale.total || 0) - paid;
const canCancel = sale.status !== 'cancelled' && balance > 0;
const html = '<div class="modal-overlay open" id="receivableDetailOverlay" style="z-index:2000;">' +
'<div class="modal-pago" style="max-width:600px;width:90%;max-height:90vh;overflow:auto;">' +
'<div class="modal-header"><h3>Detalle de Venta a Crédito</h3>' +
'<button class="modal-close" onclick="Accounting.closeReceivableDetail()">&#x2715;</button></div>' +
'<div style="padding:var(--space-4);">' +
'<p><strong>Ticket:</strong> VTA-' + sale.id + '</p>' +
'<p><strong>Cliente:</strong> ' + esc(sale.customer_name || '-') + '</p>' +
'<p><strong>Fecha:</strong> ' + (sale.created_at ? new Date(sale.created_at).toLocaleString('es-MX') : '-') + '</p>' +
'<p><strong>Estado:</strong> ' + esc(sale.status) + '</p>' +
'<p><strong>Total:</strong> $' + fmt(sale.total) + '</p>' +
'<p><strong>Pagado:</strong> $' + fmt(paid) + '</p>' +
'<p><strong>Saldo:</strong> $' + fmt(balance) + '</p>' +
'<h4 style="margin-top:var(--space-4);margin-bottom:var(--space-2);">Artículos</h4>' +
'<table class="data-table"><thead><tr><th>Clave</th><th>Producto</th><th>Cant</th><th>P.Unit</th><th>Subtotal</th></tr></thead><tbody>' +
(itemsHtml || '<tr><td colspan="5" style="text-align:center;">Sin artículos</td></tr>') +
'</tbody></table>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-ghost" onclick="Accounting.closeReceivableDetail()">Cerrar</button>' +
(canCancel ? '<button class="btn btn-danger" onclick="Accounting.cancelReceivable(' + sale.id + ')">Cancelar Ticket</button>' : '') +
'</div>' +
'</div></div>';
const existing = document.getElementById('receivableDetailOverlay');
if (existing) existing.remove();
document.body.insertAdjacentHTML('beforeend', html);
} catch (e) {
alert('Error al cargar detalle: ' + e.message);
}
}
function closeReceivableDetail() {
const el = document.getElementById('receivableDetailOverlay');
if (el) el.remove();
}
async function cancelReceivable(saleId) {
const reason = prompt('Motivo de cancelación del ticket (mínimo 3 caracteres):');
if (!reason || reason.trim().length < 3) {
alert('Se requiere un motivo para cancelar.');
return;
}
if (!confirm('¿Estás seguro de cancelar el ticket VTA-' + saleId + '? Esta acción reversa el inventario y el crédito del cliente.')) {
return;
}
try {
const res = await api('/pos/api/sales/' + saleId + '/cancel', {
method: 'PUT',
body: JSON.stringify({ reason: reason.trim() })
});
if (res.error) {
alert('Error: ' + res.error);
return;
}
alert('Ticket cancelado correctamente.');
closeReceivableDetail();
loadAging();
} catch (e) {
alert('Error al cancelar: ' + e.message);
}
}
// ---- Tab 2: Cuentas por Pagar ----
async function loadAccountsPayable() {
const panel = document.getElementById('panel-cxp');
@@ -259,35 +133,25 @@ const Accounting = (() => {
try {
// Use accounts endpoint filtered for payables or a dedicated endpoint
const res = await api('/aging?type=payable');
let rows = res.data || [];
const statusFilter = document.getElementById('cxp-status-filter');
const selected = statusFilter ? statusFilter.value : 'all';
if (selected !== 'all') {
rows = rows.filter(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
return status === selected;
});
}
const rows = res.data || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por pagar.</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Pendiente');
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
return `<tr>
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
<td class="td--primary">${r.vendor_name || r.name || '-'}</td>
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--primary">${r.name || r.vendor_name || '-'}</td>
<td>${r.receipt_date ? new Date(r.receipt_date).toLocaleDateString('es-MX') : '-'}</td>
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--amount">$${fmt(r.total)}</td>
<td class="td--amount">$${fmt(r.paid || 0)}</td>
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
<td>${statusBadge(status, label)}</td>
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.registerPayablePayment(${r.id || 0})">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
</tr>`;
}).join('');
@@ -520,8 +384,6 @@ const Accounting = (() => {
loadSummaryCards();
// Load initial tab data (cxc is active by default)
loadAging();
// Preload chart of accounts for the manual entry modal
loadChartOfAccounts();
}
document.addEventListener('DOMContentLoaded', init);
@@ -561,27 +423,6 @@ const Accounting = (() => {
URL.revokeObjectURL(url);
}
async function exportarCuentasPorCobrar() {
try {
const res = await fetch(`${API}/aging/export?type=receivable&format=pdf`, { headers: headers() });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Error al exportar');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ventas_por_cobrar_' + new Date().toISOString().slice(0, 10) + '.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
alert('Error al exportar: ' + e.message);
}
}
// ---- Nueva Poliza modal ----
function showNewEntryModal() {
const overlay = document.getElementById('newEntryModalOverlay');
@@ -607,20 +448,13 @@ const Accounting = (() => {
line.className = 'entry-line';
line.style.cssText = 'display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;';
line.innerHTML =
accountSelectHtml() +
'<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<button class="btn btn--ghost btn--sm" onclick="this.closest(\'.entry-line\').remove()">&times;</button>';
container.appendChild(line);
}
function populateEntryAccountSelects() {
document.querySelectorAll('#entryLines .entry-account').forEach(sel => {
const selected = sel.value;
sel.outerHTML = accountSelectHtml(selected);
});
}
async function submitNewEntry() {
const date = document.getElementById('entryDate').value;
const type = document.getElementById('entryType').value;
@@ -634,16 +468,16 @@ const Accounting = (() => {
const lines = [];
document.querySelectorAll('#entryLines .entry-line').forEach(row => {
const accountId = row.querySelector('.entry-account').value;
const account = row.querySelector('.entry-account').value.trim();
const debit = parseFloat(row.querySelector('.entry-debit').value) || 0;
const credit = parseFloat(row.querySelector('.entry-credit').value) || 0;
if (accountId && (debit || credit)) {
lines.push({ account_id: parseInt(accountId, 10), debit, credit });
if (account && (debit || credit)) {
lines.push({ account, debit, credit });
}
});
if (lines.length < 2) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos dos partidas.</span>';
if (!lines.length) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos una partida.</span>';
return;
}
@@ -661,23 +495,17 @@ const Accounting = (() => {
// Expose switchTab globally for onclick handlers in HTML
window.switchTab = switchTab;
function registerPayablePayment(payableId) {
// Placeholder until supplier payments module is implemented
alert('Registrar pago a proveedor — próximamente' + (payableId ? ' (OC ' + payableId + ')' : ''));
}
function runPeriodClose() {
// Placeholder until fiscal period close module is implemented
alert('Ejecutar cierre de período — próximamente');
}
window.exportarContabilidad = exportarContabilidad;
window.exportarCuentasPorCobrar = exportarCuentasPorCobrar;
window.showNewEntryModal = showNewEntryModal;
window.closeNewEntryModal = closeNewEntryModal;
window.addEntryLine = addEntryLine;
window.submitNewEntry = submitNewEntry;
return {
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -686,13 +514,4 @@ const Accounting = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
window.Accounting = {
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
showReceivableDetail, closeReceivableDetail, cancelReceivable,
registerPayablePayment, runPeriodClose,
};
return window.Accounting;
})();

1
pos/static/js/accounting.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,27 +0,0 @@
// /home/Autopartes/pos/static/js/api.js
// Minimal shared API helper used by standalone pages (e.g. historical_sales).
async function api(path, options = {}) {
const token = localStorage.getItem('pos_token') || '';
const url = path.startsWith('http') ? path : path;
const res = await fetch(url, {
...options,
headers: {
'Authorization': token ? 'Bearer ' + token : '',
'Content-Type': 'application/json',
...(options.headers || {})
}
});
if (res.status === 401) {
window.location.href = '/pos/login';
throw new Error('Sesión expirada');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.message || 'Error ' + res.status);
}
return data;
}

View File

@@ -43,8 +43,7 @@
var _t = typeof window.t === 'function' ? window.t : function(k) { return k; };
var roleLabels = {
'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'),
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant'),
'workshop': 'Taller', 'mechanic': 'Mecanico'
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant')
};
var roleLabel = roleLabels[role] || role;
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
@@ -118,7 +117,6 @@
localStorage.removeItem('pos_employee');
localStorage.removeItem('pos_tenant_id');
localStorage.removeItem('pos_cart');
document.cookie = 'pos_role=; path=/pos; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.href = '/pos/login';
};
@@ -182,147 +180,6 @@
permissions: payload.permissions || []
};
// ─── Page guard based on role + permissions ───
function moduleEnabled(key) {
try {
var modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
return modules[key] !== false;
} catch(e) { return true; }
}
function updateHomeLinks(userRole, userPerms) {
// Point the dashboard/home icon to the role-specific default page.
var homeHref = '/pos/dashboard';
if (userRole === 'counter' && userPerms.indexOf('inventory.view') !== -1) {
homeHref = '/pos/inventory';
} else if (userRole === 'cashier' && userPerms.indexOf('pos.sell') !== -1) {
homeHref = '/pos/sale';
} else if (userRole === 'workshop' && userPerms.indexOf('workshop.view') !== -1 && moduleEnabled('workshop')) {
homeHref = '/pos/workshop';
}
document.querySelectorAll('a[href="/pos/dashboard"]').forEach(function(a) {
a.setAttribute('href', homeHref);
});
}
function isPageAllowed(pagePath, userRole, userPerms) {
if (userRole === 'owner' || userRole === 'admin') return true;
// Restricted roles (workshop/mechanic/counter/cashier) see modules based on permissions.
if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
var allowed = [];
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
allowed = ['/pos/workshop'];
}
var permMap = {
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'catalog.view': '/pos/catalog',
'inventory.view': '/pos/inventory',
'customers.view': '/pos/customers',
'workshop.view': '/pos/workshop',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'dashboard.view': '/pos/dashboard'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
// Hide workshop if its module is disabled
if (permMap[p] === '/pos/workshop' && !moduleEnabled('workshop')) continue;
allowed.push(permMap[p]);
}
}
return allowed.indexOf(pagePath) !== -1;
}
// Always allow login/logout pages so users can sign out without hitting the guard.
if (pagePath === '/pos/login' || pagePath === '/pos/logout') return true;
// 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;
// Build the actual list of allowed pages so we can pick a safe fallback.
var fallback = null;
if (userRole === 'owner' || userRole === 'admin') {
fallback = '/pos/dashboard';
} else if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
var allowed = [];
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
allowed = ['/pos/workshop'];
}
var permMap = {
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'catalog.view': '/pos/catalog',
'inventory.view': '/pos/inventory',
'customers.view': '/pos/customers',
'workshop.view': '/pos/workshop',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'dashboard.view': '/pos/dashboard'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
if (permMap[p] === '/pos/workshop' && !moduleEnabled('workshop')) continue;
allowed.push(permMap[p]);
}
}
// Role-specific default landing pages.
if (userRole === 'counter' && allowed.indexOf('/pos/inventory') !== -1) {
fallback = '/pos/inventory';
} else if (userRole === 'cashier' && allowed.indexOf('/pos/sale') !== -1) {
fallback = '/pos/sale';
} else {
fallback = allowed.length ? allowed[0] : '/pos/login';
}
} else {
fallback = '/pos/dashboard';
}
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') || '{}'));
}
updateHomeLinks(window.POS_USER.role, window.POS_USER.permissions);
}).catch(function() {
enforcePageGuard(role, payload.permissions || []);
updateHomeLinks(role, payload.permissions || []);
});
} catch(e) {
enforcePageGuard(role, payload.permissions || []);
updateHomeLinks(role, payload.permissions || []);
}
// ─── Preload enabled modules for sidebar filtering ───
try {
fetch('/pos/api/config/modules', {
@@ -340,16 +197,6 @@
}).catch(function() {});
} catch(e) {}
// ─── Hide POS "Sistema" button for roles that cannot access the dashboard ───
(function hideBackToSystemForRestrictedRoles() {
var backBtn = document.getElementById('backToSystemBtn');
if (!backBtn) return;
// owner/admin always see it; others only if they have dashboard.view.
if (role === 'owner' || role === 'admin') return;
if ((window.POS_USER.permissions || []).indexOf('dashboard.view') !== -1) return;
backBtn.style.display = 'none';
})();
// ─── Service Worker update handler ───
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function (event) {
@@ -360,21 +207,4 @@
});
}
// ─── Global toast utility ───
window.toast = function(msg, type) {
type = type || 'success';
var bg = type === 'error' ? '#d32f2f' : '#388e3c';
var el = document.createElement('div');
el.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:99999;padding:12px 20px;border-radius:8px;background:' + bg + ';color:#fff;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.2);opacity:0;transition:opacity .3s;';
el.textContent = msg;
document.body.appendChild(el);
// trigger reflow
el.offsetHeight;
el.style.opacity = '1';
setTimeout(function() {
el.style.opacity = '0';
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
}, 3000);
};
})();

View File

@@ -4,9 +4,6 @@
const Config = (() => {
const API = '/pos/api/config';
const user = window.POS_USER || {};
const canDeleteBranch = (user.role === 'owner' || user.role === 'admin');
// Cache for branches (used by employee modal selector)
let _branches = [];
@@ -58,7 +55,7 @@ const Config = (() => {
// -------------------------------------------------------------------------
// Theme switcher
// -------------------------------------------------------------------------
function setTheme(theme) {
/*function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
@@ -73,7 +70,22 @@ const Config = (() => {
var opts = document.querySelectorAll('.theme-option');
if (opts[idx]) opts[idx].classList.add('is-selected');
}
window.setTheme = setTheme;
window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
document.querySelectorAll('.theme-btn').forEach(function(btn) {
btn.classList.toggle('is-active', btn.dataset.themeTarget === theme);
});
document.querySelectorAll('.theme-option').forEach(function(opt) {
opt.classList.remove('is-selected');
});
var idx = theme === 'industrial' ? 0 : 1;
var opts = document.querySelectorAll('.theme-option');
if (opts[idx]) opts[idx].classList.add('is-selected');
}
function selectThemeOption(theme) {
setTheme(theme);
@@ -108,22 +120,16 @@ const Config = (() => {
owner: 'Dueno',
admin: 'Admin',
cashier: 'Cajero',
counter: 'Mostrador',
warehouse: 'Almacenista',
accountant: 'Contador',
workshop: 'Taller',
mechanic: 'Mecanico'
accountant: 'Contador'
};
var ROLE_BADGE = {
owner: 'badge--owner',
admin: 'badge--blue',
cashier: 'badge--green',
counter: 'badge--gray',
warehouse: 'badge--yellow',
accountant: 'badge--purple',
workshop: 'badge--orange',
mechanic: 'badge--teal'
accountant: 'badge--purple'
};
function roleBadge(role) {
@@ -188,7 +194,6 @@ const Config = (() => {
+ '</div>'
+ '<div class="device-card__actions">'
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editBranch(' + b.id + ')">Editar</button>'
+ (canDeleteBranch && b.is_active && !b.is_main ? '<button class="btn btn--danger btn--sm" style="margin-left:4px;" onclick="Config.deleteBranch(' + b.id + ')">Eliminar</button>' : '')
+ '</div></div>';
});
@@ -244,22 +249,6 @@ const Config = (() => {
openBranchModal(b);
}
async function deleteBranch(branchId) {
var b = _branches.find(function(x) { return x.id === branchId; });
if (!b) { toast('Sucursal no encontrada', 'error'); return; }
if (b.is_main) { toast('No se puede eliminar la sucursal principal', 'error'); return; }
if (!confirm('¿Eliminar la sucursal "' + b.name + '"? Se marcará como inactiva.')) return;
try {
var res = await fetch(API + '/branches/' + branchId, { method: 'DELETE', headers: headers() });
var json = await res.json().catch(function() { return {}; });
if (!res.ok) throw new Error(json.error || res.statusText);
toast('Sucursal eliminada');
loadBranches();
} catch (e) {
toast(e.message || 'Error al eliminar sucursal', 'error');
}
}
async function saveBranch(data) {
var branchId = document.getElementById('branch-id').value;
var url = API + '/branches' + (branchId ? '/' + branchId : '');
@@ -320,10 +309,7 @@ const Config = (() => {
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
+ '<td>' + statusBadge + '</td>'
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
+ '<td>'
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button>'
+ (emp.role !== 'owner' ? ' <button class="btn btn--danger btn--sm" onclick="Config.deleteEmployee(' + emp.id + ', \'' + escHtml(emp.name).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')">Eliminar</button>' : '')
+ '</td>'
+ '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></td>'
+ '</tr>';
});
@@ -333,7 +319,7 @@ const Config = (() => {
async function saveEmployee(data) {
// Check if we're editing (modal has editId) or creating
var modal = document.getElementById('modal-employee');
var modal = document.getElementById('employee-modal');
var editId = modal ? modal.dataset.editId : null;
var url = API + '/employees';
var method = 'POST';
@@ -387,21 +373,6 @@ const Config = (() => {
return el ? el.value.trim() : '';
}
async function deleteEmployee(empId, name) {
if (!confirm('¿Eliminar al empleado "' + name + '"? Esta accion lo desactiva.')) return;
try {
var res = await fetch(API + '/employees/' + empId, { method: 'DELETE', headers: headers() });
if (!res.ok) {
var err = await res.json().catch(function() { return { error: 'Error ' + res.status }; });
throw new Error(err.error || 'Error al eliminar');
}
toast('Empleado eliminado', 'ok');
loadEmployees();
} catch (e) {
toast(e.message, 'error');
}
}
async function editEmployee(empId) {
if (!checkAuth()) return;
// Find the employee in the loaded data by re-fetching
@@ -412,25 +383,24 @@ const Config = (() => {
var emp = (json.data || []).find(function(e) { return e.id === empId; });
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
// Pre-fill the employee modal with existing data for editing
setVal('emp-name', emp.name);
setVal('emp-email', emp.email || '');
setVal('emp-phone', emp.phone || '');
var roleSelect = document.getElementById('emp-role');
// Pre-fill the "new employee" modal with existing data for editing
setVal('new-emp-name', emp.name);
setVal('new-emp-email', emp.email || '');
var roleSelect = document.getElementById('new-emp-role');
if (roleSelect) roleSelect.value = emp.role || 'cashier';
var branchSelect = document.getElementById('emp-branch');
var branchSelect = document.getElementById('new-emp-branch');
if (branchSelect) branchSelect.value = emp.branch_id || '';
setVal('emp-discount', emp.max_discount_pct || '');
setVal('emp-pin', ''); // Don't pre-fill PIN for security
setVal('new-emp-discount', emp.max_discount_pct || '');
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
// Store the ID so saveEmployee knows it's an update
var modal = document.getElementById('modal-employee');
var modal = document.getElementById('employee-modal');
if (modal) {
modal.dataset.editId = empId;
var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Editar Empleado';
}
openModal('modal-employee');
openModal('employee-modal');
} catch (e) {
toast('Error: ' + e.message, 'error');
}
@@ -487,156 +457,6 @@ const Config = (() => {
}
}
// -------------------------------------------------------------------------
// Receipt / ticket customization
// -------------------------------------------------------------------------
let _receiptLogo = '';
async function loadReceiptConfig() {
try {
var res = await fetch(API + '/receipt', { headers: headers() });
if (!res.ok) return;
var d = await res.json();
_receiptLogo = d.logo || '';
setVal('receipt-store-name', d.store_name);
setVal('receipt-tagline', d.tagline);
setVal('receipt-rfc', d.rfc);
setVal('receipt-address', d.address);
setVal('receipt-phone', d.phone);
setVal('receipt-paper-width', d.paper_width || '80');
setVal('receipt-thanks', d.thanks_message);
setVal('receipt-footer', d.footer);
setChecked('receipt-show-logo', d.show_logo);
setChecked('receipt-show-rfc', d.show_rfc);
setChecked('receipt-show-address', d.show_address);
setChecked('receipt-show-phone', d.show_phone);
setChecked('receipt-show-iva', d.show_iva_breakdown);
setChecked('receipt-show-payment', d.show_payment_details);
setChecked('receipt-show-employee', d.show_employee);
renderReceiptLogoThumb();
} catch (e) {
console.error('Config.loadReceiptConfig:', e);
}
}
function setChecked(id, v) {
var el = document.getElementById(id);
if (el) el.checked = !!v;
}
function getChecked(id) {
var el = document.getElementById(id);
return el ? el.checked : false;
}
function renderReceiptLogoThumb() {
var thumb = document.getElementById('receipt-logo-thumb');
var removeBtn = document.getElementById('receipt-logo-remove');
if (!thumb) return;
if (_receiptLogo) {
thumb.innerHTML = '<img src="' + escapeHtml(_receiptLogo) + '" style="max-width:100%;max-height:100%;object-fit:contain;" alt="Logo ticket">';
if (removeBtn) removeBtn.style.display = '';
} else {
thumb.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>';
if (removeBtn) removeBtn.style.display = 'none';
}
}
function handleReceiptLogo(input) {
var file = input && input.files ? input.files[0] : null;
if (!file) return;
if (!file.type.match(/^image\/(png|jpeg|jpg|webp)$/)) {
toast('Solo se permiten imágenes PNG, JPG o WebP', 'error');
input.value = '';
return;
}
var reader = new FileReader();
reader.onload = function(e) {
var img = new Image();
img.onload = function() {
var maxWidth = 300;
var scale = Math.min(1, maxWidth / img.width);
var w = Math.round(img.width * scale);
var h = Math.round(img.height * scale);
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
_receiptLogo = canvas.toDataURL('image/jpeg', 0.85);
renderReceiptLogoThumb();
toast('Logo cargado. Guarda los cambios para aplicarlo.', 'ok');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
input.value = '';
}
function removeReceiptLogo() {
_receiptLogo = '';
renderReceiptLogoThumb();
}
async function saveReceiptConfig() {
if (!checkAuth()) return;
var data = {
logo: _receiptLogo,
store_name: getVal('receipt-store-name'),
tagline: getVal('receipt-tagline'),
rfc: getVal('receipt-rfc'),
address: getVal('receipt-address'),
phone: getVal('receipt-phone'),
paper_width: getVal('receipt-paper-width') || '80',
thanks_message: getVal('receipt-thanks'),
footer: getVal('receipt-footer'),
show_logo: getChecked('receipt-show-logo'),
show_rfc: getChecked('receipt-show-rfc'),
show_address: getChecked('receipt-show-address'),
show_phone: getChecked('receipt-show-phone'),
show_iva_breakdown: getChecked('receipt-show-iva'),
show_payment_details: getChecked('receipt-show-payment'),
show_employee: getChecked('receipt-show-employee'),
};
try {
var res = await fetch(API + '/receipt', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Error al guardar');
}
toast('Configuración de ticket guardada', 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
async function saveAll() {
if (!checkAuth()) return;
var btn = document.getElementById('btn-save-all');
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
await saveBusiness();
await saveTaxParams();
await saveCurrency();
await saveVehicleCompatSource();
await saveAllowedBrands();
await saveModules();
await saveSalesSettings();
await saveReceiptConfig();
toast('Configuración guardada', 'ok');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Cambios'; }
}
}
// -------------------------------------------------------------------------
// Event bindings
// -------------------------------------------------------------------------
@@ -701,12 +521,6 @@ const Config = (() => {
var btnNewEmp = document.getElementById('btn-new-employee');
if (btnNewEmp) {
btnNewEmp.addEventListener('click', function() {
var modal = document.getElementById('modal-employee');
if (modal) {
delete modal.dataset.editId;
var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Nuevo Empleado';
}
openModal('modal-employee');
});
}
@@ -730,7 +544,6 @@ const Config = (() => {
btnSaveEmp.disabled = true;
btnSaveEmp.textContent = 'Guardando...';
var isEdit = !!document.getElementById('modal-employee').dataset.editId;
try {
await saveEmployee({
name: name,
@@ -741,7 +554,7 @@ const Config = (() => {
branch_id: branchId ? parseInt(branchId, 10) : null,
max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0
});
toast(isEdit ? 'Empleado actualizado' : 'Empleado creado');
toast('Empleado creado');
closeModal('modal-employee');
// Reset form
document.getElementById('emp-name').value = '';
@@ -958,42 +771,25 @@ const Config = (() => {
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
var cbWork = document.getElementById('cfg-module-workshop');
if (cbWa) cbWa.checked = data.whatsapp !== false;
if (cbMp) cbMp.checked = data.marketplace !== false;
if (cbMeli) cbMeli.checked = data.meli !== false;
if (cbCat) cbCat.checked = data.catalog !== false;
if (cbWork) cbWork.checked = data.workshop !== false;
localStorage.setItem('pos_modules', JSON.stringify(data));
} 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() {
var cbWa = document.getElementById('cfg-module-whatsapp');
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
var cbWork = document.getElementById('cfg-module-workshop');
var cbCr = document.getElementById('cfg-module-counter-remission');
if (!cbWa && !cbMp && !cbMeli && !cbCat && !cbWork && !cbCr) return;
var btn = event.target;
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
var data = {
whatsapp: cbWa ? cbWa.checked : true,
marketplace: cbMp ? cbMp.checked : true,
meli: cbMeli ? cbMeli.checked : true,
catalog: cbCat ? cbCat.checked : true,
workshop: cbWork ? cbWork.checked : true,
whatsapp: document.getElementById('cfg-module-whatsapp').checked,
marketplace: document.getElementById('cfg-module-marketplace').checked,
meli: document.getElementById('cfg-module-meli').checked,
catalog: document.getElementById('cfg-module-catalog').checked,
};
var res = await fetch(API + '/modules', {
method: 'PUT',
@@ -1005,257 +801,14 @@ 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');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar módulos'; }
}
}
async function loadSalesSettings() {
try {
var res = await fetch(API + '/sales-settings', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
var cbZero = document.getElementById('cfg-allow-zero-price');
if (cbZero) cbZero.checked = data.allow_zero_price_sales !== false;
var cbNeg = document.getElementById('cfg-allow-negative-stock');
if (cbNeg) cbNeg.checked = data.allow_negative_stock === true;
} catch (e) {
console.error('Config.loadSalesSettings:', e);
}
}
async function saveSalesSettings() {
var cbZero = document.getElementById('cfg-allow-zero-price');
var cbNeg = document.getElementById('cfg-allow-negative-stock');
if (!cbZero && !cbNeg) return;
try {
var body = {};
if (cbZero) body.allow_zero_price_sales = cbZero.checked;
if (cbNeg) body.allow_negative_stock = cbNeg.checked;
var res = await fetch(API + '/sales-settings', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(body)
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Save failed');
}
} catch (e) {
toast(e.message, 'error');
throw e;
}
}
// -------------------------------------------------------------------------
// 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
// -------------------------------------------------------------------------
var _rolePermissions = {};
var _availablePermissions = [];
var _workshopPermissions = {};
var _workshopSchema = { statuses: [], actions: [] };
var _currentPermTab = 'modules';
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');
}
}
// Workshop-specific permissions editor
// -------------------------------------------------------------------------
async function loadWorkshopPermissions() {
try {
var res = await fetch(API + '/role-permissions/workshop', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_workshopPermissions = data.roles || {};
_workshopSchema = { statuses: data.statuses || [], actions: data.actions || [] };
renderWorkshopPermissions();
} catch (e) {
console.error('Config.loadWorkshopPermissions:', e);
}
}
function renderWorkshopPermissions() {
var container = document.getElementById('workshop-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 de Taller.</p>';
return;
}
var cfg = _workshopPermissions[role] || { statuses: [], actions: [] };
var visibleStatuses = cfg.statuses || [];
var visibleActions = cfg.actions || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--space-4);">';
// Statuses
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);">Estatus visibles en Taller</h4>';
(_workshopSchema.statuses || []).forEach(function(s) {
var checked = visibleStatuses.indexOf(s.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-ws-status="' + escapeHtml(s.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(s.label) + '</span>';
html += '</label>';
});
html += '</div>';
// Actions
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);">Acciones permitidas en Taller</h4>';
(_workshopSchema.actions || []).forEach(function(a) {
var checked = visibleActions.indexOf(a.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-ws-action="' + escapeHtml(a.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(a.label) + '</span>';
html += '</label>';
});
html += '</div>';
html += '</div>';
container.innerHTML = html;
}
async function saveWorkshopPermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('workshop-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var statuses = [];
var actions = [];
document.querySelectorAll('#workshop-permissions-container input[data-ws-status]').forEach(function(cb) {
if (cb.checked) statuses.push(cb.dataset.wsStatus);
});
document.querySelectorAll('#workshop-permissions-container input[data-ws-action]').forEach(function(cb) {
if (cb.checked) actions.push(cb.dataset.wsAction);
});
var payload = { roles: {} };
payload.roles[role] = { statuses: statuses, actions: actions };
try {
var res = await fetch(API + '/role-permissions/workshop', {
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');
_workshopPermissions[role] = { statuses: statuses, actions: actions };
if (status) status.textContent = 'Permisos de Taller guardados';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
function switchPermTab(tab) {
_currentPermTab = tab;
document.querySelectorAll('#perm-panel-modules, #perm-panel-workshop').forEach(function(el) { el.style.display = 'none'; el.classList.remove('active'); });
document.querySelectorAll('#tab-perm-modules, #tab-perm-workshop').forEach(function(el) { el.classList.remove('active'); });
document.getElementById('perm-panel-' + tab).style.display = 'block';
document.getElementById('perm-panel-' + tab).classList.add('active');
document.getElementById('tab-perm-' + tab).classList.add('active');
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -1277,10 +830,6 @@ const Config = (() => {
// Bind UI events
bindEvents();
// Global save button
var btnSaveAll = document.getElementById('btn-save-all');
if (btnSaveAll) btnSaveAll.addEventListener('click', saveAll);
// Vehicle compat source save button
var btnCompat = document.getElementById('btn-save-compat-source');
if (btnCompat) {
@@ -1308,11 +857,6 @@ 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();
@@ -1321,24 +865,18 @@ const Config = (() => {
loadVehicleCompatSource();
loadAllowedBrands();
loadModules();
loadSalesSettings();
loadReceiptConfig();
if (isAdmin) {
loadRolePermissions();
loadWorkshopPermissions();
}
// 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);
return {
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
loadModules, saveModules,
openModal, closeModal, openBranchModal, editBranch
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -1347,18 +885,4 @@ const Config = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
return {
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee, deleteEmployee,
deleteBranch,
loadBusiness, saveBusiness, saveTaxParams, saveAll,
loadCurrency, saveCurrency,
loadVehicleCompatSource, saveVehicleCompatSource,
loadModules, saveModules,
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
openModal, closeModal, openBranchModal, editBranch,
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions,
loadWorkshopPermissions, renderWorkshopPermissions, saveWorkshopPermissions, switchPermTab
};
})();

View File

@@ -5,20 +5,12 @@
* Wired to the design-system HTML (customers.html).
*/
const Customers = (() => {
let token = localStorage.getItem('pos_token') || '';
let currentPage = 1;
let totalPages = 1;
let currentCustomer = null;
let searchTimeout = null;
const user = window.POS_USER || {};
const userRole = (user.role || '').toLowerCase();
const userPerms = user.permissions || [];
const canDeleteCustomer = userRole === 'owner' || userRole === 'admin' || userPerms.includes('customers.delete');
function getToken() {
return localStorage.getItem('pos_token') || '';
}
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
@@ -31,7 +23,7 @@ const Customers = (() => {
};
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
async function api(url, options = {}) {
@@ -51,9 +43,6 @@ const Customers = (() => {
const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' };
function statusBadge(c) {
if (c.is_active === false) {
return '<span class="badge badge--inactive"><span class="badge-dot"></span>Inactivo</span>';
}
// Derive status: if credit_balance > credit_limit => Mora, else Activo
if (c.credit_balance > 0 && c.credit_limit > 0 && c.credit_balance > c.credit_limit) {
return '<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>';
@@ -93,27 +82,14 @@ const Customers = (() => {
const searchEl = document.getElementById('searchInput');
q = q !== undefined ? q : (searchEl ? searchEl.value || '' : '');
const tipoEl = document.getElementById('tipoFilter');
const estadoEl = document.getElementById('estadoFilter');
const tipo = tipoEl ? tipoEl.value : '';
// Map UI status labels to backend values
const estadoMap = { 'Activo': 'active', 'Inactivo': 'inactive', 'Mora': 'overdue' };
const estado = estadoEl ? (estadoMap[estadoEl.value] || 'all') : 'active';
const tbody = document.getElementById('customersBody');
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderLoadingState({ message: 'Cargando clientes...' }) + '</td></tr>';
try {
const params = new URLSearchParams({ page, per_page: 50 });
if (q) params.append('q', q);
if (tipo) params.append('price_tier', tipo);
if (estado) params.append('status', estado);
const data = await api(`/pos/api/customers?${params}`);
renderTable(data.data || []);
renderPagination(data.pagination || {});
} catch (e) {
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los clientes.' }) + '</td></tr>';
console.error('Load customers failed:', e);
}
}
@@ -160,7 +136,7 @@ const Customers = (() => {
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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>',
title: 'Sin clientes',
subtitle: 'No se encontraron clientes registrados.',
action: '<button class="btn btn--primary btn--sm" onclick="Customers.showCreateModal()">Nuevo cliente</button>'
action: '<button class="btn btn--primary btn--sm" onclick="Customers.openCreateModal()">Nuevo cliente</button>'
}) + '</td></tr>';
return;
}
@@ -303,28 +279,6 @@ const Customers = (() => {
const discountEl = document.getElementById('detailMaxDiscount');
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
// Vehicles
const vehiclesEl = document.getElementById('detailVehicles');
if (vehiclesEl) {
const vehicles = c.fleet_vehicles || [];
if (vehicles.length === 0) {
vehiclesEl.innerHTML = '<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>';
} else {
vehiclesEl.innerHTML = vehicles.map(v => {
const title = [v.year, v.make, v.model].filter(Boolean).join(' ');
const subtitle = [v.plate, v.vin, v.color].filter(Boolean).join(' · ');
return `<div class="vehicle-row" style="padding:var(--space-2);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-weight:600;">${title || 'Veh&iacute;culo'}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${subtitle}</div>
</div>`;
}).join('');
}
}
// Delete button visibility
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.style.display = canDeleteCustomer ? 'inline-flex' : 'none';
// Re-wire action buttons after detail panel is visible
wireActionButtons();
@@ -426,106 +380,15 @@ const Customers = (() => {
// Wire action buttons in detail panel
function wireActionButtons() {
const btns = document.querySelectorAll('.quick-actions .action-btn');
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial, Eliminar
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial
if (btns.length >= 1) btns[0].onclick = () => {
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
};
if (btns.length >= 2) btns[1].onclick = () => editCurrent();
if (btns.length >= 3) btns[2].onclick = () => showStatement();
if (btns.length >= 4) btns[3].onclick = () => {
if (currentCustomer) showCustomerHistory(currentCustomer.id);
if (currentCustomer) selectCustomer(currentCustomer.id);
};
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.onclick = () => deleteCustomer();
}
async function deleteCustomer() {
if (!currentCustomer) return;
if (!canDeleteCustomer) {
alert('No tienes permiso para eliminar clientes');
return;
}
if (!confirm(`¿Eliminar al cliente "${currentCustomer.name}"? Se borrará completamente. Las ventas, órdenes y vehículos conservarán sus datos pero quedarán sin cliente asignado.`)) return;
try {
await api(`/pos/api/customers/${currentCustomer.id}`, { method: 'DELETE' });
alert('Cliente eliminado');
currentCustomer = null;
closeDetail();
loadCustomers(currentPage);
} catch (e) {
alert('Error: ' + e.message);
}
}
window.deleteCustomer = deleteCustomer;
async function showCustomerHistory(customerId) {
try {
const res = await api(`/pos/api/customers/${customerId}/purchases`);
const purchases = res.data || [];
let modal = document.getElementById('customerHistoryModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'customerHistoryModal';
modal.className = 'modal-overlay';
modal.style.display = 'none';
modal.innerHTML = `
<div class="modal-content" style="max-width:700px;">
<div class="modal-header">
<h3>Historial de Compras — <span id="customerHistoryName"></span></h3>
<button class="modal-close" onclick="Customers.closeCustomerHistoryModal()">&times;</button>
</div>
<div class="modal-body">
<table class="history-table" style="width:100%;">
<thead>
<tr>
<th>Fecha</th>
<th>Folio</th>
<th>Total</th>
<th>Pago</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="customerHistoryBody"></tbody>
</table>
</div>
</div>
`;
document.body.appendChild(modal);
}
const tbody = document.getElementById('customerHistoryBody');
const title = document.getElementById('customerHistoryName');
if (title && currentCustomer) title.textContent = currentCustomer.name;
if (purchases.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--color-text-muted);padding:var(--space-4);">Sin compras registradas</td></tr>';
} else {
tbody.innerHTML = purchases.map(p => {
const statusClass = p.status === 'paid' ? 'mbadge--paid' : p.status === 'cancelled' ? 'mbadge--error' : p.status === 'overdue' ? 'mbadge--overdue' : 'mbadge--pending';
const statusLabel = p.status === 'paid' ? 'Pagado' : p.status === 'cancelled' ? 'Cancelado' : p.status === 'overdue' ? 'Vencido' : 'Pendiente';
return `<tr>
<td class="date">${formatDate(p.created_at)}</td>
<td class="folio">NX-${String(p.id).padStart(5, '0')}</td>
<td class="total">${fmt(p.total)}</td>
<td>${p.payment_method || '-'}</td>
<td><span class="mbadge ${statusClass}">${statusLabel}</span></td>
</tr>`;
}).join('');
}
modal.style.display = 'flex';
modal.classList.add('active');
} catch (e) {
console.error('Error loading customer history:', e);
alert('Error al cargar historial: ' + e.message);
}
}
function closeCustomerHistoryModal() {
const modal = document.getElementById('customerHistoryModal');
if (modal) {
modal.style.display = 'none';
modal.classList.remove('active');
}
}
// ─── Create/Edit Modal ───────────────
@@ -717,7 +580,7 @@ const Customers = (() => {
try {
await api(`/pos/api/customers/${currentCustomer.id}/payment`, {
method: 'POST',
body: JSON.stringify({ amount, payment_method: method, reference }),
body: JSON.stringify({ amount, method, reference }),
});
closePayment();
selectCustomer(currentCustomer.id);
@@ -736,7 +599,7 @@ const Customers = (() => {
// ─── Init ────────────────────────────
function init() {
// Auth check
if (!getToken()) {
if (!token) {
window.location.href = '/pos/login';
return;
}
@@ -956,18 +819,8 @@ const Customers = (() => {
showCreateModal, editCurrent, editCustomer, closeModal, save,
showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment,
showCustomerHistory, closeCustomerHistoryModal,
deleteCustomer,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
// Bulk selection
publicApi.toggleCustomerSelection = function(id) {
if (selectedCustomers.has(id)) selectedCustomers.delete(id);
@@ -1003,15 +856,15 @@ const Customers = (() => {
updateBulkToolbar();
};
publicApi.featureProximamente = function(name) {
if (typeof window.featureProximamente === 'function') {
window.featureProximamente(name);
} else {
alert(name + ' — próximamente');
}
};
// Expose globally for inline HTML onclick handlers
window.Customers = publicApi;
return publicApi;
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

View File

@@ -46,7 +46,7 @@ const Dashboard = (() => {
// -------------------------------------------------------------------------
// Theme switcher
// -------------------------------------------------------------------------
function setTheme(theme) {
/*function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
const btnInd = document.getElementById('btn-industrial');
@@ -54,7 +54,15 @@ const Dashboard = (() => {
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
}
window.setTheme = setTheme;
window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
const btnInd = document.getElementById('btn-industrial');
const btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
}
// -------------------------------------------------------------------------
// Sidebar toggle (mobile)
@@ -367,51 +375,16 @@ const Dashboard = (() => {
}
// -------------------------------------------------------------------------
// 4. Credit alerts
// -------------------------------------------------------------------------
async function loadCreditAlerts() {
const data = await apiFetch('/pos/api/dashboard/credit-alerts');
const tbody = document.getElementById('credit-alerts-tbody');
const meta = document.getElementById('credit-alerts-meta');
if (!tbody) return;
if (!data || !data.data || data.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:var(--space-4);color:var(--color-text-muted);">No hay créditos por vencer.</td></tr>';
if (meta) meta.textContent = 'Vencidos: 0 / Por vencer: 0';
return;
}
if (meta) {
meta.innerHTML = `<span style="color:var(--color-error);font-weight:600;">Vencidos: ${data.overdue_count || 0}</span> &nbsp;|&nbsp; <span style="color:var(--color-warning);font-weight:600;">Por vencer: ${data.due_soon_count || 0}</span>`;
}
tbody.innerHTML = data.data.map(function(r) {
const dueDate = r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-';
const daysText = r.days_until_due < 0 ? `${Math.abs(r.days_until_due)} días vencido` : `${r.days_until_due} días restantes`;
const statusClass = r.status === 'overdue' ? 'error' : (r.status === 'due_soon' ? 'warning' : 'success');
return `<tr>
<td><span class="td-client">${escHtml(r.customer_name)}</span></td>
<td><span class="td-mono">${escHtml(r.folio)}</span></td>
<td>${dueDate}</td>
<td>${daysText}</td>
<td class="align-right"><span class="td-mono">${fmt(r.balance)}</span></td>
<td><span class="badge badge--${statusClass}">${r.status_label}</span></td>
</tr>`;
}).join('');
}
// -------------------------------------------------------------------------
// 5. Top Products (from today's sales detail)
// 4. Top Products (from today's sales detail)
// -------------------------------------------------------------------------
async function loadTopProducts() {
// Single optimized endpoint: returns today's top products already aggregated
const data = await apiFetch('/pos/api/dashboard/stats');
const today = todayStr();
// Fetch all today's sales with pagination
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&status=completed&per_page=200`);
const container = document.getElementById('top-products-list');
if (!container) return;
const top = data && data.top_products ? data.top_products : [];
if (!top.length) {
if (!data || !data.data || data.data.length === 0) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
@@ -421,7 +394,37 @@ const Dashboard = (() => {
return;
}
const sorted = top.slice(0, 5);
// Fetch detail for each sale to get items (up to 20 sales for performance)
const salesToFetch = data.data.slice(0, 20);
const details = await Promise.all(
salesToFetch.map(s => apiFetch(`/pos/api/sales/${s.id}`))
);
// Aggregate items
const productMap = {};
for (const sale of details) {
if (!sale || !sale.items) continue;
for (const item of sale.items) {
const key = item.part_number || item.name;
if (!productMap[key]) {
productMap[key] = { name: item.name, part_number: item.part_number || '', qty: 0, revenue: 0 };
}
productMap[key].qty += item.quantity || 0;
productMap[key].revenue += item.subtotal || 0;
}
}
const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
if (sorted.length === 0) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
title: 'Sin productos vendidos',
subtitle: 'No hay suficiente información para mostrar el ranking.'
});
return;
}
const maxRev = sorted[0].revenue || 1;
container.innerHTML = sorted.map((p, i) => {
const pct = Math.round((p.revenue / maxRev) * 100);
@@ -430,7 +433,7 @@ const Dashboard = (() => {
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
<div class="rank-item__info">
<div class="rank-item__name">${escHtml(p.name)}</div>
<div class="rank-item__sub">${p.quantity} pzas vendidas</div>
<div class="rank-item__sub">${escHtml(p.part_number)} &nbsp;&middot;&nbsp; ${p.qty} pzas vendidas</div>
<div class="rank-item__bar-bg">
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
</div>
@@ -624,12 +627,11 @@ const Dashboard = (() => {
// -------------------------------------------------------------------------
async function loadRecentSales() {
const today = todayStr();
const data = await apiFetch(`/pos/api/sales/recent?date_from=${today}&date_to=${today}&limit=10`);
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`);
const tbody = document.getElementById('recent-sales-tbody');
if (!tbody) return;
const sales = data && data.data ? data.data : [];
if (!sales.length) {
if (!data || !data.data || data.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
@@ -639,22 +641,26 @@ const Dashboard = (() => {
return;
}
const salesToShow = sales.slice(0, 5);
// Fetch items for first 5 sales
const salesToShow = data.data.slice(0, 5);
const details = await Promise.all(
salesToShow.map(s => apiFetch(`/pos/api/sales/${s.id}`))
);
tbody.innerHTML = salesToShow.map((sale) => {
tbody.innerHTML = salesToShow.map((sale, idx) => {
const detail = details[idx];
const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--';
const client = sale.customer_name || 'Publico General';
const total = sale.total || 0;
const method = sale.payment_method || 'efectivo';
// Build products summary from items already included in the response
// Build products summary from detail items
let productsSummary = '';
const items = sale.items || [];
if (items.length > 0) {
productsSummary = items.slice(0, 3).map(it =>
if (detail && detail.items && detail.items.length > 0) {
productsSummary = detail.items.slice(0, 3).map(it =>
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
).join(', ');
if (items.length > 3) productsSummary += '...';
if (detail.items.length > 3) productsSummary += '...';
}
const methodClass = getPaymentBadgeClass(method);
@@ -703,7 +709,6 @@ const Dashboard = (() => {
loadDailySummary();
loadHistoricalSummary();
loadAlerts();
loadCreditAlerts();
loadTopProducts();
loadChart('semana');
loadRecentSales();
@@ -712,7 +717,6 @@ const Dashboard = (() => {
setInterval(() => {
loadDailySummary();
loadRecentSales();
loadCreditAlerts();
}, 120000);
}

View File

@@ -12,16 +12,6 @@ var Fleet = (function() {
var currentPage = 1;
var searchTimeout = null;
var user = window.POS_USER || {};
var role = (user.role || '').toLowerCase();
var perms = user.permissions || [];
function hasPerm(p) {
return role === 'owner' || perms.indexOf(p) !== -1;
}
var canCreate = hasPerm('fleet.create');
var canEdit = hasPerm('fleet.edit');
var canDelete = hasPerm('fleet.delete');
// ─── Helpers ───
function headers() {
@@ -85,16 +75,9 @@ var Fleet = (function() {
}
// New vehicle button
var btnNewVehicle = document.getElementById('btnNewVehicle');
if (btnNewVehicle) {
if (!canCreate) {
btnNewVehicle.style.display = 'none';
} else {
btnNewVehicle.addEventListener('click', function() {
document.getElementById('btnNewVehicle').addEventListener('click', function() {
openVehicleModal();
});
}
}
// Load initial data
loadStats();
@@ -122,8 +105,6 @@ var Fleet = (function() {
var url = API + '/vehicles?page=' + currentPage + '&per_page=50';
if (q) url += '&q=' + encodeURIComponent(q);
document.getElementById('vehicleGrid').innerHTML = renderLoadingState({ message: 'Cargando vehiculos...' });
fetch(url, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
@@ -134,7 +115,7 @@ var Fleet = (function() {
})
.catch(function() {
document.getElementById('vehicleGrid').innerHTML =
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los vehiculos.' });
'<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
});
}
@@ -144,7 +125,7 @@ var Fleet = (function() {
grid.innerHTML = '<div class="empty-state">' +
'<div class="empty-state__icon">&#x1F69A;</div>' +
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
(canCreate ? '<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' : '') +
'<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
'</div>';
return;
}
@@ -154,7 +135,7 @@ var Fleet = (function() {
var label = (v.make || '') + ' ' + (v.model || '');
if (v.year) label += ' ' + v.year;
html += '<div class="vehicle-card" ' + (canEdit ? 'onclick="Fleet.viewVehicle(' + v.id + ')" style="cursor:pointer;"' : '') + '>' +
html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
'<div class="vehicle-card__header">' +
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
@@ -219,11 +200,9 @@ var Fleet = (function() {
function openVehicleModal(data) {
var modal = document.getElementById('vehicleModal');
var title = document.getElementById('vehicleModalTitle');
var saveBtn = document.getElementById('btnSaveVehicle');
if (data && data.id) {
title.textContent = 'Editar Vehiculo';
if (saveBtn) saveBtn.style.display = canEdit ? '' : 'none';
document.getElementById('vehEditId').value = data.id;
document.getElementById('vehPlate').value = data.plate || '';
document.getElementById('vehVin').value = data.vin || '';
@@ -237,7 +216,6 @@ var Fleet = (function() {
document.getElementById('vehNotes').value = data.notes || '';
} else {
title.textContent = 'Nuevo Vehiculo';
if (saveBtn) saveBtn.style.display = canCreate ? '' : 'none';
document.getElementById('vehEditId').value = '';
['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes']
.forEach(function(id) { document.getElementById(id).value = ''; });
@@ -254,8 +232,6 @@ var Fleet = (function() {
function saveVehicle() {
var editId = document.getElementById('vehEditId').value;
if (editId && !canEdit) { alert('No tienes permiso para editar vehiculos'); return; }
if (!editId && !canCreate) { alert('No tienes permiso para crear vehiculos'); return; }
var payload = {
plate: document.getElementById('vehPlate').value.trim(),
vin: document.getElementById('vehVin').value.trim(),
@@ -299,36 +275,26 @@ var Fleet = (function() {
// ─── Maintenance Tab ───
function loadMaintenance() {
// Single bulk endpoint replaces N+1 per-vehicle schedule requests
document.getElementById('maintBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando programas...' }) + '</td></tr>';
fetch(API + '/vehicles/schedules', {headers: headers()})
// Load all vehicles with their schedules
fetch(API + '/vehicles?per_page=200', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var schedules = d.data || [];
var results = schedules.map(function(s) {
return {
vehicle: s.vehicle || {},
schedules: [{
id: s.id,
maintenance_type: s.maintenance_type,
interval_km: s.interval_km,
interval_months: s.interval_months,
last_done_at: s.last_done_at,
last_done_km: s.last_done_km,
next_due_at: s.next_due_at,
next_due_km: s.next_due_km,
notes: s.notes
}]
};
var allVehicles = d.data || [];
var promises = allVehicles.map(function(v) {
return fetch(API + '/vehicles/' + v.id + '/schedules', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(s) {
return {vehicle: v, schedules: s.data || []};
});
});
return Promise.all(promises);
})
.then(function(results) {
renderMaintenance(results);
})
.catch(function(e) {
console.error('loadMaintenance error:', e);
.catch(function() {
document.getElementById('maintBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los programas.' }) + '</td></tr>';
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
});
}
@@ -360,7 +326,7 @@ var Fleet = (function() {
'<td>' + (next || '—') + '</td>' +
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
'<td>' + (canCreate ? '<button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button>' : '') + '</td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
'</tr>';
}
@@ -378,9 +344,7 @@ var Fleet = (function() {
if (!items.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
'No hay programas de mantenimiento.<br>' +
(canCreate ? '<button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button>' : '') +
'</td></tr>';
'No hay programas de mantenimiento.<br><button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button></td></tr>';
return;
}
@@ -399,25 +363,25 @@ var Fleet = (function() {
// ─── History Tab ───
function loadHistory() {
// Single bulk endpoint replaces N+1 per-vehicle detail requests
document.getElementById('historyBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando historial...' }) + '</td></tr>';
fetch(API + '/vehicles/history', {headers: headers()})
fetch(API + '/vehicles?per_page=200', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var logs = d.data || [];
var results = logs.map(function(l) {
l._plate = (l.vehicle && l.vehicle.plate) || 'S/P';
l._make = ((l.vehicle && l.vehicle.make) || '') + ' ' + ((l.vehicle && l.vehicle.model) || '');
return {vehicle: l.vehicle || {}, logs: [l]};
var allVehicles = d.data || [];
var promises = allVehicles.map(function(v) {
return fetch(API + '/vehicles/' + v.id, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(detail) {
return {vehicle: v, logs: detail.recent_logs || []};
});
});
return Promise.all(promises);
})
.then(function(results) {
renderHistory(results);
})
.catch(function(e) {
console.error('loadHistory error:', e);
.catch(function() {
document.getElementById('historyBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudo cargar el historial.' }) + '</td></tr>';
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
});
}
@@ -473,8 +437,6 @@ var Fleet = (function() {
// ─── Alerts Tab ───
function loadAlerts() {
document.getElementById('alertsList').innerHTML = renderLoadingState({ message: 'Cargando alertas...' });
fetch(API + '/alerts', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
@@ -482,7 +444,7 @@ var Fleet = (function() {
})
.catch(function() {
document.getElementById('alertsList').innerHTML =
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar las alertas.' });
'<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
});
}
@@ -513,7 +475,7 @@ var Fleet = (function() {
' &nbsp; ' + detail +
'</div>' +
'</div>' +
(canCreate ? '<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' : '') +
'<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
'</div>';
});
el.innerHTML = html;
@@ -542,7 +504,6 @@ var Fleet = (function() {
}
function saveSchedule() {
if (!canCreate) { alert('No tienes permiso para crear programas'); return; }
var vehicleId = document.getElementById('schedVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
@@ -613,7 +574,6 @@ var Fleet = (function() {
}
function saveLog() {
if (!canCreate) { alert('No tienes permiso para registrar mantenimiento'); return; }
var vehicleId = document.getElementById('logVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }

View File

@@ -10,7 +10,6 @@ var I18N = {
'catalog': 'Catalogo',
'inventory': 'Inventario',
'diagrams': 'Diagramas',
'remission_notes': 'Notas de Remisión',
'customers': 'Clientes',
'invoicing': 'Facturacion',
'accounting': 'Contabilidad',
@@ -167,7 +166,6 @@ var I18N = {
'catalog': 'Catalog',
'inventory': 'Inventory',
'diagrams': 'Diagrams',
'remission_notes': 'Remission Notes',
'customers': 'Customers',
'invoicing': 'Invoicing',
'accounting': 'Accounting',
@@ -319,9 +317,7 @@ var I18N = {
}
};
// Spanish is mandatory for all tenants; ignore any previously stored language.
var currentLang = 'es';
localStorage.setItem('pos_lang', 'es');
var currentLang = localStorage.getItem('pos_lang') || 'es';
/**
* Translate a key to the current language.
@@ -332,11 +328,12 @@ window.t = function(key) {
};
/**
* Language switcher is disabled; the system stays in Spanish.
* Switch the UI language and reload.
*/
window.setLang = function(lang) {
currentLang = 'es';
localStorage.setItem('pos_lang', 'es');
currentLang = lang;
localStorage.setItem('pos_lang', lang);
location.reload();
};
/**

View File

@@ -6,38 +6,19 @@
'use strict';
var API = '/pos/api/inventory';
function getToken() { return localStorage.getItem('pos_token') || ''; }
if (!getToken()) { window.location.href = '/pos/login'; return; }
var token = localStorage.getItem('pos_token');
if (!token) { window.location.href = '/pos/login'; return; }
function authHeaders() { return { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }; }
var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
var currentPage = 1;
var currentSearch = '';
var draftCountId = null;
var inventoryVS = null;
var compatSource = 'both'; // default, loaded from config
var inventorySearchController = null;
var user = window.POS_USER || {};
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() {
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.source) compatSource = d.source;
@@ -46,7 +27,7 @@
// --- API helper ---
function apiFetch(url, opts) {
return fetch(url, Object.assign({ headers: authHeaders() }, opts || {}))
return fetch(url, Object.assign({ headers: headers }, opts || {}))
.then(function (resp) {
if (resp.status === 401) {
localStorage.removeItem('pos_token');
@@ -65,15 +46,6 @@
d.textContent = s;
return d.innerHTML;
}
function formatDateTime(isoStr) {
if (!isoStr) return '-';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return esc(isoStr);
return d.toLocaleString('es-MX', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
// --- Dashboard summary badges ---
function loadSummary() {
@@ -128,7 +100,7 @@
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
fetch(API + '/tier-discounts', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
}).then(function(r) { return r.json(); })
.then(function(res) {
@@ -199,11 +171,10 @@
'<td>' + esc(it.location) + '</td>' +
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</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" 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> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
'</td></tr>';
}
@@ -254,14 +225,7 @@
var tbody = document.getElementById('productTableBody');
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
if (inventorySearchController) {
inventorySearchController.abort();
}
inventorySearchController = new AbortController();
apiFetch(API + '/items?' + params.toString(), { signal: inventorySearchController.signal })
.then(function (data) {
inventorySearchController = null;
apiFetch(API + '/items?' + params.toString()).then(function (data) {
if (!data) return;
var items = data.data || [];
@@ -304,9 +268,6 @@
} else {
pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>';
}
}).catch(function (err) {
if (err.name === 'AbortError') return;
console.error('Inventory load error:', err);
});
}
@@ -326,49 +287,39 @@
// CREATE ITEM (createModal)
// =====================================================================
function loadCategories(selectedId) {
function loadCategories() {
var sel = document.getElementById('newCategory');
if (!sel) return;
apiFetch(API + '/categories/all').then(function(data) {
apiFetch(API + '/categories').then(function(data) {
if (!data || !data.categories) return;
var cats = data.categories;
var top = cats.filter(function(c) { return !c.parent_id; });
var subs = cats.filter(function(c) { return c.parent_id; });
sel.innerHTML = '<option value="">Sin categoría</option>';
top.forEach(function(c) {
sel.innerHTML += '<optgroup label="' + esc(c.name) + '">' +
'<option value="' + c.id + '"' + (c.id === selectedId ? ' selected' : '') + '>' + esc(c.name) + '</option>';
subs.filter(function(s) { return s.parent_id === c.id; }).forEach(function(s) {
sel.innerHTML += '<option value="' + s.id + '"' + (s.id === selectedId ? ' selected' : '') + '>&nbsp;&nbsp;' + esc(s.name) + '</option>';
});
sel.innerHTML += '</optgroup>';
sel.innerHTML = '<option value="">Selecciona categoría</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
});
}
window.loadCategories = loadCategories;
function onCategoryChange(categoryId) {
// Kept for backwards compatibility; selector is now flat.
var subSel = document.getElementById('newSubcategory');
if (!subSel) return;
if (!categoryId) {
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
subSel.disabled = true;
return;
}
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
if (!data || !data.subcategories) return;
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
data.subcategories.forEach(function(s) {
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
});
subSel.disabled = false;
});
}
window.onCategoryChange = onCategoryChange;
function resetCreateModal() {
document.getElementById('editItemId').value = '';
document.getElementById('createModalTitle').textContent = 'Nuevo Producto';
document.getElementById('createModalBtn').textContent = 'Crear Producto';
var ids = ['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newUnit','newCost','newPrice1','newMinStock','newInitialStock','newMaxStock','newTaxRate','newLocation','newDescription'];
ids.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('newCategory').innerHTML = '<option value="">Sin categoría</option>';
document.getElementById('newIsActive').value = 'true';
document.getElementById('initialStockField').style.display = '';
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = false; });
}
function showCreateModal() {
resetCreateModal();
document.getElementById('createModal').classList.add('is-open');
loadCategories();
// Attach AI classification on part number blur
@@ -386,41 +337,6 @@
}
}
function showEditItemModal(itemId) {
resetCreateModal();
document.getElementById('editItemId').value = itemId;
document.getElementById('createModalTitle').textContent = 'Editar Producto';
document.getElementById('createModalBtn').textContent = 'Guardar Cambios';
document.getElementById('initialStockField').style.display = 'none';
document.getElementById('createModal').classList.add('is-open');
apiFetch(API + '/items/' + itemId).then(function(it) {
if (!it) return;
document.getElementById('newPartNumber').value = it.part_number || '';
document.getElementById('newName').value = it.name || '';
document.getElementById('newBrand').value = it.brand || '';
document.getElementById('newBarcode').value = it.barcode || '';
document.getElementById('newUnit').value = it.unit || '';
document.getElementById('newCost').value = it.cost != null ? it.cost : '';
document.getElementById('newPrice1').value = it.price_1 != null ? it.price_1 : '';
document.getElementById('newMinStock').value = it.min_stock != null ? it.min_stock : '';
document.getElementById('newMaxStock').value = it.max_stock != null ? it.max_stock : '';
document.getElementById('newTaxRate').value = it.tax_rate != null ? it.tax_rate : '';
document.getElementById('newLocation').value = it.location || '';
document.getElementById('newDescription').value = it.description || '';
document.getElementById('newIsActive').value = (it.is_active === false ? 'false' : 'true');
document.getElementById('newSku2').value = (it.sku_aliases && it.sku_aliases[0]) ? it.sku_aliases[0].sku : '';
document.getElementById('newSku3').value = (it.sku_aliases && it.sku_aliases[1]) ? it.sku_aliases[1].sku : '';
if (!canEditPrices) {
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = true; });
}
loadCategories(it.category_id);
}).catch(function(e) {
alert('Error al cargar producto: ' + e.message);
closeCreateModal();
});
}
window.showEditItemModal = showEditItemModal;
function classifyPartNumber(partNumber) {
var resultEl = document.getElementById('createResult');
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
@@ -451,57 +367,58 @@
function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = '';
resetCreateModal();
var catSel = document.getElementById('newCategory');
var subSel = document.getElementById('newSubcategory');
if (catSel) catSel.innerHTML = '<option value="">Selecciona categoría</option>';
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; subSel.disabled = true; }
}
function createItem() {
var editId = document.getElementById('editItemId').value;
var elPrice2 = document.getElementById('newPrice2');
var elPrice3 = document.getElementById('newPrice3');
var data = {
part_number: document.getElementById('newPartNumber').value.trim(),
name: document.getElementById('newName').value.trim(),
brand: document.getElementById('newBrand').value.trim(),
barcode: document.getElementById('newBarcode').value.trim() || undefined,
unit: document.getElementById('newUnit').value.trim() || undefined,
cost: parseFloat(document.getElementById('newCost').value) || 0,
price_1: parseFloat(document.getElementById('newPrice1').value) || 0,
price_2: elPrice2 ? (parseFloat(elPrice2.value) || 0) : 0,
price_3: elPrice3 ? (parseFloat(elPrice3.value) || 0) : 0,
min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
max_stock: parseInt(document.getElementById('newMaxStock').value) || 0,
tax_rate: parseFloat(document.getElementById('newTaxRate').value) || 0,
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
location: document.getElementById('newLocation').value.trim(),
description: document.getElementById('newDescription').value.trim(),
is_active: document.getElementById('newIsActive').value === 'true',
sku_aliases: []
};
var sku2 = document.getElementById('newSku2').value.trim();
var sku3 = document.getElementById('newSku3').value.trim();
var categoryId = document.getElementById('newCategory').value;
if (categoryId) data.category_id = parseInt(categoryId);
var subcategoryId = document.getElementById('newSubcategory').value;
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'});
if (subcategoryId) {
data.category_id = parseInt(subcategoryId);
} else if (categoryId) {
data.category_id = parseInt(categoryId);
}
if (!data.part_number || !data.name) {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
return;
}
var url = API + '/items';
var method = 'POST';
if (editId) {
url = API + '/items/' + editId;
method = 'PUT';
// Stock inicial solo en creación
} else {
data.initial_stock = parseInt(document.getElementById('newInitialStock').value) || 0;
}
apiFetch(url, { method: method, body: JSON.stringify(data) }).then(function (result) {
if (result && (result.id || result.message)) {
var msg = editId ? 'Producto actualizado' : ('Creado ID ' + result.id + ' | Barcode: ' + result.barcode);
if (!editId && result.vehicle_compatibilities_added > 0) {
apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
if (result && result.id) {
var msg = 'Creado ID ' + result.id + ' | Barcode: ' + result.barcode;
if (result.vehicle_compatibilities_added > 0) {
msg += ' | ' + result.vehicle_compatibilities_added + ' vehiculo(s) asignado(s) por IA';
}
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">' + msg + '</span>';
loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
if (window.loadInventoryStats) window.loadInventoryStats();
} else {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
@@ -527,7 +444,7 @@
fetch(API + '/items/bulk-import', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + getToken(),
'Authorization': 'Bearer ' + token,
'X-Import-Mode': mode,
'X-Import-Strategy': strategy
},
@@ -557,28 +474,6 @@
}
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)
// =====================================================================
@@ -1109,7 +1004,7 @@
history.forEach(function (h) {
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
html += '<tr>' +
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' +
'<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
'<td>' + esc(h.type) + '</td>' +
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '—') + '</td>' +
@@ -1134,9 +1029,10 @@
function deleteItem(itemId) {
if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return;
var token = localStorage.getItem('pos_token') || '';
fetch(API + '/items/' + itemId, {
method: 'DELETE',
headers: getToken() ? { 'Authorization': 'Bearer ' + getToken() } : {}
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; }
@@ -1189,7 +1085,7 @@
var ids = Array.from(selectedItems);
fetch('/pos/api/marketplace-ext/inventory-check', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ inventory_ids: ids })
}).then(function(r){ return r.json(); })
.then(function(data) {
@@ -1244,7 +1140,7 @@
formData.append('file', file);
fetch('/pos/api/inventory/items/' + itemId + '/image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() },
headers: { 'Authorization': 'Bearer ' + token },
body: formData
}).then(function(r){ return r.json(); })
.then(function(data) {
@@ -1269,7 +1165,7 @@
clearTimeout(meliCategorySearchTimeout);
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
meliCategorySearchTimeout = setTimeout(function() {
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' } })
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(data) {
var cats = data.categories || [];
@@ -1316,7 +1212,7 @@
var section = document.getElementById('meliAttrsSection');
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
section.style.display = 'block';
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r){ return r.json(); })
.then(function(data) {
meliCategoryAttrs = data.attributes || [];
@@ -1445,7 +1341,7 @@
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
fetch('/pos/api/marketplace-ext/listings/validate', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
inventory_ids: ids,
category_id: categoryId,
@@ -1535,7 +1431,7 @@
var maxAttempts = 60; // 2 min
var interval = setInterval(function() {
attempts++;
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.status === 'done') {
@@ -1572,7 +1468,7 @@
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
inventory_ids: ids,
category_id: categoryId,
@@ -1631,7 +1527,7 @@
if (statusEl) statusEl.textContent = 'Subiendo...';
fetch(API + '/items/' + itemId + '/image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() },
headers: { 'Authorization': 'Bearer ' + token },
body: fd
})
.then(function (r) { return r.json(); })
@@ -1654,7 +1550,7 @@
if (!confirm('Eliminar imagen de este producto?')) return;
fetch(API + '/items/' + itemId + '/image', {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + getToken() }
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function (r) { return r.json(); })
.then(function (result) {
@@ -1764,7 +1660,7 @@
? '/pos/api/catalog/part/' + catalogPartId
: '/pos/api/catalog/search?q=' + encodeURIComponent(partNumber);
fetch(url, { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('crossRefContent');
@@ -1776,7 +1672,7 @@
// If it was a search, get alternatives from first result
if (!catalogPartId && d.data && d.data.length > 0) {
// Fetch detail for first match
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r2) { return r2.json(); })
.then(function(d2) {
renderCrossRefs(el, d2.alternatives || [], d2.bodegas || []);
@@ -1849,7 +1745,7 @@
// Load SKU aliases
(function loadSkuAliases() {
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('skuAliasContent');
@@ -1881,7 +1777,7 @@
// Load vehicle compatibilities and makes
(function loadCompatPanel() {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('compatContent');
@@ -1907,7 +1803,7 @@
});
// Load makes
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var sel = document.getElementById('manualMake');
@@ -1932,7 +1828,7 @@
history.forEach(function (h) {
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
html += '<tr>' +
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' +
'<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
'<td>' + esc(h.type) + '</td>' +
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '\u2014') + '</td>' +
@@ -1951,7 +1847,7 @@
function autoMatchCompat(itemId) {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() }
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); })
.then(function(d) {
var msg = '';
@@ -1974,7 +1870,7 @@
if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + getToken() }
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); })
.then(function() {
viewProductDetail(itemId);
@@ -2007,7 +1903,7 @@
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
return;
}
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona modelo</option>';
@@ -2033,7 +1929,7 @@
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
return;
}
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona ano</option>';
@@ -2059,7 +1955,7 @@
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
return;
}
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona motor</option>';
@@ -2087,7 +1983,7 @@
}
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode })
}).then(function(r) { return r.json(); })
.then(function(d) {
@@ -2105,7 +2001,7 @@
if (!sku) { alert('Ingresa un SKU'); return; }
fetch('/pos/api/inventory/items/' + itemId + '/skus', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: sku, label: label })
}).then(function(r) { return r.json(); })
.then(function(d) {
@@ -2118,7 +2014,7 @@
if (!confirm('Eliminar este SKU alternativo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + getToken() }
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); })
.then(function() {
viewProductDetail(itemId);

View File

@@ -62,10 +62,7 @@ const Invoicing = (() => {
if (name === 'notas') loadNotas();
if (name === 'complementos') loadComplementos();
if (name === 'cancelaciones') loadCancelaciones();
if (name === 'config') {
loadFacturapiStatus();
loadEmisorData();
}
if (name === 'config') loadFacturapiStatus();
}
// ---- Badge helpers ----
@@ -89,34 +86,17 @@ const Invoicing = (() => {
}
// ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ----
let facturasCache = [];
async function loadFacturas() {
const panel = document.getElementById('panel-facturas');
if (!panel) return;
const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return;
const statusFilter = document.getElementById('facturas-status-filter');
const status = statusFilter ? statusFilter.value : '';
const url = status ? `/queue?per_page=50&type=ingreso&status=${status}` : '/queue?per_page=50&type=ingreso';
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando facturas...' }) + '</td></tr>';
try {
const res = await api(url);
facturasCache = res.data || [];
renderFacturas(facturasCache, res.pagination?.total || facturasCache.length);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
}
}
function renderFacturas(items, total) {
const panel = document.getElementById('panel-facturas');
const tbody = panel.querySelector('.data-table tbody');
const res = await api('/queue?per_page=50&type=Ingreso');
const items = res.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin facturas', subtitle: 'No hay facturas en este periodo.' }) + '</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay facturas en este periodo.</td></tr>';
return;
}
tbody.innerHTML = items.map(item => `<tr>
@@ -125,9 +105,9 @@ const Invoicing = (() => {
<td class="td--primary">${item.customer_name || '-'}</td>
<td class="td--mono">${item.rfc || '-'}</td>
<td class="td--amount">$${fmt(item.subtotal)}</td>
<td class="td--amount">$${fmt(item.tax_total)}</td>
<td class="td--amount">$${fmt(item.tax)}</td>
<td class="td--amount">$${fmt(item.total)}</td>
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
<td style="font-size:var(--text-caption);">${item.uso_cfdi || '-'}</td>
<td>${statusBadge(item.status)}</td>
<td>
<div style="display:flex;gap:4px;">
@@ -138,42 +118,12 @@ const Invoicing = (() => {
</td>
</tr>`).join('');
// Update footer count
const footer = panel.querySelector('.table-footer span');
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${total} facturas`;
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${res.pagination?.total || items.length} facturas`;
} catch (e) {
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
}
function filterFacturas() {
const q = (document.getElementById('facturas-search')?.value || '').toLowerCase();
const filtered = facturasCache.filter(item =>
(item.provisional_folio || '').toLowerCase().includes(q) ||
(item.customer_name || '').toLowerCase().includes(q) ||
(item.rfc || '').toLowerCase().includes(q)
);
renderFacturas(filtered, facturasCache.length);
}
function exportFacturasCSV() {
if (typeof window.exportVisibleTableCSV === 'function') {
window.exportVisibleTableCSV('facturas');
} else {
alert('Exportador no disponible');
}
}
function exportNotasCSV() {
if (typeof window.exportVisibleTableCSV === 'function') {
window.exportVisibleTableCSV('notas_de_credito');
} else {
alert('Exportador no disponible');
}
}
function newCreditNote() {
alert('Nueva nota de crédito — próximamente');
}
function newPaymentComplement() {
alert('Nuevo complemento de pago — próximamente');
}
// ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ----
@@ -183,20 +133,18 @@ const Invoicing = (() => {
const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando notas de credito...' }) + '</td></tr>';
try {
const res = await api('/queue?per_page=50&type=egreso');
const res = await api('/queue?per_page=50&type=Egreso');
const items = res.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin notas de credito', subtitle: 'No hay notas de credito registradas.' }) + '</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay notas de credito.</td></tr>';
return;
}
tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || '-'}</td>
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td>
<td>${item.cancel_motive || '-'}</td>
<td>${item.description || '-'}</td>
<td class="td--amount">$${fmt(item.total)}</td>
<td>${statusBadge(item.status)}</td>
<td>
@@ -207,7 +155,7 @@ const Invoicing = (() => {
</td>
</tr>`).join('');
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
}
}
@@ -218,31 +166,14 @@ const Invoicing = (() => {
const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando complementos...' }) + '</td></tr>';
try {
const res = await api('/queue?per_page=50&type=pago');
complementosCache = res.data || [];
renderComplementos(complementosCache);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
}
}
let complementosCache = [];
function renderComplementos(items) {
const panel = document.getElementById('panel-complementos');
const tbody = panel.querySelector('.data-table tbody');
const methodFilter = document.getElementById('complementos-method-filter');
const method = methodFilter ? methodFilter.value : '';
const filtered = method ? items.filter(item => (item.payment_method || '').startsWith(method)) : items;
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin complementos', subtitle: 'No hay complementos de pago registrados.' }) + '</td></tr>';
const res = await api('/queue?per_page=50&type=Pago');
const items = res.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay complementos de pago.</td></tr>';
return;
}
tbody.innerHTML = filtered.map(item => `<tr>
tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || '-'}</td>
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td>
@@ -257,6 +188,9 @@ const Invoicing = (() => {
</div>
</td>
</tr>`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
}
}
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
@@ -354,18 +288,10 @@ const Invoicing = (() => {
let pendingHtml = '';
if (status.pending_steps && status.pending_steps.length) {
pendingHtml = '<ul style="margin:var(--space-2) 0 0 0;padding-left:var(--space-5);color:var(--color-warning);">' +
status.pending_steps.map(s => `<li>${s.description || s.type || s}</li>`).join('') +
status.pending_steps.map(s => `<li>${s.description || s.type}</li>`).join('') +
'</ul>';
}
const configuredHtml = status.configured
? '<span style="color:var(--color-success);">Sí</span>'
: '<span style="color:var(--color-error);">No</span>';
const retryButton = !status.configured || status.error
? '<button class="btn btn--secondary" style="margin-top:var(--space-3);" onclick="Invoicing.setupFacturapi(this)">Reintentar configuración</button>'
: '';
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
<div>
@@ -375,128 +301,29 @@ const Invoicing = (() => {
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${status.org_id || ''}</div>
</div>
<div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Configurada</div>
<div style="font-weight:var(--font-weight-semibold);">${configuredHtml}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">CSD</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">CSD</div>
<div style="font-weight:var(--font-weight-semibold);">${csdHtml}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">Pasos pendientes</div>
${pendingHtml || '<span style="color:var(--color-success);">Ninguno</span>'}
</div>
</div>
${status.error ? `<p style="color:var(--color-error);margin-top:var(--space-3);">Error: ${escapeHtml(status.error)}</p>` : ''}
${retryButton}
`;
} catch (e) {
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
}
}
// ---- Emisor data (config tab) ----
async function loadEmisorData() {
const rfcEl = document.getElementById('rfc-emisor');
const cpEl = document.getElementById('cp-fiscal');
const razonEl = document.getElementById('razon-social');
const regimenEl = document.getElementById('regimen-fiscal');
const direccionEl = document.getElementById('direccion-fiscal');
const exteriorEl = document.getElementById('numero-exterior');
const interiorEl = document.getElementById('numero-interior');
const coloniaEl = document.getElementById('colonia-fiscal');
const ciudadEl = document.getElementById('ciudad-fiscal');
const municipioEl = document.getElementById('municipio-fiscal');
const estadoEl = document.getElementById('estado-fiscal');
if (!rfcEl || !cpEl || !razonEl || !regimenEl) return;
try {
const res = await fetch('/pos/api/config/business', { headers: headers() });
if (!res.ok) throw new Error('Error al cargar datos fiscales');
const data = await res.json();
rfcEl.value = data.rfc || '';
cpEl.value = data.cp || '';
razonEl.value = data.razon_social || '';
if (direccionEl) direccionEl.value = data.direccion || '';
if (exteriorEl) exteriorEl.value = data.numero_exterior || '';
if (interiorEl) interiorEl.value = data.numero_interior || '';
if (coloniaEl) coloniaEl.value = data.colonia || '';
if (ciudadEl) ciudadEl.value = data.ciudad || '';
if (municipioEl) municipioEl.value = data.municipio || '';
if (estadoEl) estadoEl.value = data.estado || '';
const regimen = data.regimen_fiscal || '601';
regimenEl.value = regimen + ' — ' + (regimenEl.querySelector('option[value^="' + regimen + '"')?.textContent.split('—')[1]?.trim() || '');
// If exact value not matched, leave first option selected by SAT code prefix
if (!regimenEl.value.startsWith(regimen)) {
Array.from(regimenEl.options).forEach(function(opt) {
if (opt.value.startsWith(regimen)) opt.selected = true;
});
}
} catch (e) {
console.error('Invoicing.loadEmisorData:', e);
}
}
async function saveEmisorData() {
const rfc = (document.getElementById('rfc-emisor')?.value || '').trim();
const cp = (document.getElementById('cp-fiscal')?.value || '').trim();
const razon_social = (document.getElementById('razon-social')?.value || '').trim();
const regimenValue = document.getElementById('regimen-fiscal')?.value || '601';
const regimen_fiscal = regimenValue.split('—')[0].trim();
const direccion = (document.getElementById('direccion-fiscal')?.value || '').trim();
const numero_exterior = (document.getElementById('numero-exterior')?.value || '').trim();
const numero_interior = (document.getElementById('numero-interior')?.value || '').trim();
const colonia = (document.getElementById('colonia-fiscal')?.value || '').trim();
const ciudad = (document.getElementById('ciudad-fiscal')?.value || '').trim();
const municipio = (document.getElementById('municipio-fiscal')?.value || '').trim();
const estado = (document.getElementById('estado-fiscal')?.value || '').trim();
const statusEl = document.getElementById('emisor-save-status');
if (!rfc || !razon_social || !cp) {
if (statusEl) statusEl.textContent = 'RFC, Razón Social y C.P. son obligatorios';
return;
}
try {
const res = await fetch('/pos/api/config/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({
rfc, razon_social, regimen_fiscal, cp,
direccion, numero_exterior, numero_interior, colonia, ciudad, municipio, estado,
cfdi_regimen_fiscal: regimen_fiscal, cfdi_serie: 'A'
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Error al guardar');
}
if (statusEl) {
statusEl.textContent = 'Datos fiscales guardados';
statusEl.style.color = 'var(--color-success)';
}
setTimeout(() => { if (statusEl) { statusEl.textContent = ''; statusEl.style.color = ''; } }, 4000);
} catch (e) {
if (statusEl) {
statusEl.textContent = e.message;
statusEl.style.color = 'var(--color-error)';
}
}
}
async function setupFacturapi(btn) {
if (!btn) return;
btn.disabled = true;
btn.textContent = 'Configurando...';
try {
const res = await api('/facturapi/setup', { method: 'POST' });
if (res.error) {
alert('Aviso: ' + res.error);
} else if (res.configured) {
alert('Organización vinculada y configurada: ' + res.org_id);
} else {
alert('Organización vinculada: ' + res.org_id + '. Revisa pasos pendientes.');
}
alert('Organización vinculada: ' + res.org_id);
loadFacturapiStatus();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Crear / Vincular Organización';
}
@@ -508,13 +335,6 @@ const Invoicing = (() => {
document.getElementById('csd-key-label').textContent = 'Subir llave privada .key';
}
function reloadManifiesto() {
const iframe = document.getElementById('manifiesto-iframe');
if (iframe) {
iframe.src = iframe.src;
}
}
function updateFileLabels() {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
@@ -854,6 +674,13 @@ const Invoicing = (() => {
window.submitNewInvoice = submitNewInvoice;
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
return {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -862,15 +689,4 @@ const Invoicing = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
return {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
loadEmisorData, saveEmisorData,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm, reloadManifiesto,
filterFacturas, exportFacturasCSV, exportNotasCSV,
newCreditNote, newPaymentComplement,
};
})();

View File

@@ -82,13 +82,7 @@
localStorage.setItem('pos_token', result.data.token);
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';
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

@@ -1 +1 @@
!function(){"use strict";var t="",e=document.querySelectorAll("#pinDots .pin-dot"),n=document.getElementById("loginError"),o=new URLSearchParams(window.location.search).get("tenant")||localStorage.getItem("pos_tenant_id"),a=localStorage.getItem("pos_device_id");function i(){e.forEach((function(e,n){e.classList.toggle("filled",n<t.length)}))}a||(a="dev-"+Date.now()+"-"+Math.random().toString(36).substr(2,9),localStorage.setItem("pos_device_id",a)),window.addDigit=function(e){t.length>=4||(t+=e,i(),n.textContent="",4===t.length&&submitPin())},window.clearPin=function(){t="",i(),n.textContent=""},window.submitPin=function(){4===t.length&&(n.textContent="",fetch("/pos/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tenant_id:parseInt(o),pin:t,device_id:a})}).then((function(t){return t.json().then((function(e){return{ok:t.ok,data:e}}))})).then((function(t){if(!t.ok)return n.textContent=t.data.error||"Error de autenticacion",void clearPin();localStorage.setItem("pos_token",t.data.token),localStorage.setItem("pos_employee",JSON.stringify(t.data.employee)),localStorage.setItem("pos_tenant_id",o),document.cookie="pos_role="+(t.data.employee.role||"")+"; path=/pos; SameSite=Lax",window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}();
!function(){"use strict";var t="",e=document.querySelectorAll("#pinDots .pin-dot"),n=document.getElementById("loginError"),o=new URLSearchParams(window.location.search).get("tenant")||localStorage.getItem("pos_tenant_id"),a=localStorage.getItem("pos_device_id");function i(){e.forEach((function(e,n){e.classList.toggle("filled",n<t.length)}))}a||(a="dev-"+Date.now()+"-"+Math.random().toString(36).substr(2,9),localStorage.setItem("pos_device_id",a)),window.addDigit=function(e){t.length>=4||(t+=e,i(),n.textContent="",4===t.length&&submitPin())},window.clearPin=function(){t="",i(),n.textContent=""},window.submitPin=function(){4===t.length&&(n.textContent="",fetch("/pos/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tenant_id:parseInt(o),pin:t,device_id:a})}).then((function(t){return t.json().then((function(e){return{ok:t.ok,data:e}}))})).then((function(t){if(!t.ok)return n.textContent=t.data.error||"Error de autenticacion",void clearPin();localStorage.setItem("pos_token",t.data.token),localStorage.setItem("pos_employee",JSON.stringify(t.data.employee)),localStorage.setItem("pos_tenant_id",o),window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}();

View File

@@ -64,7 +64,7 @@
}
}
window.startOAuth = async function() {
window.startOAuth = function() {
var clientId = document.getElementById('cfgClientId').value.trim();
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
var category = document.getElementById('cfgCategory').value.trim();
@@ -75,34 +75,15 @@
return;
}
// Purge any previously stored secret from older versions
localStorage.removeItem('meli_client_secret');
localStorage.removeItem('meli_client_id');
// Save config locally for the callback
localStorage.setItem('meli_client_id', clientId);
localStorage.setItem('meli_client_secret', clientSecret);
localStorage.setItem('meli_category', category);
localStorage.setItem('meli_shipping', shipping);
try {
var res = await fetch(API + '/connect/init', {
method: 'POST',
headers: headers(),
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
category: category,
shipping: shipping
})
});
var data = await res.json();
if (!res.ok) {
alert('Error iniciando conexión: ' + (data.error || 'Unknown'));
return;
}
if (data.auth_url) {
window.location.href = data.auth_url;
}
} catch (e) {
alert('Error: ' + e.message);
}
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access';
window.location.href = authUrl;
};
window.disconnectMeli = async function() {
@@ -382,6 +363,8 @@
var authCode = urlParams.get('code');
if (authCode && window.location.pathname.includes('marketplace-external')) {
(async function() {
var clientId = localStorage.getItem('meli_client_id');
var clientSecret = localStorage.getItem('meli_client_secret');
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
try {
var res = await fetch(API + '/connect', {
@@ -389,6 +372,8 @@
headers: headers(),
body: JSON.stringify({
code: authCode,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
})
});

View File

@@ -160,15 +160,7 @@
delete el.dataset.originalContent;
};
// ── Loading / Empty state helpers ────────────────────────────────────────
window.renderLoadingState = function(opts) {
opts = opts || {};
var message = opts.message || 'Cargando...';
return '<div class="empty-state empty-state--loading" role="status" aria-live="polite">' +
'<div class="empty-state__icon"><svg class="empty-state__spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg></div>' +
'<div class="empty-state__title">' + message + '</div>' +
'</div>';
};
// ── Empty state helper ────────────────────────────────────────
window.renderEmptyState = function(opts) {
opts = opts || {};
var icon = opts.icon || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';

View File

@@ -21,24 +21,10 @@ const POS = (() => {
let paymentMethod = 'efectivo';
let canViewCost = false;
let employeeMaxDiscount = 100;
let lastSaleId = sessionStorage.getItem('pos_last_sale_id') || null;
let lastSaleId = null;
let lastSaleData = null;
let searchTimeout = null;
let customerSearchTimeout = null;
let canCancel = false;
let canDiscount = false;
let canEditPrice = false;
let canCreateWorkshopOrder = false;
let canCreateLayaway = false;
let canCreateRemission = false;
let counterRemissionEnabled = false;
let allowZeroPriceSales = true;
let allowNegativeStock = false;
let workshopModuleEnabled = true;
let currentPerms = [];
let receiptConfig = {};
let couriers = [];
let selectedCourierId = null;
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -46,16 +32,6 @@ 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 };
}
@@ -78,48 +54,6 @@ const POS = (() => {
setTimeout(() => el.remove(), 2100);
}
// ─── Permission-based UI toggles ─────
function applyPermissionsUI() {
function hide(selector) {
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');
}
if (!canDiscount) hide('[onclick="POS.applyDiscount()"]');
if (!canEditPrice) hide('[onclick="POS.modifyPrice()"]');
if (!canCreateWorkshopOrder || !workshopModuleEnabled) {
hide('[onclick="POS.createServiceOrder()"]');
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 ────────────────────────────
async function init() {
// Parse JWT to get employee info
@@ -127,44 +61,9 @@ const POS = (() => {
const payload = JSON.parse(atob(token.split('.')[1]));
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') || employeeRole === 'cashier' || employeeRole === 'counter';
canCreateWorkshopOrder = perms.includes('workshop.edit');
canCreateLayaway = perms.includes('pos.sell');
canViewCost = (payload.permissions || []).includes('pos.view_cost');
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;
}
// Sales settings (zero-price and negative-stock toggles)
try {
const ssCfg = await api('/pos/api/config/sales-settings');
allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false;
allowNegativeStock = ssCfg.allow_negative_stock === true;
} catch (e) {
allowZeroPriceSales = true;
allowNegativeStock = false;
}
// Module toggles (from app-init preloaded modules)
try {
var modules = window.POS_USER && window.POS_USER.modules;
if (!modules) modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
workshopModuleEnabled = modules.workshop !== false;
} catch (e) {
workshopModuleEnabled = true;
}
// 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 = '';
@@ -179,8 +78,6 @@ const POS = (() => {
const parts = payload.name.split(' ');
avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase();
}
applyPermissionsUI();
} catch (e) {
console.warn('Could not parse token:', e);
}
@@ -205,10 +102,8 @@ const POS = (() => {
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
}
// Load current register and receipt config
// Load current register
await loadRegister();
await loadReceiptConfig();
await loadCouriers();
// Setup event listeners
setupKeyboard();
@@ -228,48 +123,14 @@ 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 only for users who can sell
if (currentPerms.includes('pos.sell')) {
// Force open register modal on first load
showOpenRegisterModal();
}
}
} catch (e) {
console.warn('Register check failed:', e);
}
}
async function loadReceiptConfig() {
try {
receiptConfig = await api('/pos/api/config/receipt');
} catch (e) {
console.warn('Could not load receipt config:', e);
receiptConfig = {};
}
}
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;
}
function showOpenRegisterModal() {
document.getElementById('openRegisterModal').classList.add('open');
document.getElementById('registerOpenResult').innerHTML = '';
@@ -417,7 +278,6 @@ const POS = (() => {
}
function openCancelModal() {
if (!canCancel) { showToast('No tienes permiso para cancelar'); return; }
const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta');
if (overlay) overlay.classList.add('active');
@@ -447,7 +307,6 @@ const POS = (() => {
}
function applyDiscount() {
if (!canDiscount) { showToast('No tienes permiso para aplicar descuentos'); return; }
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
@@ -462,20 +321,7 @@ const POS = (() => {
}
}
function isZeroPriceItem(item) {
const unitPrice = parseFloat(item.unit_price || 0);
const qty = parseFloat(item.quantity || 1);
const discount = parseFloat(item.discount_pct || 0);
return (unitPrice * qty * (1 - discount / 100)) <= 0;
}
function findZeroPriceItem() {
if (allowZeroPriceSales) return null;
return cart.find(isZeroPriceItem) || null;
}
function modifyPrice() {
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
@@ -483,15 +329,12 @@ const POS = (() => {
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
if (p !== null) {
const n = parseFloat(p);
if (n < 0) { showToast('Precio no válido'); return; }
if (!allowZeroPriceSales && n === 0) {
showToast('No está permitido dejar el precio en $0');
return;
}
if (n >= 0) {
cart[selectedRow].unit_price = n;
renderCart();
}
}
}
// Wire confirm-cancel button
document.addEventListener('DOMContentLoaded', function() {
@@ -676,11 +519,26 @@ 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 => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
const price = priceForTier(item.price_1, tier);
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;
}
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>
@@ -775,7 +633,7 @@ const POS = (() => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => {
if (item.price_1 > 0) {
item.unit_price = priceForTier(item.price_1, tier);
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
});
}
@@ -971,12 +829,9 @@ const POS = (() => {
transferencia: 'refPayment',
tarjeta: 'refPayment',
mixto: 'mixedPayment',
credito: 'creditPayment',
cheque: 'chequePayment',
pendiente: 'pendingPayment',
};
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
const el = document.getElementById(id);
if (el) {
const isActive = el.id === tabs[method];
@@ -990,12 +845,6 @@ 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() {
@@ -1033,7 +882,6 @@ const POS = (() => {
let amountPaid = 0;
let paymentDetails = [];
let reference = '';
let saleType = 'cash';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
@@ -1053,20 +901,6 @@ 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 = {
@@ -1079,7 +913,7 @@ const POS = (() => {
})),
customer_id: currentCustomer ? currentCustomer.id : null,
payment_method: paymentMethod,
sale_type: saleType,
sale_type: 'cash',
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
@@ -1087,12 +921,6 @@ const POS = (() => {
generate_cfdi: document.getElementById('cfdiCheck').checked,
};
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const confirmBtn = document.getElementById('btnConfirmPayment');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Procesando...';
@@ -1104,10 +932,9 @@ const POS = (() => {
const convertData = {
register_id: currentRegister ? currentRegister.id : null,
payment_method: paymentMethod,
sale_type: saleType,
sale_type: 'cash',
amount_paid: amountPaid,
payment_details: paymentDetails,
reference: reference,
};
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
method: 'POST',
@@ -1125,7 +952,6 @@ const POS = (() => {
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
closePaymentModal();
showTicket(sale);
@@ -1142,18 +968,22 @@ const POS = (() => {
}
}
// ─── 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 zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
// ─── 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; }
const total = getTotal();
const noteData = {
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 = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
@@ -1161,158 +991,34 @@ const POS = (() => {
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer ? currentCustomer.id : null,
notes: 'Nota de remision generada desde mostrador',
customer_id: currentCustomer.id,
payment_method: 'credito',
sale_type: 'credit',
register_id: currentRegister ? currentRegister.id : null,
courier_id: selectedCourierId,
amount_paid: 0,
};
try {
const sale = await api('/pos/api/sales/remission', {
const sale = await api('/pos/api/sales', {
method: 'POST',
body: JSON.stringify(noteData),
body: JSON.stringify(saleData),
});
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) {}
showTicket(sale);
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 al generar nota de remision: ' + e.message);
alert('Error: ' + 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; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido cotizar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const body = {
items: cart.map(item => ({
@@ -1351,13 +1057,7 @@ const POS = (() => {
// ─── Layaway ─────────────────────────
async function createLayaway() {
if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
if (cart.length === 0) { alert('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
alert('No está permitido apartar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
const total = getTotal();
@@ -1397,138 +1097,6 @@ const POS = (() => {
}
}
// ─── Service Order from POS ──────────
function createServiceOrder() {
if (!workshopModuleEnabled) { showToast('El módulo de taller no está habilitado'); return; }
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
if (cart.length === 0) { showToast('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido generar orden con artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const custInput = document.getElementById('soCustomer');
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
document.getElementById('serviceOrderModal').classList.add('open');
}
function closeServiceOrderModal() {
document.getElementById('serviceOrderModal').classList.remove('open');
}
async function confirmServiceOrder() {
const customerId = currentCustomer ? currentCustomer.id : null;
if (!customerId) { alert('Seleccione un cliente para la orden de servicio'); return; }
const vehicleText = document.getElementById('soVehicle').value.trim();
const delivery = document.getElementById('soDelivery').value;
const notes = document.getElementById('soNotes').value.trim();
const isDirect = document.getElementById('soDirect').checked;
const items = cart.map(item => ({
inventory_id: item.inventory_id,
part_number: item.part_number,
name: item.name,
quantity: item.quantity,
unit_price: item.unit_price,
status: 'pending'
}));
try {
const order = await api('/pos/api/service-orders/from-pos', {
method: 'POST',
body: JSON.stringify({
customer_id: customerId,
priority: 'normal',
reception_notes: (vehicleText ? 'Vehiculo: ' + vehicleText + '. ' : '') + (notes || ''),
delivery_method: delivery || null,
is_direct: isDirect,
items: items
})
});
showToast(`Orden de servicio ${order.order_number} creada`);
closeServiceOrderModal();
cart = [];
selectedRow = -1;
renderCart();
showServiceOrderTicket(order);
} catch (e) {
alert('Error: ' + e.message);
}
}
function showServiceOrderTicket(order) {
const dateStr = new Date(order.created_at).toLocaleString('es-MX', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const customerName = order.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
let itemsHtml = '';
(order.items || []).forEach(item => {
const itemTotal = item.unit_price * item.quantity;
itemsHtml += `
<div class="item-line-wide ticket-line">
<span class="qty">${item.quantity}</span>
<span class="name">${item.name || ''}</span>
<span class="price">${fmt(item.unit_price)}</span>
<span class="subtotal">${fmt(itemTotal)}</span>
</div>`;
});
const ticketHtml = `
<div class="store-name">NEXUS AUTOPARTS</div>
<div class="store-tagline">Orden de servicio</div>
<div class="store-info">
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
</div>
<hr class="divider-double">
<div class="folio-line">
<span>ORDEN: ${order.order_number}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</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>
<span class="name">Descripcion</span>
<span class="price">P. Unit</span>
<span class="subtotal">Importe</span>
</div>
<hr class="divider" style="margin: 2px 0;">
${itemsHtml}
<hr class="divider-double">
<div class="total-section">
<div class="total-line">
<span>Refacciones:</span><span>${fmt(order.total_parts || 0)}</span>
</div>
<div class="total-line">
<span>Mano de obra:</span><span>${fmt(order.total_labor || 0)}</span>
</div>
<div class="total-line grand">
<span>TOTAL:</span><span>${fmt(order.total || 0)}</span>
</div>
</div>
<hr class="divider">
<div class="footer-section">
<div class="thanks">Gracias por su preferencia!</div>
<div>Conserve su ticket como comprobante.</div>
</div>
`;
const printArea = document.getElementById('ticketContent');
if (printArea) printArea.innerHTML = ticketHtml;
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = 'Ticket de Orden de Servicio';
document.getElementById('ticketModal').classList.add('open');
}
// ─── Ticket ──────────────────────────
function showTicket(sale) {
const dateStr = new Date(sale.created_at).toLocaleString('es-MX', {
@@ -1536,15 +1104,14 @@ const POS = (() => {
hour: '2-digit', minute: '2-digit'
});
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 : '');
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
let itemsHtml = '';
(sale.items || []).forEach(item => {
const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
itemsHtml += `
<div class="item-line-wide ticket-line">
<div class="item-line-wide">
<span class="qty">${item.quantity}</span>
<span class="name">${item.name || ''}</span>
<span class="price">${fmt(item.unit_price)}</span>
@@ -1552,48 +1119,22 @@ const POS = (() => {
</div>`;
});
const cfg = receiptConfig || {};
const showLogo = cfg.show_logo && cfg.logo;
const showRfc = cfg.show_rfc !== false;
const showAddress = cfg.show_address;
const showPhone = cfg.show_phone;
const showIva = cfg.show_iva_breakdown !== false;
const showPayment = cfg.show_payment_details !== false;
const showEmployee = cfg.show_employee;
const storeName = rc('store_name', 'NEXUS AUTOPARTS');
const storeTagline = rc('tagline', 'Tu conexion con las refacciones');
const storeRfc = rc('rfc', 'NAU210315XX1');
const storeAddress = rc('address', '');
const storePhone = rc('phone', '');
const thanksMsg = rc('thanks_message', 'Gracias por su compra!');
const footerMsg = rc('footer', 'Conserve su ticket como comprobante.');
let storeInfoLines = [];
if (currentRegister && currentRegister.branch_name) storeInfoLines.push(`Sucursal: ${currentRegister.branch_name}`);
if (showEmployee && sale.employee_name) storeInfoLines.push(`Atendió: ${sale.employee_name}`);
if (showRfc && storeRfc) storeInfoLines.push(`RFC: ${storeRfc}`);
if (showAddress && storeAddress) storeInfoLines.push(storeAddress);
if (showPhone && storePhone) storeInfoLines.push(`Tel: ${storePhone}`);
const logoHtml = showLogo ? `<div class="ticket-logo-wrap"><img src="${cfg.logo}" alt="Logo" class="ticket-logo" style="max-width:140px;max-height:70px;object-fit:contain;"></div>` : '';
const ticketHtml = `
${logoHtml}
<div class="store-name">${storeName}</div>
${storeTagline ? `<div class="store-tagline">${storeTagline}</div>` : ''}
<div class="store-name">NEXUS AUTOPARTS</div>
<div class="store-tagline">Tu conexion con las refacciones</div>
<div class="store-info">
${storeInfoLines.join('<br>')}
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
</div>
<hr class="divider-double">
<div class="folio-line">
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
<span>VENTA: 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>
@@ -1609,20 +1150,15 @@ const POS = (() => {
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
</div>
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
${showIva ? `<div class="total-line"><span>IVA:</span><span>${fmt(sale.tax_total)}</span></div>` : ''}
<div class="total-line">
<span>IVA 16%:</span><span>${fmt(sale.tax_total)}</span>
</div>
<div class="total-line grand">
<span>TOTAL:</span><span>${fmt(sale.total)}</span>
</div>
</div>
${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>
@@ -1633,13 +1169,11 @@ const POS = (() => {
<div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''}
`}
</div>` : ''}
</div>
<hr class="divider">
<div class="footer-section">
<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 class="thanks">Gracias por su compra!</div>
<div>Conserve su ticket como comprobante.</div>
</div>
`;
@@ -1648,8 +1182,6 @@ const POS = (() => {
if (printArea) printArea.innerHTML = ticketHtml;
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
document.getElementById('ticketModal').classList.add('open');
}
@@ -1659,84 +1191,11 @@ const POS = (() => {
}
function printTicket() {
const src = document.getElementById('ticketContent');
if (!src) { console.warn('printTicket: ticketContent not found'); return; }
try {
// Paper width: 58 mm -> 48 mm printable; 80 mm -> 72 mm printable.
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
const is58 = paperWidth === 58;
const printWidth = is58 ? 48 : 72;
const fontSize = is58 ? '12pt' : '14pt';
const smallFont = is58 ? '10pt' : '12pt';
const nameMax = is58 ? '20mm' : '38mm';
let iframe = document.getElementById('ticketPrintFrame');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'ticketPrintFrame';
iframe.style.position = 'fixed';
iframe.style.left = '-9999px';
iframe.style.top = '0';
iframe.style.width = printWidth + 'mm';
iframe.style.border = '0';
document.body.appendChild(iframe);
}
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@page { margin: 0; size: ${printWidth}mm auto; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: auto; overflow: visible; background: #fff; color: #000; font-family: 'Courier New', Courier, 'Lucida Console', monospace; font-size: ${fontSize}; line-height: 1.55; -webkit-font-smoothing: none; -moz-osx-font-smoothing: unset; text-rendering: geometricPrecision; }
body { width: ${printWidth}mm; }
.ticket { width: ${printWidth}mm; padding: 3mm 2.5mm 4mm; border: none !important; box-shadow: none !important; }
.item-line-wide { display: grid !important; grid-template-columns: ${is58 ? '9mm 1fr auto' : '10mm 1fr auto auto'} !important; gap: 2mm; font-size: ${fontSize}; margin-bottom: 2.5mm; padding-left: 1mm; align-items: start; }
.item-line-wide .qty { text-align: center; }
.item-line-wide .name { white-space: normal !important; word-break: break-word; overflow-wrap: anywhere; max-width: ${nameMax}; padding-left: 1mm; }
.item-line-wide .price { text-align: right; white-space: nowrap; ${is58 ? 'display: none !important;' : ''} }
.item-line-wide .subtotal { text-align: right; white-space: nowrap; }
.ticket-row, .folio-line, .total-line { display: flex !important; justify-content: space-between; gap: 2mm; }
.total-line.grand { font-size: ${is58 ? '13pt' : '15pt'}; font-weight: bold; border-top: 0.6mm solid #000; padding-top: 1.5mm; margin-top: 1.5mm; }
.store-name { font-size: ${is58 ? '14pt' : '16pt'}; font-weight: bold; text-align: center; margin-bottom: 3px; }
.store-tagline, .store-info { font-size: ${smallFont}; text-align: center; color: #000; margin-bottom: 5px; line-height: 1.45; }
.divider { border: none; border-top: 1px dashed #999; margin: 5px 0; }
.divider-double { border: none; border-top: 2px solid #333; margin: 5px 0; }
.footer-section { text-align: center; margin-top: 8px; font-size: ${smallFont}; color: #000; line-height: 1.45; }
.payment-section, .total-section { width: 100%; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${src.innerHTML}</body>
</html>`);
doc.close();
// Measure real content height and set exact page size (avoids phantom 3276 mm).
const bodyEl = doc.body;
const heightPx = Math.max(bodyEl.scrollHeight, bodyEl.offsetHeight);
const heightMm = Math.ceil((heightPx * 25.4 / 96) + 5);
const exactPage = doc.createElement('style');
exactPage.textContent = `@page { margin: 0; size: ${printWidth}mm ${heightMm}mm; }`;
doc.head.appendChild(exactPage);
iframe.contentWindow.focus();
setTimeout(() => {
try {
iframe.contentWindow.print();
} catch (e) {
console.error('printTicket: iframe.print failed', e);
// Make print area visible for @media print
const area = document.getElementById('ticketPrintArea');
if (area) area.style.display = 'block';
window.print();
}
}, 200);
} catch (e) {
console.error('printTicket error:', e);
alert('No se pudo preparar la impresion del ticket. Revise la consola.');
}
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
}
// ─── Thermal Printing ─────────────────
@@ -1758,8 +1217,7 @@ const POS = (() => {
return;
}
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
const ok = await NexusPrinter.printSale(lastSaleId, paperWidth);
const ok = await NexusPrinter.printSale(lastSaleId);
if (ok) {
showToast('Ticket enviado a impresora termica');
} else {
@@ -1828,10 +1286,6 @@ const POS = (() => {
e.preventDefault();
openDrawer();
break;
case 'F7':
e.preventDefault();
createServiceOrder();
break;
case 'Escape':
e.preventDefault();
if (document.getElementById('paymentModal').classList.contains('open')) {
@@ -1840,8 +1294,6 @@ const POS = (() => {
closeNewCustomerModal();
} else if (document.getElementById('ticketModal').classList.contains('open')) {
closeTicketModal();
} else if (document.getElementById('serviceOrderModal').classList.contains('open')) {
closeServiceOrderModal();
} else if (document.querySelector('.confirm-overlay.active')) {
// Close cancel modal
const overlay = document.getElementById('overlay-cancelar-venta');
@@ -1909,14 +1361,6 @@ const POS = (() => {
});
}
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
// ─── Public API ──────────────────────
init();
@@ -1928,10 +1372,7 @@ const POS = (() => {
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
selectPaymentMethod, updateChange, updateMixedTotal,
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
saveQuotation, createLayaway,
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
creditSale, saveQuotation, createLayaway,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,
connectThermal, thermalPrint,
@@ -1939,5 +1380,12 @@ const POS = (() => {
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

View File

@@ -54,25 +54,12 @@ const Reports = (() => {
}
// Track which tabs have been loaded
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');
}
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
// -------------------------------------------------------------------------
// Theme switcher
// -------------------------------------------------------------------------
function setTheme(theme) {
/* function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
var btnInd = document.getElementById('btn-industrial');
@@ -80,7 +67,15 @@ const Reports = (() => {
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
}
window.setTheme = setTheme;
window.setTheme = setTheme;*/
function setTheme(theme) {
if (window.posSetTheme) window.posSetTheme(theme);
var btnInd = document.getElementById('btn-industrial');
var btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
}
// -------------------------------------------------------------------------
// Tab switcher with lazy loading
@@ -99,7 +94,6 @@ 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;
@@ -518,7 +512,7 @@ const Reports = (() => {
agingEl.innerHTML = spinner();
try {
var data = await apiFetch('/pos/api/accounting/aging-summary');
var data = await apiFetch('/pos/api/accounting/aging');
var clients = data.data || [];
var totals = data.totals || {};
@@ -751,153 +745,6 @@ 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
// -------------------------------------------------------------------------
@@ -914,9 +761,6 @@ 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);
@@ -925,49 +769,6 @@ 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');
@@ -991,18 +792,15 @@ const Reports = (() => {
}
}
// Load the default active tab (ventas) only for privileged users
if (!limited) {
// Load the default active tab (ventas)
loadVentas();
}
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, switchTab,
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
showCorteVentas, fmt
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -15,6 +15,7 @@ window.renderSidebar = function(modulesOverride) {
var initials = u.initials || '?';
var currentPath = window.location.pathname;
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
var currentLang = localStorage.getItem('pos_lang') || 'es';
var modules = {};
if (modulesOverride && typeof modulesOverride === 'object') {
@@ -30,73 +31,30 @@ window.renderSidebar = function(modulesOverride) {
return modules[key] !== false;
}
var role = (u.role || '').toLowerCase();
var perms = u.permissions || [];
function hasPerm(p) {
return role === 'owner' || perms.indexOf(p) !== -1;
}
// Role-based section filtering. Owner/admin bypasses; other roles get
// only the sections allowed by their permissions.
function itemAllowed(id) {
if (role === 'owner' || role === 'admin') return true;
var base = [];
if (role === 'workshop' || role === 'mechanic') {
base = ['workshop'];
}
var permMap = {
'pos.sell': 'pos',
'pos.view': 'pos',
'catalog.view': 'catalog',
'inventory.view': 'inventory',
'customers.view': 'customers',
'workshop.view': 'workshop',
'pos.remission': 'remission_notes',
'invoicing.view': 'invoicing',
'quotations.view': 'quotations',
'accounting.view': 'accounting',
'reports.view': 'reports',
'dashboard.view': 'dashboard'
};
var allowed = base.slice();
for (var p in permMap) {
if (perms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
allowed.push(permMap[p]);
}
}
return allowed.indexOf(id) !== -1;
}
var navSections = [
{ label: _t('nav_main'), items: [
{ id: 'dashboard', name: _t('dashboard'), href: '/pos/dashboard', icon: '<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"/>' },
{ id: 'pos', name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
moduleEnabled('catalog') ? { id: 'catalog', name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
{ id: 'inventory', name: _t('inventory'), href: '/pos/inventory', icon: '<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"/>' },
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<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"/>' },
{ name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
moduleEnabled('catalog') ? { name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
{ name: _t('inventory'), href: '/pos/inventory', icon: '<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"/>' },
].filter(Boolean)},
{ 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"/>' },
moduleEnabled('workshop') ? { 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"/>' } : null,
{ 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,
moduleEnabled('meli') ? { id: 'meli', name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
{ 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"/>' },
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); })},
{ 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"/>' },
{ 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"/>' },
{ 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') ? { 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,
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
{ 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"/>' },
{ 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"/>' },
{ 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"/>' },
{ 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"/>' },
moduleEnabled('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)},
{ label: _t('nav_system'), items: [
{ id: 'config', name: _t('config'), href: '/pos/config', icon: '<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"/>' },
].filter(function(i){ return itemAllowed(i.id); })},
{ name: _t('config'), href: '/pos/config', icon: '<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"/>' },
]},
];
// Remove sections whose items were all filtered out
navSections = navSections.filter(function(sec) { return sec.items.length > 0; });
function svgIcon(paths) {
return '<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>';
}
@@ -124,6 +82,16 @@ window.renderSidebar = function(modulesOverride) {
+ '</button>'
+ '</div>';
// Language toggle buttons
var langHtml = '<div class="sidebar__lang-toggle">'
+ '<button class="lang-toggle-btn' + (currentLang === 'es' ? ' is-active' : '') + '" onclick="setLang(\'es\')" title="Espanol">'
+ '<span class="lang-flag">MX</span> ES'
+ '</button>'
+ '<button class="lang-toggle-btn' + (currentLang === 'en' ? ' is-active' : '') + '" onclick="setLang(\'en\')" title="English">'
+ '<span class="lang-flag">US</span> EN'
+ '</button>'
+ '</div>';
window.updateThemeButtons = function() {
var t = localStorage.getItem('pos_theme') || 'industrial';
document.querySelectorAll('.theme-toggle-btn').forEach(function(b, i) {
@@ -141,6 +109,7 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>'
+ '<nav class="sidebar__nav">' + navHtml + '</nav>'
+ themeHtml
+ langHtml
+ '<div class="sidebar__footer">'
+ ' <div class="sidebar__user-avatar">' + initials + '</div>'
+ ' <div class="sidebar__user-info">'
@@ -153,14 +122,14 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>';
// Replace existing sidebar
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
if (existing) {
existing.className = 'pos-sidebar sidebar';
existing.className = 'pos-sidebar';
existing.innerHTML = sidebarHtml;
existing.removeAttribute('style');
} else {
var el = document.createElement('aside');
el.className = 'pos-sidebar sidebar';
el.className = 'pos-sidebar';
el.innerHTML = sidebarHtml;
document.body.insertBefore(el, document.body.firstChild);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
// /home/Autopartes/pos/static/pwa/sw.js
// Nexus POS — Service Worker
// Nexus POS — Service Worker v17
// Self-contained vanilla JS. No external imports.
//
// Bump VERSION whenever static assets change significantly.
// Bump CACHE_NAME whenever static assets change significantly.
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
const VERSION = 54;
const CACHE_NAME = 'nexus-pos-v' + VERSION;
const CACHE_NAME = 'nexus-pos-v20';
const APP_SHELL = [
'/pos/static/css/tokens.css',

View File

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/accounting.css?v=33"></head>
<link rel="stylesheet" href="/pos/static/css/accounting.css">
</head>
<body>
@@ -227,18 +228,19 @@
<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>
<input type="text" placeholder="Buscar cliente o factura..." />
</div>
<select id="cxc-status-filter" class="select-filter" onchange="Accounting.loadAging()">
<option value="all">Todos los estados</option>
<option value="pending">Vigente</option>
<option value="overdue">Vencida</option>
<option value="partial">Parcial</option>
<option value="ok">Pagada</option>
<select class="select-filter">
<option>Todos los estados</option>
<option>Vigente</option>
<option>Vencida</option>
<option>Parcial</option>
</select>
<select class="select-filter" title="Filtro de sucursal (próximamente)">
<select class="select-filter">
<option>Todas las sucursales</option>
<option>Matriz</option>
<option>Sucursal Norte</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--ghost btn--sm" onclick="window.exportarCuentasPorCobrar()">
<button class="btn btn--ghost btn--sm">
<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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar
</button>
@@ -279,15 +281,13 @@
<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>
<input type="text" placeholder="Buscar proveedor o factura..." />
</div>
<select id="cxp-status-filter" class="select-filter" onchange="Accounting.loadAccountsPayable()">
<option value="all">Todos los estados</option>
<option value="pending">Vigente</option>
<option value="overdue">Vencida</option>
<option value="partial">Parcial</option>
<option value="ok">Pagada</option>
<select class="select-filter">
<option>Todos los estados</option>
<option>Vigente</option>
<option>Vencida</option>
</select>
<div class="toolbar__spacer"></div>
<button id="cxp-register-payment" class="btn btn--primary btn--sm" onclick="Accounting.registerPayablePayment()">
<button class="btn btn--primary btn--sm">
<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>
Registrar Pago
</button>
@@ -424,11 +424,13 @@
=============================================================== -->
<div class="tab-panel" id="panel-cierre">
<div class="toolbar">
<select id="cierre-period-filter" class="select-filter" title="Período a cerrar (próximamente)">
<option>Seleccionar período</option>
<select class="select-filter">
<option>Marzo 2026</option>
<option>Febrero 2026 (cerrado)</option>
<option>Enero 2026 (cerrado)</option>
</select>
<div class="toolbar__spacer"></div>
<button id="cierre-run-btn" class="btn btn--primary" onclick="Accounting.runPeriodClose()">
<button class="btn btn--primary">
<svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Ejecutar Cierre
</button>
@@ -476,7 +478,7 @@
</div>
<div id="entryLines">
<div class="entry-line" style="display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;">
<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select>
<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<button class="btn btn--ghost btn--sm" onclick="this.closest('.entry-line').remove()">&times;</button>
@@ -492,12 +494,12 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/accounting.v9.js?v=33" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/accounting.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>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -7,7 +7,7 @@
<title>Catalogo — Nexus Autoparts POS</title>
<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/pos-ui.css?v=2" />
<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/chat.css" />
@@ -17,7 +17,8 @@
<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/catalog.css"></head>
<link rel="stylesheet" href="/pos/static/css/catalog.css">
</head>
<body>
@@ -123,7 +124,7 @@
</div>
<div class="search-bar" id="searchBar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" aria-label="Buscar productos" />
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
<button type="button" id="btnScanBarcode" title="Escanear codigo de barras" style="background:none;border:none;cursor:pointer;padding:4px 8px;color:var(--color-text-muted);display:flex;align-items:center;" onclick="CatalogApp.startBarcodeScan()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7V5a2 2 0 012-2h2"/><path d="M17 3h2a2 2 0 012 2v2"/><path d="M21 17v2a2 2 0 01-2 2h-2"/><path d="M7 21H5a2 2 0 01-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="7" y1="8" x2="17" y2="8"/><line x1="7" y1="16" x2="17" y2="16"/></svg>
</button>
@@ -194,7 +195,7 @@
<!-- Level title + optional filter -->
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); flex-wrap:wrap;">
<h2 class="level-title" id="levelTitle">Selecciona una marca</h2>
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" aria-label="Filtrar niveles de catalogo" />
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" />
</div>
<!-- Loading spinner -->
@@ -314,17 +315,17 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/catalog.js?v=33" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/catalog.js?v=8" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script src="/pos/static/js/onboarding.js?v=33" defer></script>
<script src="/pos/static/js/onboarding.js?v=2" defer></script>
<script>
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'}).then(function(reg){
@@ -340,6 +341,6 @@
}
</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/brand-catalog.js?v=33" defer></script>
<script src="/pos/static/js/brand-catalog.js?v=10" defer></script>
</body>
</html>

View File

@@ -8,45 +8,14 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<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>
<link rel="stylesheet" href="/pos/static/css/config.css?v=2">
</head>
<body>
@@ -161,7 +130,7 @@
<h1 class="page-header__title">Configuración</h1>
</div>
<div class="page-header__actions">
<button id="btn-save-all" class="btn btn--primary" type="button">
<button class="btn btn--primary">
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Guardar Cambios
</button>
@@ -170,20 +139,11 @@
<!-- 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" data-tab="general">
<div class="settings-section">
<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>
@@ -246,7 +206,7 @@
<!-- ===============================================================
SECTION 2: DATOS DE LA EMPRESA
=============================================================== -->
<div class="settings-section" data-tab="general">
<div class="settings-section">
<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>
@@ -295,88 +255,9 @@
</div>
<!-- ===============================================================
SECTION 3: PERSONALIZACIÓN DE TICKET
SECTION 3: MÓDULOS E INTEGRACIONES
=============================================================== -->
<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>
</div>
<div>
<div class="settings-section__title">Personalización de Ticket</div>
<div class="settings-section__desc">Logo, datos y opciones que aparecen en el ticket de venta</div>
</div>
</div>
<div class="settings-card">
<div class="form-grid">
<div class="form-group form-group--full">
<label class="form-label">Logo del negocio</label>
<input type="file" id="receipt-logo" accept="image/png,image/jpeg,image/jpg,image/webp" onchange="Config.handleReceiptLogo(this)" style="display:none;" />
<div id="receipt-logo-preview" style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;">
<div id="receipt-logo-thumb" style="width:80px;height:80px;border:1px dashed var(--color-border);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--color-surface-2);">
<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>
</div>
<button class="btn btn--secondary btn--sm" onclick="document.getElementById('receipt-logo').click()">Subir imagen</button>
<button class="btn btn--ghost btn--sm" id="receipt-logo-remove" onclick="Config.removeReceiptLogo()" style="display:none;">Quitar</button>
</div>
<div class="form-hint">Recomendado: PNG/JPG con fondo blanco o transparente, máx. 300x150 px.</div>
</div>
<div class="form-group">
<label class="form-label">Nombre en ticket</label>
<input class="form-input" id="receipt-store-name" type="text" placeholder="Ej: Refacciones El Toro" />
</div>
<div class="form-group">
<label class="form-label">Slogan / Línea debajo del nombre</label>
<input class="form-input" id="receipt-tagline" type="text" placeholder="Ej: Tu conexion con las refacciones" />
</div>
<div class="form-group">
<label class="form-label">RFC en ticket</label>
<input class="form-input" id="receipt-rfc" type="text" placeholder="Ej: RET260101ABC" maxlength="13" style="text-transform:uppercase;" />
</div>
<div class="form-group form-group--full">
<label class="form-label">Dirección en ticket</label>
<input class="form-input" id="receipt-address" type="text" placeholder="Calle, Número, Colonia, CP, Ciudad" />
</div>
<div class="form-group">
<label class="form-label">Teléfono en ticket</label>
<input class="form-input" id="receipt-phone" type="tel" placeholder="Ej: 664-123-4567" />
</div>
<div class="form-group">
<label class="form-label">Ancho del papel</label>
<select class="form-select" id="receipt-paper-width">
<option value="58">58 mm (térmica pequeña)</option>
<option value="80" selected>80 mm (térmica estándar)</option>
</select>
</div>
<div class="form-group form-group--full">
<label class="form-label">Mensaje de agradecimiento</label>
<input class="form-input" id="receipt-thanks" type="text" placeholder="Gracias por su compra!" />
</div>
<div class="form-group form-group--full">
<label class="form-label">Pie de página adicional</label>
<input class="form-input" id="receipt-footer" type="text" placeholder="Ej: Conserve su ticket como comprobante." />
</div>
</div>
<div class="form-grid" style="margin-top:var(--space-4);">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-logo" style="width:auto;" checked /> Mostrar logo</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-rfc" style="width:auto;" checked /> Mostrar RFC</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-address" style="width:auto;" /> Mostrar dirección</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-phone" style="width:auto;" /> Mostrar teléfono</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-iva" style="width:auto;" checked /> Mostrar desglose de IVA</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-payment" style="width:auto;" checked /> Mostrar detalle de pago</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-employee" style="width:auto;" /> Mostrar nombre del empleado</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveReceiptConfig()">Guardar ticket</button>
</div>
</div>
</div>
<!-- ===============================================================
SECTION 4: MÓDULOS E INTEGRACIONES
=============================================================== -->
<div class="settings-section" data-tab="general">
<div class="settings-section">
<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>
@@ -428,26 +309,6 @@
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Taller / Servicio</span>
<span class="toggle-row__desc">Mostrar el módulo de órdenes de servicio y taller</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-workshop" checked />
<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>
@@ -457,13 +318,13 @@
<!-- ===============================================================
SECTION 4: USUARIOS Y PERMISOS
=============================================================== -->
<div class="settings-section" data-tab="employees">
<div class="settings-section">
<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">Empleados</div>
<div class="settings-section__title">Usuarios y Permisos</div>
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
</div>
</div>
@@ -500,7 +361,7 @@
<!-- ===============================================================
SECTION 4: IMPRESORAS
=============================================================== -->
<div class="settings-section" data-tab="general">
<div class="settings-section">
<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>
@@ -526,7 +387,7 @@
<!-- ===============================================================
SECTION 5: SUCURSALES
=============================================================== -->
<div class="settings-section" data-tab="fiscal">
<div class="settings-section">
<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>
@@ -545,7 +406,7 @@
<!-- ===============================================================
SECTION 6: PARÁMETROS FISCALES
=============================================================== -->
<div class="settings-section" data-tab="fiscal">
<div class="settings-section">
<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>
@@ -681,7 +542,7 @@
<!-- ===============================================================
SECTION 7: PREFERENCIAS DEL SISTEMA
=============================================================== -->
<div class="settings-section" data-tab="general">
<div class="settings-section">
<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>
@@ -753,33 +614,13 @@
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Permitir ventas en $0</span>
<span class="toggle-row__desc">Permite vender artículos con precio unitario o total de línea igual a $0</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-allow-zero-price" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Permitir venta sin stock</span>
<span class="toggle-row__desc">Permite vender aunque no haya existencias suficientes en la sucursal (stock negativo)</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-allow-negative-stock" />
<span class="toggle__slider"></span>
</label>
</div>
</div>
</div>
<!-- ===============================================================
SECTION 8: VEHICLE COMPATIBILITY SOURCE
=============================================================== -->
<div class="settings-section" data-tab="catalog">
<div class="settings-section">
<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>
@@ -812,7 +653,7 @@
<!-- ===============================================================
SECTION 9: MARCAS DE PARTES PERMITIDAS
=============================================================== -->
<div class="settings-section" data-tab="catalog">
<div class="settings-section">
<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>
@@ -873,64 +714,6 @@
</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(); Config.renderWorkshopPermissions();">
<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>
<!-- Permission tabs -->
<div class="cfg-tabs" style="margin-top:var(--space-4);position:static;">
<button type="button" class="cfg-tab-btn active" id="tab-perm-modules" onclick="Config.switchPermTab('modules')">Módulos</button>
<button type="button" class="cfg-tab-btn" id="tab-perm-workshop" onclick="Config.switchPermTab('workshop')">Taller</button>
</div>
<div id="perm-panel-modules" class="perm-panel active" style="margin-top:var(--space-4);">
<div id="role-permissions-container">
<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 id="perm-panel-workshop" class="perm-panel" style="margin-top:var(--space-4);display:none;">
<div id="workshop-permissions-container">
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos de Taller.</p>
</div>
<div style="margin-top:var(--space-4);">
<button class="btn btn--primary" id="btn-save-workshop-permissions" onclick="Config.saveWorkshopPermissions()">Guardar permisos de Taller</button>
<span id="workshop-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
</div>
</div>
</div>
</div>
</div><!-- /content-scroll -->
</main>
</div><!-- /app-shell -->
@@ -1039,11 +822,8 @@
<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>
<option value="mechanic">Mecanico</option>
</select>
</div>
<div class="form-group">
@@ -1069,13 +849,13 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/config.js?v=41" defer></script>
<script src="/pos/static/js/config.js?v=3" 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

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/customers.css?v=34"></head>
<link rel="stylesheet" href="/pos/static/css/customers.css">
</head>
<body>
@@ -297,15 +298,15 @@
<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
</svg>
</span>
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" aria-label="Buscar clientes" />
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" />
</div>
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter" aria-label="Filtrar por tipo de cliente">
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter">
<option value="">Todos los tipos</option>
<option value="Taller">Taller</option>
<option value="Mostrador">Mostrador</option>
<option value="Mayoreo">Mayoreo</option>
</select>
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter" aria-label="Filtrar por estado de cliente">
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter">
<option value="">Todos los estados</option>
<option value="Activo">Activo</option>
<option value="Inactivo">Inactivo</option>
@@ -341,7 +342,15 @@
<!-- Pagination -->
<div class="table-footer">
<div class="table-footer__info" id="tableInfo"></div>
<div class="pagination" id="customersPagination"></div>
<div class="pagination">
<button class="page-btn">&#8249;</button>
<button class="page-btn active">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<span style="color:var(--color-text-muted);font-size:var(--text-caption);padding:0 4px;"></span>
<button class="page-btn">86</button>
<button class="page-btn">&#8250;</button>
</div>
</div>
</div>
@@ -366,12 +375,12 @@
<!-- Header -->
<div class="detail-header">
<div class="customer-avatar" id="detailAvatar"></div>
<div class="customer-avatar" id="detailAvatar">MA</div>
<div class="detail-header__info">
<div class="detail-header__name" id="detailName"></div>
<div class="detail-header__rfc" id="detailRFC"></div>
<div class="detail-header__name" id="detailName">MIGUEL ÁNGEL TORRES</div>
<div class="detail-header__rfc" id="detailRFC">TOAM820115HDF</div>
<div class="detail-header__meta">
<span class="tipo-chip tipo-chip--taller" id="detailTipo"></span>
<span class="tipo-chip tipo-chip--taller" id="detailTipo">Taller</span>
<span class="badge badge--active" id="detailStatus"><span class="badge-dot"></span>Activo</span>
</div>
</div>
@@ -392,7 +401,7 @@
</div>
<div class="info-row info-row--full">
<span class="info-label">Dirección</span>
<span class="info-value" id="detailAddress"></span>
<span class="info-value" id="detailAddress">Av. Insurgentes Sur 1602, Col. Crédito Constructor, CDMX</span>
</div>
<div class="info-row">
<span class="info-label">CP</span>
@@ -451,14 +460,6 @@
</div>
</div>
<!-- Vehicles -->
<div class="detail-section">
<div class="detail-section__title">Veh&iacute;culos</div>
<div id="detailVehicles" style="display:flex;flex-direction:column;gap:var(--space-2);">
<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>
</div>
</div>
<!-- Quick Actions -->
<div class="detail-section">
<div class="detail-section__title">Acciones Rápidas</div>
@@ -497,15 +498,6 @@
</span>
Historial
</button>
<button class="action-btn action-btn--danger" id="btnDeleteCustomer" onclick="deleteCustomer()">
<span class="action-btn__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<polyline points="3 6 5 6 13 6"/><path d="M5 6v7a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V6"/>
<line x1="7" y1="3" x2="9" y2="3"/><line x1="4" y1="3" x2="14" y2="3"/>
</svg>
</span>
Eliminar
</button>
</div>
</div>
@@ -650,13 +642,13 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/customers.js?v=35" defer></script>
<script src="/pos/static/js/customers.js?v=2" 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

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=33"></head>
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=3">
</head>
<body>
@@ -158,16 +159,6 @@
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">
@@ -520,40 +511,6 @@
</div><!-- end alerts-grid -->
</section>
<!-- =================================================================
CRÉDITOS POR VENCER
================================================================= -->
<section>
<div class="table-card">
<div class="table-header">
<div>
<div class="section-title">Créditos por Cobrar</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:2px;" id="credit-alerts-meta">Vencidos: -- / Por vencer: --</div>
</div>
<a href="/pos/accounting" class="section-action" style="text-decoration:none;color:inherit;">Ir a contabilidad &rarr;</a>
</div>
<div class="table-wrap themed-scrollbar">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Folio</th>
<th>Vencimiento</th>
<th>Días</th>
<th class="align-right">Saldo</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="credit-alerts-tbody">
<tr><td colspan="6"><div class="skeleton skeleton--text" style="width:100%;"></div></td></tr>
<tr><td colspan="6"><div class="skeleton skeleton--text" style="width:80%;"></div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- =================================================================
RECENT SALES TABLE
================================================================= -->
@@ -605,15 +562,15 @@
<script src="/pos/static/js/chart.umd.min.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" 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/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=3" defer></script>
<script src="/pos/static/js/dashboard.js?v=7" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js?v=34',{scope:'/pos/'});}</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

@@ -8,7 +8,7 @@
<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/pos-ui.css?v=2" />
<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/onboarding.css" />
@@ -17,7 +17,8 @@
<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/diagrams.css"></head>
<link rel="stylesheet" href="/pos/static/css/diagrams.css">
</head>
<body>
<div class="app-shell">
@@ -148,12 +149,12 @@
</main>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" 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

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/fleet.css"></head>
<link rel="stylesheet" href="/pos/static/css/fleet.css">
</head>
<body>
<div class="page-shell">
@@ -302,11 +303,11 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" 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

@@ -34,7 +34,8 @@
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination span { font-size: 13px; color: #4b5563; }
.loading { text-align: center; padding: 40px; color: #6b7280; }
</style></head>
</style>
</head>
<body>
<div class="header">
<h1>📊 Ventas Históricas - Atlas</h1>

View File

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34"></head>
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=8">
</head>
<body>
@@ -192,7 +193,7 @@
<h1 class="page-header__title">Inventario</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<button class="btn btn--ghost" 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 +205,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" id="btnHeaderNewProduct" onclick="showCreateModal()">
<button class="btn btn--primary" 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 +337,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" id="btnStockNewProduct" onclick="showCreateModal()">
<button class="btn btn--primary btn--sm" 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>
@@ -710,48 +711,42 @@
<!-- ===== MODALS ===== -->
<!-- Create/Edit Item Modal -->
<!-- Create Item Modal -->
<div class="inv-modal-overlay" id="createModal">
<div class="inv-modal">
<div class="inv-modal__header">
<h3 id="createModalTitle">Nuevo Producto</h3>
<h3>Nuevo Producto</h3>
<button class="inv-modal__close" onclick="closeCreateModal()">&times;</button>
</div>
<div class="inv-modal__body">
<input type="hidden" id="editItemId" />
<div class="inv-form-grid">
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
<div class="inv-field"><label>Categoría</label>
<select class="select-filter" id="newCategory" style="width:100%;">
<option value="">Sin categoría</option>
<select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
<option value="">Selecciona categoría</option>
</select>
</div>
<div class="inv-field"><label>Subcategoría</label>
<select class="select-filter" id="newSubcategory" style="width:100%;" disabled>
<option value="">Selecciona categoría primero</option>
</select>
</div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div>
<div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div>
<div class="inv-field"><label>Unidad</label><input type="text" id="newUnit" placeholder="pza, kit, lt..." /></div>
<div class="inv-field price-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field price-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
<div class="inv-field" id="initialStockField"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Máximo</label><input type="number" id="newMaxStock" placeholder="0" /></div>
<div class="inv-field"><label>Impuesto (%)</label><input type="number" id="newTaxRate" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
<div class="inv-field"><label>Estado</label>
<select class="select-filter" id="newIsActive" style="width:100%;">
<option value="true">Activo</option>
<option value="false">Inactivo</option>
</select>
</div>
<div class="inv-field inv-field--full"><label>Descripción</label><textarea id="newDescription" rows="2" placeholder="Descripción del producto"></textarea></div>
</div>
<div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button>
<button class="btn btn--primary" id="createModalBtn" onclick="createItem()">Crear Producto</button>
<button class="btn btn--primary" onclick="createItem()">Crear Producto</button>
</div>
</div>
</div>
@@ -1018,9 +1013,6 @@
<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);" />
@@ -1042,8 +1034,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, 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>
<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>
</div>
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
</div>
@@ -1061,13 +1053,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=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
<script src="/pos/static/js/inventory.js?v=38" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js?v=2" defer></script>
<script src="/pos/static/js/inventory.js?v=18" 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

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/invoicing.css"></head>
<link rel="stylesheet" href="/pos/static/css/invoicing.css">
</head>
<body>
@@ -338,14 +339,25 @@
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input id="facturas-search" type="text" placeholder="Buscar folio, RFC, cliente…" oninput="Invoicing.filterFacturas()" aria-label="Buscar facturas" />
<input type="text" placeholder="Buscar folio, RFC, cliente…" />
</div>
<select id="facturas-status-filter" class="select-filter" aria-label="Filtrar por estatus" onchange="Invoicing.loadFacturas()">
<div class="date-range">
<svg viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
01 Mar 2026 — 31 Mar 2026
</div>
<select class="select-filter" aria-label="Filtrar por estatus">
<option value="">Todas</option>
<option value="stamped">Timbradas</option>
<option value="pending">Pendientes</option>
<option value="cancelled">Canceladas</option>
<option value="timbrada">Timbradas</option>
<option value="pendiente">Pendientes</option>
<option value="cancelada">Canceladas</option>
<option value="ppd">PPD</option>
</select>
<div class="toolbar__spacer"></div>
@@ -360,7 +372,7 @@
Factura Global
</button>
<button id="facturas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportFacturasCSV()">
<button class="btn btn--ghost">
<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="7 10 12 15 17 10"/>
@@ -410,10 +422,10 @@
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" placeholder="Buscar nota de crédito…" aria-label="Buscar notas de credito" />
<input type="text" placeholder="Buscar nota de crédito…" />
</div>
<div class="toolbar__spacer"></div>
<button id="notas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportNotasCSV()">
<button class="btn btn--ghost">
<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="7 10 12 15 17 10"/>
@@ -421,7 +433,7 @@
</svg>
Exportar
</button>
<button id="notas-new" class="btn btn--primary" onclick="Invoicing.newCreditNote()">
<button class="btn btn--primary">
<svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
@@ -466,9 +478,9 @@
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" placeholder="Buscar complemento de pago…" aria-label="Buscar complementos de pago" />
<input type="text" placeholder="Buscar complemento de pago…" />
</div>
<select id="complementos-method-filter" class="select-filter" aria-label="Método de pago" onchange="Invoicing.loadComplementos()">
<select class="select-filter" aria-label="Método de pago">
<option value="">Todos los métodos</option>
<option value="03">03 Transferencia electrónica</option>
<option value="04">04 Tarjeta de crédito</option>
@@ -477,7 +489,7 @@
<option value="99">99 Por definir</option>
</select>
<div class="toolbar__spacer"></div>
<button id="complementos-new" class="btn btn--primary" onclick="Invoicing.newPaymentComplement()">
<button class="btn btn--primary">
<svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
@@ -507,7 +519,17 @@
<div class="table-footer">
<span></span>
<div class="pagination" id="complementos-pagination"></div>
<div class="pagination">
<button class="page-btn" aria-label="Anterior">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="page-btn is-active">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<button class="page-btn" aria-label="Siguiente">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
</div>
</div><!-- /panel-complementos -->
@@ -757,47 +779,11 @@
<span class="form-hint">Régimen del emisor según el SAT</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="direccion-fiscal">Calle</label>
<input class="form-input" id="direccion-fiscal" type="text" value="" placeholder="Ej: Av. Insurgentes Sur" />
</div>
<div class="form-field">
<label class="form-label" for="numero-exterior">Número Exterior</label>
<input class="form-input" id="numero-exterior" type="text" value="" placeholder="Ej: 123" />
</div>
<div class="form-field">
<label class="form-label" for="numero-interior">Número Interior</label>
<input class="form-input" id="numero-interior" type="text" value="" placeholder="Ej: 4B" />
</div>
<div class="form-field">
<label class="form-label" for="colonia-fiscal">Colonia</label>
<input class="form-input" id="colonia-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="ciudad-fiscal">Ciudad</label>
<input class="form-input" id="ciudad-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="municipio-fiscal">Municipio / Alcaldía</label>
<input class="form-input" id="municipio-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="estado-fiscal">Estado</label>
<input class="form-input" id="estado-fiscal" type="text" value="" />
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);align-items:center;">
<span id="emisor-save-status" style="font-size:var(--text-caption);color:var(--color-text-muted);"></span>
<button class="btn btn--ghost" type="button" onclick="Invoicing.loadEmisorData()">Cancelar</button>
<button class="btn btn--primary" type="button" onclick="Invoicing.saveEmisorData()">
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button class="btn btn--ghost">Cancelar</button>
<button class="btn btn--primary">
<svg viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
@@ -902,44 +888,6 @@
</div>
</div>
<!-- CARTA MANIFIESTO -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
<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"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span class="config-section__title">Carta Manifiesto (SAT)</span>
</div>
<div class="config-section__body" id="manifiesto-panel">
<p style="color:var(--color-text-muted); margin-bottom:var(--space-3);">
Firma la carta manifiesto con tu <strong>FIEL</strong> (e.firma) para autorizar a Facturapi a timbrar CFDI ante el SAT.
Si no la ves, usa el botón para abrirla en una pestaña nueva.
</p>
<div style="border:1px solid var(--color-border); border-radius:var(--radius-md); overflow:hidden; background:var(--color-bg-base);">
<iframe
id="manifiesto-iframe"
src="https://www.facturapi.io/embedded/manifiesto"
title="Firma de Carta Manifiesto"
style="width:100%; height:720px; border:0; display:block;"
loading="lazy"
allow="fullscreen"
></iframe>
</div>
<div style="margin-top:var(--space-3); display:flex; gap:var(--space-3); justify-content:flex-end;">
<a class="btn btn--ghost btn--sm" href="https://www.facturapi.io/manifiesto" target="_blank" rel="noopener">
Abrir portal de firma
</a>
<button type="button" class="btn btn--secondary btn--sm" onclick="Invoicing.reloadManifiesto()">
Recargar firma
</button>
</div>
</div>
</div>
<!-- CONFIGURACIÓN DE SERIES — full width -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
@@ -1137,12 +1085,12 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/invoicing.js?v=35" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/invoicing.js?v=3" 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

@@ -155,7 +155,6 @@
------------------------------------------------------------------ */
const state = {
selectedUser: null,
selectedUserId: null,
pin: [],
maxPinLength: 6,
};
@@ -326,21 +325,18 @@
localStorage.setItem('pos_device_id', deviceId);
}
// Optional auto-redirect disabled to allow switching users on shared devices.
// Users with a valid session can navigate directly to /pos/catalog or /pos/workshop.
// Auto-redirect if already logged in with valid token
(function checkExistingSession() {
var token = localStorage.getItem('pos_token');
if (token && tenantId) {
try {
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now() + 30000) {
localStorage.removeItem('pos_token');
localStorage.removeItem('pos_employee');
if (payload.exp * 1000 > Date.now() + 30000) {
window.location.href = '/pos/catalog';
return;
}
} catch(e) {
} catch(e) {}
localStorage.removeItem('pos_token');
localStorage.removeItem('pos_employee');
}
}
})();
@@ -355,7 +351,6 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenant_id: parseInt(tenantId),
employee_id: state.selectedUserId,
pin: enteredPin,
device_id: deviceId
})
@@ -379,19 +374,13 @@
localStorage.setItem('pos_token', result.data.token);
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';
btnLogin.innerHTML = '<span class="btn-login__icon" aria-hidden="true">✓</span> Bienvenido, ' + result.data.employee.name;
btnLogin.style.background = 'var(--color-success)';
showToast('¡Acceso concedido! Redirigiendo…');
setTimeout(function() {
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';
}
}, 1000);
})
.catch(function() {
@@ -409,7 +398,6 @@
function resetLoginState() {
state.selectedUser = null;
state.selectedUserId = null;
state.pin = [];
userBtns.forEach(b => {
@@ -499,7 +487,6 @@
employees.forEach(function(emp) {
var btn = document.createElement('button');
btn.className = 'user-avatar-btn';
btn.setAttribute('data-id', emp.id);
btn.setAttribute('data-user', emp.initials);
btn.setAttribute('data-name', emp.name);
btn.setAttribute('data-role', emp.role_label);
@@ -519,7 +506,6 @@
btn.classList.add('selected');
btn.setAttribute('aria-checked', 'true');
state.selectedUser = emp.initials;
state.selectedUserId = emp.id;
state.pin = [];
enablePinPad();
updatePinDisplay();

View File

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/marketplace.css"></head>
<link rel="stylesheet" href="/pos/static/css/marketplace.css">
</head>
<body>
<header class="page-header">

View File

@@ -8,7 +8,7 @@
<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/pos-ui.css?v=2" />
<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" />
@@ -28,7 +28,8 @@
.meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); }
.meli-config-row label { display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px; }
.meli-config-row input, .meli-config-row select { padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface-0);color:var(--color-text-primary); }
</style></head>
</style>
</head>
<body>
@@ -342,12 +343,12 @@
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=33" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=4" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>

View File

@@ -7,7 +7,7 @@
<title>Nexus Autoparts — Punto de Venta</title>
<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/pos-ui.css?v=2" />
<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/chat.css" />
@@ -16,7 +16,8 @@
<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=35"></head>
<link rel="stylesheet" href="/pos/static/css/pos.css?v=4">
</head>
<body class="pos-shell" id="appBody">
@@ -85,7 +86,7 @@
<div class="cart-header">
<div class="cart-header__top">
<div class="cart-header__sale-id">Venta Activa</div>
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" aria-label="Mostrar costo y margen" style="display:none;">C/M</button>
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" style="display:none;">C/M</button>
<span class="cart-header__status">Activa</span>
</div>
@@ -198,31 +199,15 @@
</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>
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
<button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button>
@@ -254,9 +239,6 @@
<div class="fkey" onclick="POS.openDrawer()" title="Abrir cajon">
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
</div>
<div class="fkey" onclick="POS.createServiceOrder()" title="Orden de servicio">
<span class="fkey-key">F7</span><span class="fkey-label">Orden Taller</span>
</div>
<div class="fkey-sep"></div>
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
@@ -308,15 +290,6 @@
<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 -->
@@ -352,26 +325,26 @@
<div class="tab-content" id="mixedPayment">
<div class="mixed-row" style="margin-bottom:var(--space-3);">
<div class="form-group">
<label class="form-label" id="label-method-1">Método 1</label>
<select id="mixed-method-1" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 1">
<label class="form-label">Metodo 1</label>
<select class="form-input" style="margin-bottom:var(--space-2);">
<option value="efectivo">Efectivo</option>
<option value="tarjeta">Tarjeta</option>
<option value="transferencia">Transferencia</option>
</select>
<input id="mixed-amount-1" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 1" />
<input id="mixed-ref-1" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 1" />
<input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
<input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
</div>
</div>
<div class="mixed-row" style="margin-bottom:var(--space-3);">
<div class="form-group">
<label class="form-label" id="label-method-2">Método 2</label>
<select id="mixed-method-2" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 2">
<label class="form-label">Metodo 2</label>
<select class="form-input" style="margin-bottom:var(--space-2);">
<option value="tarjeta">Tarjeta</option>
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
</select>
<input id="mixed-amount-2" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 2" />
<input id="mixed-ref-2" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 2" />
<input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
<input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
</div>
</div>
<div class="split-remaining">
@@ -380,35 +353,6 @@
</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" />
@@ -424,68 +368,6 @@
</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
================================================================ -->
@@ -602,10 +484,10 @@
<div style="margin-top:var(--space-4);border-top:1px solid var(--color-border);padding-top:var(--space-4);">
<div class="form-label" style="margin-bottom:var(--space-3);">Vehiculo (opcional)</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);">
<div class="form-group"><label class="form-label">Marca</label><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" aria-label="Marca del vehiculo" /></div>
<div class="form-group"><label class="form-label">Modelo</label><input type="text" class="form-input" id="ncVehModel" placeholder="Modelo" aria-label="Modelo del vehiculo" /></div>
<div class="form-group"><label class="form-label">Año</label><input type="text" class="form-input" id="ncVehYear" placeholder="Ano" aria-label="Ano del vehiculo" /></div>
<div class="form-group"><label class="form-label">Placas</label><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" aria-label="Placas del vehiculo" /></div>
<div class="form-group"><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" /></div>
<div class="form-group"><input type="text" class="form-input" id="ncVehModel" placeholder="Modelo" /></div>
<div class="form-group"><input type="text" class="form-input" id="ncVehYear" placeholder="Ano" /></div>
<div class="form-group"><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" /></div>
</div>
</div>
</div>
@@ -679,61 +561,17 @@
================================================================ -->
<div class="toast-container" id="toastContainer" aria-live="assertive" aria-atomic="true"></div>
<!-- ================================================================
SERVICE ORDER MODAL
================================================================ -->
<div class="modal-overlay" id="serviceOrderModal">
<div class="modal-pago" style="width:480px;">
<div class="modal-header">
<h3>Orden de servicio desde POS</h3>
<button class="modal-close" onclick="POS.closeServiceOrderModal()">&#x2715;</button>
</div>
<div style="padding:var(--space-6);">
<div class="form-group">
<label class="form-label">Cliente</label>
<input type="text" class="form-input" id="soCustomer" readonly />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);">
<div class="form-group">
<label class="form-label">Veh&iacute;culo</label>
<input type="text" class="form-input" id="soVehicle" placeholder="Marca / Modelo / Placas" />
</div>
<div class="form-group">
<label class="form-label">V&iacute;a de entrega</label>
<select class="form-input" id="soDelivery">
<option value=""></option>
<option value="pickup">Pasa cliente</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Notas de recepci&oacute;n</label>
<textarea class="form-input" id="soNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div>
<label style="display:flex;align-items:center;gap:var(--space-2);color:var(--color-text-primary);font-size:var(--text-body-sm);">
<input type="checkbox" id="soDirect" /> Orden directa
</label>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="POS.closeServiceOrderModal()">Cancelar</button>
<button class="btn btn-primary" onclick="POS.confirmServiceOrder()">Crear orden e imprimir</button>
</div>
</div>
</div>
<!-- ================================================================
JAVASCRIPT
================================================================ -->
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/i18n.js" 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=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" 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=42" defer></script>
<script src="/pos/static/js/pos.js?v=7" defer></script>
<script>
// Cancel sale button wiring

View File

@@ -8,16 +8,17 @@
<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/pos-ui.css?v=2" />
<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/quotations.css"></head>
<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=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<div class="page">
<h1 class="page-title">Cotizaciones</h1>

View File

@@ -1,430 +0,0 @@
<!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=8" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" 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 => `
<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>
</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

@@ -8,14 +8,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/reports.css"></head>
<link rel="stylesheet" href="/pos/static/css/reports.css">
</head>
<body>
@@ -229,14 +230,6 @@
</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>
<!-- ==================================================================
@@ -363,38 +356,6 @@
<!-- 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 -->
@@ -405,12 +366,12 @@
</div>
<!-- End app-shell -->
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/reports.js?v=35" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/reports.js?v=3" 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

@@ -7,7 +7,7 @@
<title>Catalogo de Proveedores — Nexus Autoparts POS</title>
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
@@ -49,7 +49,8 @@
.sc-interchange-list { display:flex; flex-wrap:wrap; gap:var(--space-2); }
.sc-interchange-chip { background:var(--color-surface-2); border:1px solid var(--color-border); border-radius:var(--radius-full); padding:2px 10px; font-size:var(--text-caption); }
.sc-close { background:none; border:none; font-size:20px; color:var(--color-text-muted); cursor:pointer; }
</style></head>
</style>
</head>
<body>
<!-- Theme bar -->
@@ -126,9 +127,9 @@
</div>
</div>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/supplier_catalog.js?v=33" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/supplier_catalog.js?v=2" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>

View File

@@ -11,14 +11,15 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<link rel="stylesheet" href="/pos/static/css/whatsapp.css"></head>
<link rel="stylesheet" href="/pos/static/css/whatsapp.css">
</head>
<body>
<div class="page-shell">
@@ -131,12 +132,12 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
</script>
<!-- Sidebar -->
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/whatsapp2.js?v=5" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>

View File

@@ -8,19 +8,14 @@
<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/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<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" />
<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); }
.so-search-result:hover { background: var(--color-bg-secondary); }
</style>
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=2">
</head>
<body>
@@ -33,13 +28,13 @@
<div class="page-header">
<div class="page-header__title-group">
<span class="page-header__eyebrow">Operaci&oacute;n &middot; Taller</span>
<span class="page-header__eyebrow">Operación · Taller</span>
<h1 class="page-header__title">Taller</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" id="btnCatalog" onclick="Workshop.openCatalogModal()">
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
Cat&aacute;logo de servicios
Catálogo de servicios
</button>
<button class="btn btn--primary" id="btnNewOrder" onclick="Workshop.openNewOrderModal()">
<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>
@@ -55,7 +50,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">Por revisar</div>
<div class="summary-card__label">Recibidos</div>
<div class="summary-card__value" id="statReceived">--</div>
</div>
</div>
@@ -64,7 +59,7 @@
<svg viewBox="0 0 24 24"><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"/></svg>
</div>
<div class="summary-card__body">
<div class="summary-card__label">En reparaci&oacute;n</div>
<div class="summary-card__label">En reparación</div>
<div class="summary-card__value" id="statRepair">--</div>
</div>
</div>
@@ -73,7 +68,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">Por entregar</div>
<div class="summary-card__label">Listos</div>
<div class="summary-card__value" id="statReady">--</div>
</div>
</div>
@@ -88,81 +83,8 @@
</div>
</div>
<!-- Toolbar: filters + view switch -->
<div class="workshop-toolbar">
<div class="workshop-filters">
<select class="form-input" id="filterBranch">
<option value="">Todas las sucursales</option>
</select>
<select class="form-input" id="filterStatus">
<option value="">Todos los estatus</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">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
</select>
<label class="toolbar-toggle">
<input type="checkbox" id="filterDirect" />
<span>Orden directa</span>
</label>
<input class="form-input" id="filterSearch" placeholder="Buscar orden, cliente o placas" />
</div>
<div class="view-switch" id="viewSwitch">
<button class="view-switch__btn is-active" data-view="list" onclick="Workshop.setView('list')">
<svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
Lista
</button>
<button class="view-switch__btn" data-view="kanban" onclick="Workshop.setView('kanban')">
<svg viewBox="0 0 24 24"><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>
Kanban
</button>
</div>
</div>
<!-- List view -->
<div class="workshop-list" id="listView">
<div class="table-wrapper">
<table class="data-table workshop-table">
<thead>
<tr>
<th>Sucursal</th>
<th>Orden</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 restricted-hide">Total</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="listBody">
<tr><td colspan="8" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
</tbody>
</table>
</div>
<div class="list-pagination" id="listPagination"></div>
</div>
<!-- Kanban board -->
<div class="kanban-board" id="kanbanBoard" style="display:none;">
<div class="kanban-board" id="kanbanBoard">
<!-- Columns injected by JS -->
</div>
</main>
@@ -193,72 +115,36 @@
</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);">
<select class="form-input" id="noCustomer" required style="flex:1;"></select>
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewCustomerModalFromNewOrder()">+ Nuevo</button>
</div>
<select class="form-input" id="noCustomer" required></select>
</div>
<div class="form-field">
<label class="form-label" for="noWorkshopName">Taller</label>
<input class="form-input" id="noWorkshopName" placeholder="Nombre del taller" />
<label class="form-label" for="noVehicle">Vehículo</label>
<select class="form-input" id="noVehicle"></select>
</div>
<div class="form-field">
<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="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">Mostrador</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
</div>
<div class="form-field" id="courierField" style="display:none;">
<label class="form-label" for="noCourier">Motociclista</label>
<select class="form-input" id="noCourier"></select>
</div>
<div class="form-field">
<label class="form-label" for="noMechanic">Mec&aacute;nico asignado (usuario)</label>
<label class="form-label" for="noMechanic">Mecánico asignado</label>
<select class="form-input" id="noMechanic"></select>
</div>
<div class="form-field">
<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" />
<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>
</div>
<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" />
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
</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</span>
</label>
<label class="form-label" for="noMileage">Kilometraje</label>
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="noNotes">Observaciones</label>
<label class="form-label" for="noNotes">Notas de recepción</label>
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div>
</form>
@@ -270,184 +156,11 @@
</div>
</div>
<!-- Edit order modal -->
<div class="modal-overlay" id="editOrderModal">
<div class="modal modal--md">
<div class="modal__header">
<h2 class="modal__title">Editar orden</h2>
<button class="modal__close" onclick="Workshop.closeEditOrderModal()">&times;</button>
</div>
<div class="modal__body">
<form class="form-grid" id="editOrderForm" onsubmit="return false;">
<div class="form-field form-field--span2" style="position:relative;">
<label class="form-label" for="eoCustomerSearch">Cliente</label>
<div style="display:flex;gap:var(--space-2);">
<div style="position:relative;flex:1;">
<input class="form-input" id="eoCustomerSearch" autocomplete="off" placeholder="Buscar cliente..." oninput="Workshop.searchCustomersForSO()" />
<div id="eoCustomerResults" 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.openNewCustomerModal('edit')">+ Nuevo</button>
</div>
<input type="hidden" id="eoCustomerId" />
</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="eoWorkshopName">Taller</label>
<input class="form-input" id="eoWorkshopName" placeholder="Nombre del taller" />
</div>
<div class="form-field">
<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="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>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeEditOrderModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.saveOrderChanges()">Guardar cambios</button>
</div>
</div>
</div>
<!-- New vehicle modal -->
<div class="modal-overlay" id="newVehicleModal" style="z-index:9001;">
<div class="modal modal--sm">
<div class="modal__header">
<h2 class="modal__title">Nuevo veh&iacute;culo</h2>
<button class="modal__close" onclick="Workshop.closeNewVehicleModal()">&times;</button>
</div>
<div class="modal__body">
<form class="form-grid" id="newVehicleForm" onsubmit="return false;">
<div class="form-field">
<label class="form-label" for="nvPlate">Placa</label>
<input class="form-input" id="nvPlate" placeholder="Ej. ABC-123 (opcional)" />
</div>
<div class="form-field">
<label class="form-label" for="nvMake">Marca *</label>
<input class="form-input" id="nvMake" placeholder="Ej. Nissan" />
</div>
<div class="form-field">
<label class="form-label" for="nvModel">Modelo *</label>
<input class="form-input" id="nvModel" placeholder="Ej. Versa" />
</div>
<div class="form-field">
<label class="form-label" for="nvYear">A&ntilde;o</label>
<input class="form-input" id="nvYear" type="number" placeholder="2020" />
</div>
<div class="form-field">
<label class="form-label" for="nvColor">Color</label>
<input class="form-input" id="nvColor" placeholder="Ej. Blanco" />
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="nvCustomerName">Cliente *</label>
<input type="hidden" id="nvCustomerId" />
<input class="form-input" id="nvCustomerName" readonly placeholder="Selecciona un cliente en la orden" />
</div>
</form>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeNewVehicleModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.saveNewVehicle()">Guardar veh&iacute;culo</button>
</div>
</div>
</div>
<!-- New customer modal -->
<div class="modal-overlay" id="newCustomerModal" style="z-index:9001;">
<div class="modal modal--sm">
<div class="modal__header">
<h2 class="modal__title">Nuevo cliente</h2>
<button class="modal__close" onclick="Workshop.closeNewCustomerModal()">&times;</button>
</div>
<div class="modal__body">
<form class="form-grid" id="newCustomerForm" onsubmit="return false;">
<div class="form-field form-field--span2">
<label class="form-label" for="ncName">Nombre *</label>
<input class="form-input" id="ncName" placeholder="Nombre completo" />
</div>
<div class="form-field">
<label class="form-label" for="ncPhone">Tel&eacute;fono</label>
<input class="form-input" id="ncPhone" placeholder="3312345678" />
</div>
<div class="form-field">
<label class="form-label" for="ncEmail">Correo</label>
<input class="form-input" id="ncEmail" placeholder="cliente@ejemplo.com" />
</div>
<div class="form-field">
<label class="form-label" for="ncRfc">RFC</label>
<input class="form-input" id="ncRfc" placeholder="XAXX010101000" />
</div>
<div class="form-field">
<label class="form-label" for="ncPriceTier">Lista de precios</label>
<select class="form-input" id="ncPriceTier">
<option value="1">Mostrador</option>
<option value="2">Taller</option>
<option value="3">Mayoreo</option>
</select>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="ncAddress">Direcci&oacute;n</label>
<input class="form-input" id="ncAddress" placeholder="Calle, n&uacute;mero, colonia" />
</div>
</form>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeNewCustomerModal()">Cancelar</button>
<button class="btn btn--primary" onclick="Workshop.saveNewCustomer()">Guardar cliente</button>
</div>
</div>
</div>
<!-- Catalog modal -->
<div class="modal-overlay" id="catalogModal">
<div class="modal modal--lg">
<div class="modal__header">
<h2 class="modal__title">Cat&aacute;logo de servicios</h2>
<h2 class="modal__title">Catálogo de servicios</h2>
<button class="modal__close" onclick="Workshop.closeCatalogModal()">&times;</button>
</div>
<div class="modal__body">
@@ -458,11 +171,11 @@
<div class="form-field">
<input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" />
</div>
<div class="form-field price-col">
<div class="form-field">
<input class="form-input" id="catRate" type="number" step="0.01" placeholder="Precio/hora" />
</div>
<div class="form-field form-field--span3">
<input class="form-input" id="catDesc" placeholder="Descripci&oacute;n" />
<input class="form-input" id="catDesc" placeholder="Descripción" />
</div>
<div class="form-field">
<button class="btn btn--primary" onclick="Workshop.addCatalogItem()">Agregar</button>
@@ -475,8 +188,8 @@
<tr>
<th>Servicio</th>
<th>Horas</th>
<th class="price-col">Precio/hora</th>
<th class="price-col">Total sugerido</th>
<th>Precio/hora</th>
<th>Total sugerido</th>
<th></th>
</tr>
</thead>
@@ -487,19 +200,16 @@
</div>
</div>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="Workshop.closeCatalogModal()">Cerrar</button>
</div>
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" 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=46" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/workshop.js?v=57" defer></script>
<script src="/pos/static/js/workshop.js?v=2" 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

@@ -64,20 +64,17 @@ def conn():
return MockConn(MockCursor())
def test_generate_order_number_first_of_day(conn):
def test_generate_order_number_first_of_year(conn):
conn._cursor.responses = [(None,)]
number = engine._generate_order_number(conn)
# Format DDMMYYYY-N
assert len(number.split("-")) == 2
assert number.split("-")[1] == "1"
assert number.startswith("SO-")
assert number.endswith("-0001")
def test_generate_order_number_increments(conn):
from datetime import datetime
today = datetime.utcnow().strftime('%d%m%Y')
conn._cursor.responses = [(f"{today}-42",)]
conn._cursor.responses = [("SO-2026-0042",)]
number = engine._generate_order_number(conn)
assert number == f"{today}-43"
assert number.endswith("-0043")
@mock.patch("services.inventory_engine.get_stock", return_value=10)
@@ -85,11 +82,11 @@ def test_generate_order_number_increments(conn):
def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn):
conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001", 2), # item lookup (branch_id=2)
(1, 5, 3, "pending", "SO-2026-0001"), # item lookup
None, # update
]
result = engine.reserve_item(conn, 7, branch_id=99, employee_id=9)
result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9)
assert result["reserved"] == 3
mock_stock.assert_called_once_with(conn, 5, 2)
@@ -102,11 +99,11 @@ def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_
@mock.patch("services.inventory_engine.get_stock", return_value=1)
def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn):
conn._cursor.responses = [
(1, 5, 3, "pending", "SO-2026-0001", 2),
(1, 5, 3, "pending", "SO-2026-0001"),
]
with pytest.raises(ValueError, match="Insufficient stock"):
engine.reserve_item(conn, 7, branch_id=99)
engine.reserve_item(conn, 7, branch_id=2)
@mock.patch("services.inventory_engine.record_operation", return_value=124)

View File

@@ -8,7 +8,6 @@ flask-sqlalchemy>=3.1
PyJWT>=2.8
bcrypt>=4.0
openpyxl>=3.1
fpdf2>=2.8
orjson
quart
asyncpg

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""
One-off helper: allocate existing customer_payments (abonos) to the oldest
unpaid credit sales for each customer.
This fixes historical data recorded before the customer-payment endpoint
started creating sale_payments rows automatically.
Run against a tenant database, e.g.:
DATABASE_URL=postgresql://postgres@localhost/tenant_refaccionaria_la_casita \
python3 scripts/allocate_existing_customer_payments.py
"""
import os
import sys
import psycopg2
DB_URL = os.environ.get('DATABASE_URL')
if not DB_URL:
print('DATABASE_URL is required')
sys.exit(1)
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
cur.execute("""
SELECT customer_id, SUM(amount) as total_paid
FROM customer_payments
GROUP BY customer_id
ORDER BY customer_id
""")
customer_payments = cur.fetchall()
inserted = 0
for customer_id, total_paid in customer_payments:
total_paid = float(total_paid or 0)
if total_paid <= 0:
continue
cur.execute("""
SELECT s.id,
s.total - COALESCE(SUM(sp.amount), 0) as balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s
AND s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
ORDER BY s.created_at
""", (customer_id,))
remaining = round(total_paid, 2)
for sale_id, balance in cur.fetchall():
if remaining <= 0:
break
pay = round(min(remaining, float(balance)), 2)
cur.execute("""
INSERT INTO sale_payments (sale_id, method, amount, reference)
VALUES (%s, 'efectivo', %s, %s)
""", (sale_id, pay, f'Abono cliente #{customer_id} (reproceso)'))
remaining = round(remaining - pay, 2)
inserted += 1
if remaining > 0:
print(f' Warning: customer {customer_id} has ${remaining} not allocated to any sale')
conn.commit()
cur.close()
conn.close()
print(f'Allocated existing customer payments into {inserted} sale_payments rows.')

View File

@@ -1,79 +0,0 @@
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

@@ -1,142 +0,0 @@
#!/usr/bin/env python3
"""Clone inventory tables from one tenant DB to another.
Uses COPY with the columns common to both source and target, so minor schema
mismatches (e.g. missing latitude/longitude in branches) are handled.
Example:
python scripts/clone_inventory.py \
--source tenant_autopartes_estrada \
--target tenant_originales_autopartes
"""
import argparse
import sys
import tempfile
import time
from contextlib import closing
import psycopg2
# (table, source_where_clause)
TABLES = [
("branches", "id <> 1"),
("inventory", None),
("inventory_stock", None),
("inventory_sku_aliases", None),
("inventory_stock_summary", None),
("inventory_vehicle_compat", None),
]
SEQUENCES = [
("branches_id_seq", "branches"),
("inventory_id_seq", "inventory"),
("inventory_stock_id_seq", "inventory_stock"),
("inventory_sku_aliases_id_seq", "inventory_sku_aliases"),
("inventory_vehicle_compat_id_seq", "inventory_vehicle_compat"),
]
def connect(db_name: str):
return psycopg2.connect(host="localhost", user="postgres", dbname=db_name)
def get_columns(cur, table: str):
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
AND table_schema = 'public'
ORDER BY ordinal_position
""",
(table,),
)
return [r[0] for r in cur.fetchall()]
def copy_table(src_conn, dst_conn, table: str, where: str | None):
with src_conn.cursor() as src_cur, dst_conn.cursor() as dst_cur:
src_cols = get_columns(src_cur, table)
dst_cols = set(get_columns(dst_cur, table))
common = [c for c in src_cols if c in dst_cols]
if not common:
print(f"Skipping {table}: no common columns")
return
col_sql = ", ".join(f'"{c}"' for c in common)
copy_to = f'COPY (SELECT {col_sql} FROM "{table}"'
if where:
copy_to += f" WHERE {where}"
copy_to += ") TO STDOUT"
copy_from = f'COPY "{table}" ({col_sql}) FROM STDIN'
print(f"Copying {table} ({len(common)} columns)...", end=" ", flush=True)
start = time.time()
with tempfile.SpooledTemporaryFile(max_size=50 * 1024 * 1024, mode="w+b") as tmp:
src_cur.copy_expert(copy_to, tmp)
tmp.seek(0)
dst_cur.copy_expert(copy_from, tmp)
dst_conn.commit()
elapsed = time.time() - start
print(f"done in {elapsed:.1f}s")
def reset_sequences(dst_conn):
with dst_conn.cursor() as cur:
for seq, table in SEQUENCES:
cur.execute(
f"SELECT setval('{seq}', COALESCE((SELECT MAX(id) FROM \"{table}\"), 1), true)"
)
dst_conn.commit()
def main():
parser = argparse.ArgumentParser(description="Clone inventory between tenant DBs")
parser.add_argument("--source", required=True)
parser.add_argument("--target", required=True)
args = parser.parse_args()
src = connect(args.source)
dst = connect(args.target)
try:
# Prepare target: remove extra branches, truncate inventory tables.
with dst.cursor() as cur:
print("Preparing target tables...", end=" ", flush=True)
cur.execute("DELETE FROM branches WHERE id <> 1")
cur.execute(
"""
TRUNCATE TABLE inventory,
inventory_stock,
inventory_sku_aliases,
inventory_stock_summary,
inventory_vehicle_compat
CASCADE
"""
)
dst.commit()
print("done")
for table, where in TABLES:
copy_table(src, dst, table, where)
print("Resetting sequences...", end=" ", flush=True)
reset_sequences(dst)
print("done")
print("Inventory clone completed.")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
dst.rollback()
raise
finally:
src.close()
dst.close()
if __name__ == "__main__":
main()

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""
Importa solo el stock positivo del respaldo Punto Zero (datos1.productos.Existencia)
al tenant de La Casita. Usa part_number como llave de cruce.
Requiere pymysql y psycopg2. Instalar con:
pip3 install --target /tmp/pylibs pymysql psycopg2-binary
"""
import os
import sys
sys.path.insert(0, "/tmp/pylibs")
import pymysql
import psycopg2
MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3307"))
MYSQL_DB = os.getenv("MYSQL_DB", "datos1")
MYSQL_USER = os.getenv("MYSQL_USER", "root")
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
PG_URL = os.getenv(
"TENANT_DB_URL",
"postgresql://postgres@localhost/tenant_refaccionaria_la_casita",
)
BRANCH_ID = int(os.getenv("BRANCH_ID", "1"))
def main():
mysql = pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
user=MYSQL_USER,
password=MYSQL_PASS,
db=MYSQL_DB,
charset="latin1",
)
pg = psycopg2.connect(PG_URL)
mycur = mysql.cursor()
mycur.execute("SELECT Clave, Existencia FROM productos WHERE Existencia > 0")
rows = mycur.fetchall()
pgcur = pg.cursor()
inserted = 0
updated = 0
skipped = 0
for clave, existencia in rows:
sku = str(clave).strip() if clave else ""
if not sku:
skipped += 1
continue
stock = int(round(float(existencia)))
if stock <= 0:
continue
# Buscar inventory_id por part_number
pgcur.execute(
"SELECT id FROM inventory WHERE part_number = %s LIMIT 1",
(sku,),
)
inv_row = pgcur.fetchone()
if not inv_row:
skipped += 1
continue
inventory_id = inv_row[0]
pgcur.execute(
"""
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
VALUES (%s, %s, %s, NULL)
ON CONFLICT (inventory_id, branch_id) DO UPDATE
SET stock = EXCLUDED.stock,
updated_at = NOW()
""",
(inventory_id, BRANCH_ID, stock),
)
if pgcur.rowcount == 1:
# psycopg2 rowcount for INSERT ... ON CONFLICT is tricky
inserted += 1
else:
updated += 1
pg.commit()
pgcur.close()
mycur.close()
mysql.close()
pg.close()
print(f"Productos con stock en respaldo: {len(rows)}")
print(f"Filas insertadas/actualizadas en inventory_stock: {inserted + updated}")
print(f"Sin coincidencia de part_number o SKU vacío: {skipped}")
if __name__ == "__main__":
main()

View File

@@ -1,442 +0,0 @@
#!/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()

Some files were not shown because too many files have changed in this diff Show More