Compare commits
79 Commits
7f753376e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f2d4558b80 | |||
| c374cccd77 | |||
| a4f90cd4a3 | |||
| 667cd3b1eb | |||
| ee5075ea2e | |||
| 4363829c26 | |||
| 95a01c8958 | |||
| 8e6c1f2887 | |||
| f0c605f911 | |||
| 4a4e53602b | |||
| 6d07eab5c0 | |||
| c90012ca37 | |||
| 116d4452da | |||
| 2734a484f6 | |||
| 1cce81b75f | |||
| b2f23ef3cf | |||
| 9b88b8ba4c | |||
| fa992b7a4a | |||
| a8e748e08f | |||
| 07dad0801e | |||
| 0bd5ee8ee8 | |||
| e270e848d7 | |||
| 6e85497e42 | |||
| 4718a09f15 | |||
| 1ff4d80277 | |||
| 31b533f9f9 | |||
| 982315bdaf | |||
| 1b51fe65b5 | |||
| 302d54eb0a | |||
| 53ec0cc3e5 | |||
| 944b9b51b3 | |||
| 670db75fcd | |||
| c226b1cf77 | |||
| f42910f4f6 | |||
| 483498cfcc | |||
| 7dc34f68ab | |||
| 73553a3771 | |||
| 073c97406b | |||
| 66b8fe3d69 | |||
| 1af66cb302 | |||
| 5c700e98c3 | |||
| 603046cb09 | |||
| b6679c6d9c | |||
| 52cb01eb4e | |||
| ff600ed7df | |||
| b539c92daf | |||
| 37b7f94f54 | |||
| 7852838b21 | |||
| 7e3205b03a | |||
| e9cfb2e756 | |||
| 2863cf8e9f | |||
| bd5f733d6f | |||
| 8b378c3476 | |||
| 76dfa2adff | |||
| 1ca056ad0f | |||
| 8ce6c398d4 | |||
| 50f2c21da7 | |||
| 06b1de84dc | |||
| 2af305d184 | |||
| 93eebe0662 | |||
| a37e60eb2a | |||
| 688ea8c70c | |||
| 4d971dd1a4 | |||
| 09e5bffe54 | |||
| c2db68b184 | |||
| c58f622486 | |||
| c1e481fb79 | |||
| 70a600cc66 | |||
| 2bdeb2973a | |||
| 59a4893e84 | |||
| 66d1268bfb | |||
| 9a55e15142 | |||
| 2ed672c3c3 | |||
| 6b00e45f5d | |||
| 0da4bcd9d9 | |||
| eced393b66 | |||
| af82b028d8 | |||
| d3e1660cb1 | |||
| 98e037c09a |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -91,3 +91,7 @@ backups/
|
||||
# Local tools (AWS CLI)
|
||||
tools/
|
||||
|
||||
# Rached migration session artifacts (tokens / captures / samples)
|
||||
rached_*.json
|
||||
rached_*.txt
|
||||
|
||||
|
||||
140
docs/AUDIT_Y_MEJORAS_2026-06-15.md
Normal file
140
docs/AUDIT_Y_MEJORAS_2026-06-15.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
@@ -74,7 +74,7 @@ server {
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 302 /pos/login;
|
||||
return 302 /pos/login2;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
46
pos/app.py
46
pos/app.py
@@ -1,4 +1,4 @@
|
||||
from flask import Flask
|
||||
from flask import Flask, request, redirect, make_response
|
||||
from json_provider import OrjsonProvider
|
||||
|
||||
|
||||
@@ -10,12 +10,20 @@ 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
|
||||
return send_from_directory('static/pwa', 'sw.js',
|
||||
mimetype='application/javascript')
|
||||
from flask import send_from_directory, make_response
|
||||
response = make_response(send_from_directory('static/pwa', 'sw.js',
|
||||
mimetype='application/javascript'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
# Register blueprints
|
||||
from blueprints.auth_bp import auth_bp
|
||||
@@ -132,11 +140,23 @@ 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():
|
||||
return render_template('login.html',
|
||||
response = make_response(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))
|
||||
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
|
||||
|
||||
@app.route('/pos/supplier-catalog')
|
||||
def supplier_catalog_page():
|
||||
@@ -168,7 +188,11 @@ def create_app():
|
||||
|
||||
@app.route('/pos/dashboard')
|
||||
def pos_dashboard():
|
||||
return render_template('dashboard.html')
|
||||
response = make_response(render_template('dashboard.html'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@app.route('/pos/config')
|
||||
def pos_config():
|
||||
@@ -210,6 +234,14 @@ def create_app():
|
||||
def pos_historical_sales():
|
||||
return render_template('historical_sales.html')
|
||||
|
||||
@app.route('/pos/remission-notes')
|
||||
def pos_remission_notes():
|
||||
response = make_response(render_template('remission_notes.html'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@app.route('/pos/static/<path:filename>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
@@ -7,8 +7,10 @@ NUMERIC(14,2) in the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.accounting_engine import create_manual_entry
|
||||
@@ -565,21 +567,140 @@ def balance_sheet():
|
||||
@accounting_bp.route('/aging', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def aging_report():
|
||||
"""Antiguedad de saldos (accounts receivable aging).
|
||||
"""Antiguedad de saldos.
|
||||
|
||||
Groups outstanding credit sales by age:
|
||||
- Corriente (not yet due)
|
||||
- 1-30 dias
|
||||
- 31-60 dias
|
||||
- 61-90 dias
|
||||
- 90+ dias
|
||||
Returns individual credit sales with outstanding balance when type=receivable
|
||||
(default), or purchase orders payable to suppliers when type=payable.
|
||||
"""
|
||||
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, s.created_at,
|
||||
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,
|
||||
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
||||
FROM customers c
|
||||
JOIN sales s ON s.customer_id = c.id
|
||||
@@ -592,6 +713,11 @@ def aging_report():
|
||||
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],
|
||||
@@ -601,29 +727,25 @@ def aging_report():
|
||||
'total': 0,
|
||||
}
|
||||
|
||||
amount = float(r[6]) if r[6] else 0
|
||||
days = r[8] or 0
|
||||
|
||||
if days <= 0:
|
||||
customers[cust_id]['corriente'] += amount
|
||||
customers[cust_id]['corriente'] += balance
|
||||
elif days <= 30:
|
||||
customers[cust_id]['d1_30'] += amount
|
||||
customers[cust_id]['d1_30'] += balance
|
||||
elif days <= 60:
|
||||
customers[cust_id]['d31_60'] += amount
|
||||
customers[cust_id]['d31_60'] += balance
|
||||
elif days <= 90:
|
||||
customers[cust_id]['d61_90'] += amount
|
||||
customers[cust_id]['d61_90'] += balance
|
||||
else:
|
||||
customers[cust_id]['d90_plus'] += amount
|
||||
customers[cust_id]['d90_plus'] += balance
|
||||
|
||||
customers[cust_id]['total'] += amount
|
||||
customers[cust_id]['total'] += balance
|
||||
|
||||
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),
|
||||
@@ -631,6 +753,7 @@ def aging_report():
|
||||
'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()
|
||||
@@ -638,6 +761,161 @@ def aging_report():
|
||||
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'])
|
||||
@@ -744,7 +1022,7 @@ def close_period():
|
||||
|
||||
|
||||
@accounting_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('accounting.read')
|
||||
@require_auth('accounting.view')
|
||||
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)
|
||||
|
||||
@@ -5,9 +5,10 @@ import jwt
|
||||
import bcrypt
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from middleware import require_auth
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
||||
|
||||
@@ -59,6 +60,7 @@ 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')
|
||||
@@ -89,39 +91,26 @@ def login_pin():
|
||||
|
||||
matched_employee = None
|
||||
|
||||
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.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 employee_id:
|
||||
_record_attempt(device_id, False)
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': 'Empleado no seleccionado'}), 400
|
||||
|
||||
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
|
||||
# Verify the PIN only for the selected employee.
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
||||
if not matched_employee:
|
||||
_record_attempt(device_id, False)
|
||||
@@ -157,10 +146,78 @@ def login_pin():
|
||||
}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
|
||||
return jsonify({
|
||||
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']
|
||||
},
|
||||
'permissions': permissions
|
||||
})
|
||||
|
||||
|
||||
@@ -197,7 +254,7 @@ def list_login_employees(tenant_id=None):
|
||||
name = row[1]
|
||||
parts = name.split()
|
||||
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
||||
employees.append({
|
||||
'id': row[0],
|
||||
'name': name,
|
||||
|
||||
@@ -3,16 +3,26 @@
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.audit import log_action
|
||||
|
||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||
|
||||
|
||||
# Roles expected to operate a cash register even without the explicit pos.sell permission.
|
||||
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
|
||||
|
||||
|
||||
def _can_operate_register():
|
||||
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
|
||||
|
||||
|
||||
@cashregister_bp.route('/open', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def open_register():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Open a cash register session.
|
||||
|
||||
Body: {register_number: int, opening_amount: float}
|
||||
@@ -84,7 +94,7 @@ def open_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/current', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def current_register():
|
||||
"""Get the current open register for this employee."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -115,8 +125,10 @@ def current_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/movement', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cash_movement():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Record a cash in/out movement with mandatory reason.
|
||||
|
||||
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||
@@ -273,8 +285,10 @@ def _compute_register_summary(conn, register_id):
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cut_x():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -301,8 +315,10 @@ def cut_x():
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cut_z():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Final cut (corte Z): close the register.
|
||||
|
||||
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||
@@ -379,12 +395,18 @@ def cut_z():
|
||||
|
||||
|
||||
@cashregister_bp.route('/history', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@require_auth()
|
||||
def register_history():
|
||||
"""List closed registers with summary.
|
||||
|
||||
Query params: date_from, date_to, employee_id, page, per_page
|
||||
|
||||
Permission rules:
|
||||
- owner/admin and users with pos.view can query any employee.
|
||||
- Cashiers/counters without pos.view can only query their own registers.
|
||||
"""
|
||||
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -402,6 +424,9 @@ def register_history():
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
|
||||
if not can_view_all:
|
||||
employee_id = g.employee_id
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("cr.closed_at >= %s")
|
||||
params.append(date_from)
|
||||
@@ -576,3 +601,89 @@ def daily_summary():
|
||||
'movements_out': movements['out'],
|
||||
'registers': registers,
|
||||
})
|
||||
|
||||
|
||||
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
|
||||
@require_auth()
|
||||
def register_sales(register_id):
|
||||
"""List the sales associated with a specific cash register (cash cut).
|
||||
|
||||
Returns sales details and a summary by payment method. Accessible to
|
||||
owners/admins, users with pos.view, or the employee who operated the register.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
|
||||
(register_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register not found'}), 404
|
||||
|
||||
register_employee_id, register_branch_id = row
|
||||
|
||||
can_view = (
|
||||
g.employee_role in ('owner', 'admin') or
|
||||
has_permission('pos.view') or
|
||||
register_employee_id == g.employee_id
|
||||
)
|
||||
if not can_view:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||
|
||||
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register belongs to another branch'}), 403
|
||||
|
||||
where = "s.register_id = %s"
|
||||
params = [register_id]
|
||||
if g.branch_id:
|
||||
where += " AND s.branch_id = %s"
|
||||
params.append(g.branch_id)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
|
||||
s.created_at, c.name as customer_name, e.name as employee_name
|
||||
FROM sales s
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
""", params)
|
||||
|
||||
sales = []
|
||||
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
|
||||
for r in cur.fetchall():
|
||||
sale = {
|
||||
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
'discount_total': float(r[4]) if r[4] else 0,
|
||||
'tax_total': float(r[5]) if r[5] else 0,
|
||||
'total': float(r[6]) if r[6] else 0,
|
||||
'amount_paid': float(r[7]) if r[7] else 0,
|
||||
'change_given': float(r[8]) if r[8] else 0,
|
||||
'status': r[9], 'created_at': str(r[10]),
|
||||
'customer_name': r[11], 'employee_name': r[12]
|
||||
}
|
||||
sales.append(sale)
|
||||
if sale['status'] == 'completed':
|
||||
summary['total'] += sale['total']
|
||||
summary['count'] += 1
|
||||
m = sale['payment_method'] or 'Otro'
|
||||
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'register_id': register_id,
|
||||
'sales': sales,
|
||||
'summary': {
|
||||
'total': round(summary['total'], 2),
|
||||
'count': summary['count'],
|
||||
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# /home/Autopartes/pos/blueprints/config_bp.py
|
||||
"""Config blueprint: tenant configuration, branches, theming."""
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
@@ -8,6 +9,188 @@ 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():
|
||||
@@ -150,8 +333,58 @@ 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('config.view')
|
||||
@require_auth()
|
||||
def list_employees():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -200,7 +433,7 @@ def create_employee():
|
||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||
|
||||
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
|
||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
||||
if data['role'] not in valid_roles:
|
||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||
|
||||
@@ -215,26 +448,8 @@ def create_employee():
|
||||
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
||||
emp_id = cur.fetchone()[0]
|
||||
|
||||
# Set default permissions by role
|
||||
role_permissions = {
|
||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||
'invoicing.view', 'invoicing.create',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices'],
|
||||
'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'], []):
|
||||
# Set default permissions by role (configurable per tenant)
|
||||
for perm in _get_role_permissions(conn, data['role']):
|
||||
cur.execute(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(emp_id, perm)
|
||||
@@ -250,6 +465,105 @@ 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):
|
||||
@@ -295,6 +609,15 @@ 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'})
|
||||
@@ -305,6 +628,53 @@ 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():
|
||||
@@ -389,7 +759,14 @@ 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', ''),
|
||||
})
|
||||
@@ -405,14 +782,22 @@ 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
|
||||
# Tax params (also keep cfdi_* aliases in sync)
|
||||
'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',
|
||||
}
|
||||
@@ -663,7 +1048,7 @@ def update_whatsapp_config():
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
@require_auth()
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -681,6 +1066,7 @@ def get_modules():
|
||||
'marketplace': _bool('module_marketplace'),
|
||||
'meli': _bool('module_meli'),
|
||||
'catalog': _bool('module_catalog'),
|
||||
'workshop': _bool('module_workshop'),
|
||||
})
|
||||
|
||||
|
||||
@@ -697,6 +1083,7 @@ 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():
|
||||
@@ -713,9 +1100,78 @@ 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():
|
||||
@@ -745,3 +1201,102 @@ 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'})
|
||||
|
||||
@@ -10,10 +10,20 @@ 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('customers.view')
|
||||
@require_auth()
|
||||
def list_customers():
|
||||
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||
|
||||
@@ -23,6 +33,8 @@ def list_customers():
|
||||
per_page: items per page (default 50, max 200)
|
||||
branch_id: filter by branch (default: current user's branch)
|
||||
"""
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -30,10 +42,24 @@ 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 = ["c.is_active = true"]
|
||||
where_clauses = []
|
||||
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))
|
||||
@@ -42,8 +68,20 @@ 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)
|
||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
||||
|
||||
# Count
|
||||
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
||||
@@ -54,7 +92,8 @@ 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.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
|
||||
FROM customers c
|
||||
WHERE {where}
|
||||
ORDER BY c.name
|
||||
@@ -71,6 +110,9 @@ 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()
|
||||
@@ -84,8 +126,10 @@ def list_customers():
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
@require_auth()
|
||||
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()
|
||||
@@ -135,19 +179,71 @@ 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('customers.create')
|
||||
@require_auth()
|
||||
def create_customer():
|
||||
"""Create a new customer.
|
||||
|
||||
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||
phone, address, price_tier, credit_limit, vehicle_info}
|
||||
"""
|
||||
if not _can_create_customer():
|
||||
return jsonify({'error': 'Missing permissions: customers.create'}), 403
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
@@ -240,9 +336,57 @@ 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('customers.view')
|
||||
@require_auth()
|
||||
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:
|
||||
@@ -341,8 +485,10 @@ def customer_statement(customer_id):
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
@require_auth()
|
||||
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:
|
||||
@@ -439,6 +585,32 @@ 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("""
|
||||
|
||||
@@ -118,3 +118,70 @@ 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()
|
||||
|
||||
@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
|
||||
# ─── Vehicles CRUD ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
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()
|
||||
@require_auth('fleet.view')
|
||||
def get_vehicle(vehicle_id):
|
||||
"""Vehicle detail with maintenance schedules and recent logs."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -155,16 +155,128 @@ 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()
|
||||
@require_auth('fleet.create')
|
||||
def create_vehicle():
|
||||
"""Create a fleet vehicle.
|
||||
|
||||
Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
||||
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.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('plate') and not data.get('vin'):
|
||||
return jsonify({'error': 'plate or vin is required'}), 400
|
||||
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
|
||||
|
||||
branch_id = data.get('branch_id', g.branch_id)
|
||||
|
||||
@@ -172,17 +284,23 @@ 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, plate, vin, make, model, year,
|
||||
(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
|
||||
""", (
|
||||
branch_id, data.get('plate'), data.get('vin'),
|
||||
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'), data.get('owner_name'), data.get('notes'),
|
||||
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'),
|
||||
))
|
||||
vehicle_id = cur.fetchone()[0]
|
||||
|
||||
@@ -201,7 +319,7 @@ def create_vehicle():
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.edit')
|
||||
def update_vehicle(vehicle_id):
|
||||
"""Update vehicle fields including mileage.
|
||||
|
||||
@@ -245,7 +363,7 @@ def update_vehicle(vehicle_id):
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.delete')
|
||||
def deactivate_vehicle(vehicle_id):
|
||||
"""Soft-delete: set is_active = false."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -266,7 +384,7 @@ def deactivate_vehicle(vehicle_id):
|
||||
# ─── Maintenance Schedules ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def list_schedules(vehicle_id):
|
||||
"""Maintenance schedules for a vehicle."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -299,7 +417,7 @@ def list_schedules(vehicle_id):
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.create')
|
||||
def create_schedule(vehicle_id):
|
||||
"""Create maintenance schedule for a vehicle.
|
||||
|
||||
@@ -344,7 +462,7 @@ def create_schedule(vehicle_id):
|
||||
# ─── Maintenance Logs ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.create')
|
||||
def record_maintenance(vehicle_id):
|
||||
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
|
||||
|
||||
@@ -427,7 +545,7 @@ def record_maintenance(vehicle_id):
|
||||
# ─── Alerts ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/alerts', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def fleet_alerts():
|
||||
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -472,7 +590,7 @@ def fleet_alerts():
|
||||
# ─── Stats ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/stats', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('fleet.view')
|
||||
def fleet_stats():
|
||||
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
@@ -103,8 +103,13 @@ def list_items():
|
||||
# branch_id no longer filters inventory rows (shared catalog).
|
||||
# It is used only to show per-branch stock.
|
||||
if search:
|
||||
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
# Search also matches alternate / alias SKUs.
|
||||
where_clauses.append(
|
||||
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
|
||||
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
|
||||
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
|
||||
)
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
if category:
|
||||
where_clauses.append("i.category_id = %s")
|
||||
params.append(int(category))
|
||||
@@ -421,7 +426,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,
|
||||
location, description, category, make, model, year, engine, engine_code
|
||||
sku_secondary, 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
|
||||
@@ -445,8 +450,17 @@ def bulk_import_items():
|
||||
try:
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext == '.csv':
|
||||
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
|
||||
reader = csv.DictReader(stream)
|
||||
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))
|
||||
rows = list(reader)
|
||||
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
||||
try:
|
||||
@@ -490,6 +504,7 @@ def bulk_import_items():
|
||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||
'ubicacion': 'location', 'categoria': 'category',
|
||||
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
|
||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||
@@ -520,6 +535,10 @@ 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()
|
||||
@@ -548,7 +567,10 @@ def bulk_import_items():
|
||||
cost = _to_decimal(row.get('cost'), 0)
|
||||
location = str(row.get('location', '')).strip()
|
||||
description = str(row.get('description', '')).strip()
|
||||
category = str(row.get('category', '')).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')
|
||||
|
||||
# Check if item already exists (catalog is shared across branches)
|
||||
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
|
||||
@@ -566,10 +588,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 = COALESCE(NULLIF(%s,''), category)
|
||||
category_id = COALESCE(%s, category_id)
|
||||
WHERE id = %s
|
||||
""",
|
||||
(name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
|
||||
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
|
||||
)
|
||||
was_inserted = False
|
||||
# Record stock adjustment for existing item if stock > 0
|
||||
@@ -582,11 +604,11 @@ def bulk_import_items():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory
|
||||
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 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, 'PZA')
|
||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA')
|
||||
)
|
||||
item_id = cur.fetchone()[0]
|
||||
was_inserted = True
|
||||
@@ -599,7 +621,20 @@ def bulk_import_items():
|
||||
created_ids.append(item_id)
|
||||
created += 1
|
||||
|
||||
# ---------- 2. Vehicle compatibility ----------
|
||||
# ---------- 2. Secondary SKU alias ----------
|
||||
sku_secondary = str(row.get('sku_secondary', '')).strip()
|
||||
if sku_secondary:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(item_id, sku_secondary, 'Secundario')
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# ---------- 3. Vehicle compatibility ----------
|
||||
make = str(row.get('make', '')).strip()
|
||||
model = str(row.get('model', '')).strip()
|
||||
year_str = str(row.get('year', '')).strip()
|
||||
@@ -2271,6 +2306,22 @@ 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'])
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -43,9 +44,16 @@ 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", "601"),
|
||||
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"),
|
||||
"cp": config.get("tenant_cp", "00000"),
|
||||
"serie": config.get("cfdi_serie", "A"),
|
||||
"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"),
|
||||
"facturapi_key": config.get("cfdi_facturapi_key", ""),
|
||||
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
|
||||
}
|
||||
@@ -485,7 +493,7 @@ def get_sale_pdf(sale_id):
|
||||
|
||||
|
||||
@invoicing_bp.route("/stats", methods=["GET"])
|
||||
@require_auth("invoicing.read")
|
||||
@require_auth("invoicing.view")
|
||||
def api_invoicing_stats():
|
||||
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -667,14 +675,15 @@ def facturapi_setup():
|
||||
(result["org_id"],),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value)
|
||||
VALUES ('cfdi_facturapi_key', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""",
|
||||
(result["api_key"],),
|
||||
)
|
||||
if result.get("api_key"):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value)
|
||||
VALUES ('cfdi_facturapi_key', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""",
|
||||
(result["api_key"],),
|
||||
)
|
||||
|
||||
log_action(conn, "FACTURAPI_SETUP", "tenant_config", None, new_value={"org_id": result["org_id"]})
|
||||
|
||||
@@ -682,18 +691,30 @@ def facturapi_setup():
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"org_id": result["org_id"],
|
||||
"message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
|
||||
}
|
||||
)
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
@@ -24,6 +24,8 @@ 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
|
||||
@@ -81,6 +83,49 @@ 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():
|
||||
@@ -90,12 +135,21 @@ 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 or not client_id or not client_secret:
|
||||
return jsonify({"error": "code, client_id and client_secret required"}), 400
|
||||
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
|
||||
|
||||
try:
|
||||
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||
|
||||
@@ -13,7 +13,8 @@ from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.pos_engine import (
|
||||
process_sale, cancel_sale, calculate_totals,
|
||||
get_price_for_customer, get_margin_info
|
||||
get_price_for_customer, get_margin_info,
|
||||
create_remission_note, pay_pending_sale
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
@@ -22,6 +23,53 @@ 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.
|
||||
|
||||
@@ -103,20 +151,11 @@ 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()
|
||||
@@ -131,8 +170,80 @@ 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('pos.view')
|
||||
@require_auth()
|
||||
def list_sales():
|
||||
"""List sales with filters.
|
||||
|
||||
@@ -146,6 +257,13 @@ def list_sales():
|
||||
page: int (default 1)
|
||||
per_page: int (default 50, max 200)
|
||||
"""
|
||||
sale_type = request.args.get('sale_type')
|
||||
is_remission = sale_type == 'counter_remission'
|
||||
can_view_all = g.employee_role == 'owner' or has_permission('pos.view')
|
||||
can_view_remissions = is_remission and (has_permission('pos.sell') or has_permission('pos.remission'))
|
||||
if not (can_view_all or can_view_remissions):
|
||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -160,6 +278,10 @@ def list_sales():
|
||||
employee_id = request.args.get('employee_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
status = request.args.get('status')
|
||||
sale_type = request.args.get('sale_type')
|
||||
q = request.args.get('q')
|
||||
customer_q = request.args.get('customer')
|
||||
courier_id = request.args.get('courier_id')
|
||||
register_id = request.args.get('register_id')
|
||||
|
||||
if date_from:
|
||||
@@ -177,6 +299,18 @@ def list_sales():
|
||||
if status:
|
||||
where_clauses.append("s.status = %s")
|
||||
params.append(status)
|
||||
if sale_type:
|
||||
where_clauses.append("s.sale_type = %s")
|
||||
params.append(sale_type)
|
||||
if q:
|
||||
where_clauses.append("(s.id::text ILIKE %s OR c.name ILIKE %s)")
|
||||
params.extend([f'%{q}%', f'%{q}%'])
|
||||
if customer_q:
|
||||
where_clauses.append("c.name ILIKE %s")
|
||||
params.append(f'%{customer_q}%')
|
||||
if courier_id:
|
||||
where_clauses.append("s.courier_id = %s")
|
||||
params.append(int(courier_id))
|
||||
if register_id:
|
||||
where_clauses.append("s.register_id = %s")
|
||||
params.append(int(register_id))
|
||||
@@ -195,12 +329,14 @@ def list_sales():
|
||||
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
|
||||
s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||
s.tax_total, s.total, s.amount_paid, s.change_given,
|
||||
s.status, s.created_at,
|
||||
s.status, s.created_at, s.courier_id,
|
||||
e.name as employee_name,
|
||||
c.name as customer_name
|
||||
c.name as customer_name,
|
||||
co.name as courier_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
@@ -219,7 +355,9 @@ def list_sales():
|
||||
'amount_paid': float(r[11]) if r[11] else 0,
|
||||
'change_given': float(r[12]) if r[12] else 0,
|
||||
'status': r[13], 'created_at': str(r[14]),
|
||||
'employee_name': r[15], 'customer_name': r[16],
|
||||
'courier_id': r[15],
|
||||
'employee_name': r[16], 'customer_name': r[17],
|
||||
'courier_name': r[18],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -232,6 +370,75 @@ 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():
|
||||
@@ -269,6 +476,16 @@ 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]
|
||||
|
||||
@@ -310,9 +527,13 @@ def list_historical_sales():
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@require_auth()
|
||||
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()
|
||||
|
||||
@@ -378,12 +599,17 @@ def get_sale(sale_id):
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
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()
|
||||
|
||||
@@ -520,6 +746,8 @@ 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
|
||||
@@ -682,9 +910,23 @@ def list_quotations():
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
|
||||
@require_auth('pos.sell')
|
||||
def delete_quotation(quot_id):
|
||||
"""Delete a quotation and its items."""
|
||||
"""Delete a quotation, release its stock reservations and remove 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
|
||||
@@ -894,6 +1136,8 @@ 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
|
||||
@@ -986,6 +1230,8 @@ 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:
|
||||
@@ -997,9 +1243,10 @@ def patch_quotation(quot_id):
|
||||
if 'valid_until' in data:
|
||||
fields.append('valid_until = %s')
|
||||
params.append(data['valid_until'])
|
||||
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
|
||||
new_status = data.get('status')
|
||||
if new_status and new_status in ('active', 'cancelled', 'expired'):
|
||||
fields.append('status = %s')
|
||||
params.append(data['status'])
|
||||
params.append(new_status)
|
||||
|
||||
if not fields:
|
||||
cur.close(); conn.close()
|
||||
@@ -1007,6 +1254,20 @@ 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'})
|
||||
@@ -1356,12 +1617,15 @@ 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
|
||||
@@ -1520,6 +1784,8 @@ 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
|
||||
@@ -1911,10 +2177,14 @@ 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']:
|
||||
cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
|
||||
(item['inventory_id'],))
|
||||
inv = cur.fetchone()
|
||||
inv = inv_map.get(item['inventory_id'], ('', '', 0))
|
||||
cur.execute("""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
@@ -1923,9 +2193,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] if inv else '', inv[1] if inv else '',
|
||||
inv[0] or '', inv[1] or '',
|
||||
item['quantity'], item['unit_price'],
|
||||
float(inv[2]) if inv and inv[2] else 0,
|
||||
float(inv[2]) if inv[2] else 0,
|
||||
item['discount_pct'], item['discount_amount'],
|
||||
item['tax_rate'], item['tax_amount'], item['subtotal']
|
||||
))
|
||||
@@ -2067,7 +2337,7 @@ def create_return():
|
||||
try:
|
||||
# Validate sale exists and is completed
|
||||
cur.execute("""
|
||||
SELECT id, customer_id, total, status, branch_id
|
||||
SELECT id, customer_id, total, status, branch_id, sale_type
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
sale = cur.fetchone()
|
||||
@@ -2078,6 +2348,7 @@ 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
|
||||
@@ -2179,10 +2450,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 applicable
|
||||
if sale_customer_id:
|
||||
# Update customer credit if the original sale was on credit
|
||||
if sale_customer_id and sale_type == 'credit':
|
||||
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))
|
||||
|
||||
@@ -2421,6 +2692,79 @@ def print_ticket(sale_id):
|
||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/print-remission', methods=['POST'])
|
||||
@require_auth()
|
||||
def print_remission(sale_id):
|
||||
"""Generate printable data for a counter remission note."""
|
||||
from middleware import has_permission
|
||||
if not (has_permission('pos.remission') or has_permission('pos.sell')):
|
||||
return jsonify({'error': 'Missing permissions'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.*, e.name as employee_name, c.name as customer_name, co.name as courier_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||
WHERE s.id = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
|
||||
cols = [desc[0] for desc in cur.description]
|
||||
sale = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
|
||||
if sale.get(k) is not None:
|
||||
sale[k] = float(sale[k])
|
||||
|
||||
cur.execute("""
|
||||
SELECT name, quantity, unit_price, subtotal
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'name': r[0], 'quantity': r[1],
|
||||
'unit_price': float(r[2]) if r[2] else 0,
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
})
|
||||
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
else:
|
||||
business_info[rw[0]] = rw[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'folio': f'NR-{sale["id"]}',
|
||||
'date': str(sale.get('created_at', '')),
|
||||
'employee': sale.get('employee_name', ''),
|
||||
'customer': sale.get('customer_name', ''),
|
||||
'courier': sale.get('courier_name', ''),
|
||||
'items': items,
|
||||
'subtotal': sale.get('subtotal', 0),
|
||||
'discount_total': sale.get('discount_total', 0),
|
||||
'tax_total': sale.get('tax_total', 0),
|
||||
'total': sale.get('total', 0),
|
||||
'status': sale.get('status', ''),
|
||||
'business_name': business_info.get('name', ''),
|
||||
'business_rfc': business_info.get('rfc', ''),
|
||||
'business_address': business_info.get('address', ''),
|
||||
})
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
|
||||
@@ -3,16 +3,21 @@
|
||||
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,
|
||||
@@ -28,17 +33,136 @@ 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_auth()
|
||||
@require_workshop_view
|
||||
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)
|
||||
|
||||
@@ -47,15 +171,65 @@ 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, page=page, per_page=per_page
|
||||
employee_id=employee_id, delivery_method=delivery_method,
|
||||
is_direct=is_direct, q=q, 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_auth()
|
||||
@require_workshop_action('create_order')
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -73,6 +247,14 @@ 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:
|
||||
@@ -80,22 +262,29 @@ def create_order():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_workshop_view
|
||||
def get_order(so_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
return jsonify(order)
|
||||
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))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_workshop_action('edit_order')
|
||||
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)
|
||||
@@ -106,13 +295,32 @@ 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_auth()
|
||||
@require_workshop_action('change_status')
|
||||
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(
|
||||
@@ -130,7 +338,7 @@ def change_status(so_id):
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_items')
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -142,7 +350,7 @@ def add_order_item(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_items')
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -156,7 +364,7 @@ def update_order_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_items')
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -169,7 +377,7 @@ def delete_order_item(item_id):
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_labor')
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
@@ -183,7 +391,7 @@ def add_order_labor(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_labor')
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -197,7 +405,7 @@ def update_order_labor(labor_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_labor')
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -210,7 +418,7 @@ def delete_order_labor(labor_id):
|
||||
# ─── Kanban Summary ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_workshop_view
|
||||
def kanban_summary():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -224,7 +432,7 @@ def kanban_summary():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_items')
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -238,7 +446,7 @@ def reserve_order_item(so_id, item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_workshop_action('add_items')
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -256,6 +464,7 @@ 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.
|
||||
|
||||
@@ -290,11 +499,43 @@ 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_auth()
|
||||
@require_workshop_action('assign_mechanic')
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
@@ -312,11 +553,144 @@ 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_auth()
|
||||
@require_workshop_view
|
||||
def list_catalog():
|
||||
"""List reusable labor/service concepts."""
|
||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||
@@ -329,7 +703,7 @@ def list_catalog():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_workshop_action('edit_order')
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -345,7 +719,7 @@ def create_catalog_item():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_auth()
|
||||
@require_workshop_action('edit_order')
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -360,7 +734,7 @@ def update_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
@require_workshop_action('edit_order')
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -375,7 +749,7 @@ def delete_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_workshop_action('edit_order')
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
|
||||
# Check permissions
|
||||
if required_permissions:
|
||||
missing = set(required_permissions) - g.permissions
|
||||
# owner role bypasses all permission checks
|
||||
if g.employee_role != 'owner' and missing:
|
||||
# owner/admin roles bypass all permission checks
|
||||
if g.employee_role not in ('owner', 'admin') and missing:
|
||||
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
|
||||
|
||||
def has_permission(permission):
|
||||
"""Check if current user has a specific permission. Use inside a route."""
|
||||
return g.employee_role == 'owner' or permission in g.permissions
|
||||
return g.employee_role in ('owner', 'admin') or permission in g.permissions
|
||||
|
||||
@@ -52,6 +52,24 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +125,10 @@ def apply_migration(db_name, version):
|
||||
def run_migrations():
|
||||
"""Apply pending migrations to all tenants."""
|
||||
tenants = get_all_tenants()
|
||||
sorted_versions = sorted(MIGRATIONS.keys())
|
||||
def _version_key(v):
|
||||
return tuple(int(x) for x in v.lstrip('v').split('.'))
|
||||
|
||||
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
|
||||
|
||||
print(f"Found {len(tenants)} active tenants")
|
||||
print(f"Available migrations: {sorted_versions}")
|
||||
@@ -115,8 +136,9 @@ def run_migrations():
|
||||
for tenant_id, db_name, name, current_version in tenants:
|
||||
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
||||
|
||||
current_key = _version_key(current_version)
|
||||
for version in sorted_versions:
|
||||
if version <= current_version:
|
||||
if _version_key(version) <= current_key:
|
||||
continue
|
||||
|
||||
print(f" Applying {version}...", end=" ")
|
||||
|
||||
20
pos/migrations/v4.10_fleet_permissions.sql
Normal file
20
pos/migrations/v4.10_fleet_permissions.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 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;
|
||||
24
pos/migrations/v4.11_clean_workshop_mechanic_permissions.sql
Normal file
24
pos/migrations/v4.11_clean_workshop_mechanic_permissions.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- 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;
|
||||
11
pos/migrations/v4.12_service_order_soft_delete.sql
Normal file
11
pos/migrations/v4.12_service_order_soft_delete.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 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).
|
||||
20
pos/migrations/v4.13_fleet_vehicle_customer.sql
Normal file
20
pos/migrations/v4.13_fleet_vehicle_customer.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 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);
|
||||
10
pos/migrations/v4.14_service_order_delivery_cleanup.sql
Normal file
10
pos/migrations/v4.14_service_order_delivery_cleanup.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
|
||||
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
|
||||
|
||||
UPDATE service_orders
|
||||
SET delivery_method = 'pickup'
|
||||
WHERE delivery_method = 'courier';
|
||||
|
||||
UPDATE service_orders
|
||||
SET courier_id = NULL
|
||||
WHERE delivery_method IS NULL OR delivery_method != 'delivery';
|
||||
18
pos/migrations/v4.15_counter_remission.sql
Normal file
18
pos/migrations/v4.15_counter_remission.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- v4.15: Counter remission note support
|
||||
-- Ensures employees with role 'counter' have the base permissions needed to
|
||||
-- generate remission notes from the POS.
|
||||
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.permission
|
||||
FROM employees e
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('pos.remission'),
|
||||
('pos.view'),
|
||||
('catalog.view'),
|
||||
('inventory.view'),
|
||||
('customers.view'),
|
||||
('customers.create')
|
||||
) AS p(permission)
|
||||
WHERE e.role = 'counter'
|
||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||
18
pos/migrations/v4.16_remission_courier.sql
Normal file
18
pos/migrations/v4.16_remission_courier.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- v4.16: Add courier assignment to counter remission notes.
|
||||
|
||||
ALTER TABLE sales
|
||||
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'sales_courier_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE sales
|
||||
ADD CONSTRAINT sales_courier_id_fkey
|
||||
FOREIGN KEY (courier_id) REFERENCES couriers(id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);
|
||||
6
pos/migrations/v4.17_service_order_invoice.sql
Normal file
6
pos/migrations/v4.17_service_order_invoice.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Service order invoice requirement flag
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
|
||||
ON service_orders(requires_invoice);
|
||||
32
pos/migrations/v4.18_rached_workshop.sql
Normal file
32
pos/migrations/v4.18_rached_workshop.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- v4.18 Rached workshop fields
|
||||
-- Extends service orders and items to match the Rached legacy workshop flow.
|
||||
-- Applied to all tenants.
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS customer_address TEXT,
|
||||
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
|
||||
|
||||
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
|
||||
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
|
||||
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
|
||||
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_order_items
|
||||
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
|
||||
ADD COLUMN IF NOT EXISTS observations TEXT;
|
||||
|
||||
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
|
||||
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);
|
||||
11
pos/migrations/v4.19_counter_inventory_create.sql
Normal file
11
pos/migrations/v4.19_counter_inventory_create.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- v4.19 — Grant inventory.create permission to existing counter employees
|
||||
-- so they can create items and record purchase entries.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, 'inventory.create'
|
||||
FROM employees e
|
||||
WHERE e.role = 'counter'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
|
||||
);
|
||||
18
pos/migrations/v4.20_cashier_sell_permissions.sql
Normal file
18
pos/migrations/v4.20_cashier_sell_permissions.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
|
||||
-- The cashier default set already includes these permissions, but employees
|
||||
-- created before the fix may be missing them.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('pos.sell'),
|
||||
('pos.discount'),
|
||||
('pos.cancel'),
|
||||
('pos.view')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
15
pos/migrations/v4.21_cashier_workshop_permissions.sql
Normal file
15
pos/migrations/v4.21_cashier_workshop_permissions.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- v4.21 — Allow existing cashier employees to create and edit service orders.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('workshop.view'),
|
||||
('workshop.edit'),
|
||||
('workshop.add_items')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
15
pos/migrations/v4.22_cashier_invoicing_permissions.sql
Normal file
15
pos/migrations/v4.22_cashier_invoicing_permissions.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- v4.22 -- Enable invoicing for existing cashier employees.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('invoicing.view'),
|
||||
('invoicing.create'),
|
||||
('invoicing.cancel')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
2
pos/migrations/v4.23_service_order_mechanic_name.sql
Normal file
2
pos/migrations/v4.23_service_order_mechanic_name.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- v4.23 -- Add free-text mechanic name for service orders.
|
||||
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;
|
||||
20
pos/migrations/v4.6_inventory_support.sql
Normal file
20
pos/migrations/v4.6_inventory_support.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 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
|
||||
);
|
||||
11
pos/migrations/v4.7_workshop_business.sql
Normal file
11
pos/migrations/v4.7_workshop_business.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 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);
|
||||
15
pos/migrations/v4.8_workshop_permissions.sql
Normal file
15
pos/migrations/v4.8_workshop_permissions.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 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;
|
||||
7
pos/migrations/v4.9_workshop_customers_view.sql
Normal file
7
pos/migrations/v4.9_workshop_customers_view.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 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;
|
||||
@@ -46,18 +46,22 @@ def _get_account_id(cur, code):
|
||||
|
||||
def _get_account_ids(cur, codes):
|
||||
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
||||
result = {}
|
||||
for code in codes:
|
||||
result[code] = _get_account_id(cur, code)
|
||||
return result
|
||||
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
|
||||
|
||||
|
||||
def get_next_entry_number(conn):
|
||||
"""Get the next sequential journal entry number.
|
||||
|
||||
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.
|
||||
Uses a transaction-level advisory lock to prevent duplicate numbers
|
||||
when multiple journal entries are created concurrently.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection to tenant DB
|
||||
@@ -66,6 +70,7 @@ 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()
|
||||
|
||||
@@ -29,8 +29,13 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
||||
|
||||
|
||||
def _generate_provisional_folio(conn):
|
||||
"""Generate a provisional folio like PRE-00001."""
|
||||
"""Generate a provisional folio like PRE-00001.
|
||||
|
||||
Uses a transaction-level advisory lock to avoid duplicate provisional
|
||||
folios when multiple CFDIs are enqueued concurrently.
|
||||
"""
|
||||
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()
|
||||
@@ -101,6 +106,7 @@ 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,),
|
||||
)
|
||||
@@ -364,7 +370,7 @@ def get_queue_status(conn, filters=None):
|
||||
params.append(int(filters["sale_id"]))
|
||||
|
||||
if filters.get("type"):
|
||||
where_clauses.append("q.type = %s")
|
||||
where_clauses.append("LOWER(q.type) = LOWER(%s)")
|
||||
params.append(filters["type"])
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
@@ -376,8 +382,12 @@ 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
|
||||
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
|
||||
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
|
||||
@@ -401,6 +411,12 @@ 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],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ def find_organization_by_rfc(tenant_config: dict) -> dict | None:
|
||||
|
||||
|
||||
def create_organization(tenant_config: dict) -> dict:
|
||||
"""Create a new Facturapi organization for the tenant and return live key.
|
||||
"""Create or reuse a Facturapi organization, configure legal data and return a live key.
|
||||
|
||||
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
||||
Uses tenant RFC/razon_social if available.
|
||||
@@ -216,24 +216,121 @@ 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"
|
||||
|
||||
# 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:
|
||||
# 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)
|
||||
if existing:
|
||||
org_id = existing["id"]
|
||||
else:
|
||||
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")
|
||||
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)
|
||||
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
||||
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}
|
||||
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)
|
||||
|
||||
|
||||
def get_org_status(tenant_config: dict) -> dict:
|
||||
@@ -249,12 +346,12 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
api_key = get_api_key(tenant_config)
|
||||
result["has_key"] = True
|
||||
except FacturapiError as e:
|
||||
result["error"] = str(e)
|
||||
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
|
||||
result["has_key"] = True
|
||||
|
||||
org_id = _get_org_id(tenant_config)
|
||||
if not org_id:
|
||||
@@ -264,21 +361,36 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
result["has_org_id"] = True
|
||||
result["org_id"] = org_id
|
||||
|
||||
try:
|
||||
api_key = _get_status_key(tenant_config)
|
||||
if not api_key:
|
||||
result["error"] = "No Facturapi key available"
|
||||
return result
|
||||
|
||||
def _fetch():
|
||||
org = get_organization(org_id, api_key)
|
||||
legal = org.get("legal", {})
|
||||
cert = org.get("certificate", {})
|
||||
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", []),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"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:
|
||||
result["error"] = str(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
|
||||
|
||||
|
||||
@@ -58,19 +58,18 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||
cur.close()
|
||||
return []
|
||||
|
||||
# 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 = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
# Load sale details with items in two bulk queries (O(1) round-trips)
|
||||
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,))
|
||||
|
||||
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],
|
||||
@@ -85,33 +84,37 @@ 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, 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 = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
cur.execute("""
|
||||
SELECT id, sale_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,))
|
||||
|
||||
for r in cur.fetchall():
|
||||
sale['items'].append({
|
||||
'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)
|
||||
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',
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return sales
|
||||
return list(sales.values())
|
||||
|
||||
|
||||
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||
|
||||
@@ -122,16 +122,23 @@ 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
|
||||
# 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("""
|
||||
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
||||
SELECT %s, 'stock_update', 'pending'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM marketplace_listings
|
||||
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (inventory_id, inventory_id))
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('marketplace_listings', 'meli_sync_queue')
|
||||
""")
|
||||
if cur.fetchone()[0] >= 2:
|
||||
cur.execute("""
|
||||
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
||||
SELECT %s, 'stock_update', 'pending'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM marketplace_listings
|
||||
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (inventory_id, inventory_id))
|
||||
|
||||
cur.close()
|
||||
return op_id
|
||||
@@ -230,6 +237,54 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
||||
return op_id
|
||||
|
||||
|
||||
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
|
||||
cost_at_time=None, remaining_stock=None):
|
||||
"""Reserve stock for a counter remission note (negative quantity).
|
||||
|
||||
The reserved quantity is deducted from available stock until the note is
|
||||
paid or cancelled.
|
||||
"""
|
||||
op_id = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
|
||||
notes='Reserva por nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
|
||||
try:
|
||||
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
|
||||
if remaining <= 0:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
||||
inv_row = cur.fetchone()
|
||||
cur.close()
|
||||
if inv_row:
|
||||
from services.push_service import notify_owner
|
||||
notify_owner(
|
||||
conn,
|
||||
'Stock en Cero',
|
||||
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
|
||||
'/pos'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return op_id
|
||||
|
||||
|
||||
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Release a previous reservation (positive quantity)."""
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale',
|
||||
notes=notes or 'Liberacion de reserva de nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Record a customer return (positive quantity)."""
|
||||
result = record_operation(
|
||||
@@ -374,7 +429,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': str(r[5]),
|
||||
'notes': r[4], 'date': r[5].isoformat() if r[5] else None,
|
||||
'employee': r[6], 'branch_id': r[7]
|
||||
})
|
||||
cur.close()
|
||||
|
||||
@@ -11,10 +11,13 @@ 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,
|
||||
@@ -223,9 +226,9 @@ def process_sale(conn, sale_data):
|
||||
if not items:
|
||||
raise ValueError("No items in sale")
|
||||
|
||||
# Validate register is open
|
||||
# Validate register is open and lock it to prevent concurrent close/sale races
|
||||
if register_id:
|
||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s", (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")
|
||||
@@ -249,6 +252,17 @@ 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:
|
||||
@@ -301,24 +315,34 @@ def process_sale(conn, sale_data):
|
||||
credit_limit = float(cust[0] or 0)
|
||||
credit_balance = float(cust[1] or 0)
|
||||
credit_available = credit_limit - credit_balance
|
||||
if totals['total'] > credit_available and credit_limit > 0:
|
||||
if totals['total'] > credit_available:
|
||||
raise ValueError(
|
||||
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
||||
f"Required: ${totals['total']:.2f}"
|
||||
)
|
||||
|
||||
# Pending payment sale (e.g. "Pendiente")
|
||||
is_pending = payment_method == 'pendiente'
|
||||
if is_pending:
|
||||
amount_paid = 0.0
|
||||
sale_type = 'cash'
|
||||
|
||||
# Calculate change
|
||||
change_given = 0.0
|
||||
if sale_type == 'cash' and payment_method == 'efectivo':
|
||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||
|
||||
# SAT payment method codes
|
||||
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
|
||||
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
|
||||
forma_pago_map = {
|
||||
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
||||
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
||||
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
# Determine sale status
|
||||
status = 'pending_payment' if is_pending else 'completed'
|
||||
|
||||
# Create sale record (with currency)
|
||||
cur.execute("""
|
||||
INSERT INTO sales
|
||||
@@ -326,14 +350,14 @@ def process_sale(conn, sale_data):
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, device_id, notes, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, totals['subtotal'], totals['discount_total'],
|
||||
totals['tax_total'], totals['total'], amount_paid, change_given,
|
||||
metodo_pago_sat, forma_pago_sat,
|
||||
_safe_g('device_id'), notes,
|
||||
status, _safe_g('device_id'), notes,
|
||||
currency, exchange_rate
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
@@ -392,23 +416,26 @@ def process_sale(conn, sale_data):
|
||||
'subtotal': item['subtotal'],
|
||||
})
|
||||
|
||||
# Record payment on cash register (cash movements for efectivo)
|
||||
if register_id and payment_details:
|
||||
for pd in payment_details:
|
||||
method = pd.get('method', payment_method)
|
||||
amt = float(pd.get('amount', 0))
|
||||
ref = pd.get('reference', '')
|
||||
# Record payment on cash register (skip pending sales and zero-amount rows)
|
||||
if not is_pending:
|
||||
if register_id and payment_details:
|
||||
for pd in payment_details:
|
||||
method = pd.get('method', payment_method)
|
||||
amt = float(pd.get('amount', 0))
|
||||
ref = pd.get('reference', '')
|
||||
if amt <= 0:
|
||||
continue
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
elif register_id and amount_paid > 0:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
elif register_id:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||
|
||||
# Update customer credit balance if credit sale
|
||||
if sale_type == 'credit' and customer_id:
|
||||
@@ -418,6 +445,16 @@ def process_sale(conn, sale_data):
|
||||
WHERE id = %s
|
||||
""", (totals['total'], customer_id))
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
# Audit log
|
||||
log_action(conn, 'SALE', 'sale', sale_id,
|
||||
new_value={
|
||||
@@ -494,6 +531,8 @@ def process_sale(conn, sale_data):
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
'employee_id': employee_id,
|
||||
'register_id': register_id,
|
||||
'sale_type': sale_type,
|
||||
@@ -514,6 +553,342 @@ def process_sale(conn, sale_data):
|
||||
}
|
||||
|
||||
|
||||
def create_remission_note(conn, data):
|
||||
"""Create a counter remission note: reserve stock, no payment, pending status.
|
||||
|
||||
data: {
|
||||
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
|
||||
customer_id: int | null,
|
||||
notes: str,
|
||||
branch_id: int,
|
||||
register_id: int | null (optional)
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
items = data.get('items', [])
|
||||
customer_id = data.get('customer_id')
|
||||
notes = data.get('notes')
|
||||
branch_id = data.get('branch_id') or _safe_g('branch_id')
|
||||
register_id = data.get('register_id')
|
||||
employee_id = _safe_g('employee_id')
|
||||
currency = data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
raise ValueError("Unsupported currency")
|
||||
exchange_rate = float(data.get('exchange_rate') or 1.0)
|
||||
if currency != 'MXN' and not exchange_rate:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
|
||||
if not branch_id:
|
||||
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
branch_id = row[0] if row else None
|
||||
if not branch_id:
|
||||
raise ValueError("No hay sucursal activa disponible")
|
||||
|
||||
if not items:
|
||||
raise ValueError("No items in remission note")
|
||||
|
||||
inv_ids = [item.get('inventory_id') for item in items]
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id, retail_price
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
""", (inv_ids,))
|
||||
inv_rows = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock
|
||||
WHERE branch_id = %s AND inventory_id = ANY(%s)
|
||||
FOR UPDATE
|
||||
""", (branch_id, inv_ids))
|
||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
|
||||
enriched_items = []
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if qty <= 0:
|
||||
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
||||
inv = inv_rows.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||
current_stock = stock_map.get(inv_id, 0)
|
||||
unit_price = float(item.get('unit_price', inv[4]))
|
||||
discount_pct = float(item.get('discount_pct', 0))
|
||||
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
|
||||
unit_cost = float(inv[3]) if inv[3] else 0
|
||||
enriched_items.append({
|
||||
'inventory_id': inv_id,
|
||||
'part_number': inv[1],
|
||||
'name': inv[2],
|
||||
'quantity': qty,
|
||||
'unit_price': unit_price,
|
||||
'unit_cost': unit_cost,
|
||||
'discount_pct': discount_pct,
|
||||
'tax_rate': tax_rate,
|
||||
'branch_id': inv[8],
|
||||
'stock_before': current_stock,
|
||||
})
|
||||
|
||||
totals = calculate_totals(enriched_items)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sales
|
||||
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, status, device_id, notes, currency, exchange_rate,
|
||||
courier_id)
|
||||
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
|
||||
%s, %s, %s, %s, 0, 0, 'pending_payment',
|
||||
%s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, customer_id, employee_id, register_id,
|
||||
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
|
||||
_safe_g('device_id'), notes, currency, exchange_rate,
|
||||
data.get('courier_id')
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
|
||||
sale_items_data = []
|
||||
for item in totals['items']:
|
||||
inv = inv_rows.get(item['inventory_id'])
|
||||
retail_price = inv[9] if inv else None
|
||||
sale_items_data.append((
|
||||
sale_id, item['inventory_id'], item['part_number'], item['name'],
|
||||
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
|
||||
item['discount_pct'], item['discount_amount'],
|
||||
item['tax_rate'], item['tax_amount'], item['subtotal'],
|
||||
retail_price, currency, exchange_rate
|
||||
))
|
||||
|
||||
cur.executemany("""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, unit_cost, discount_pct, discount_amount,
|
||||
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", sale_items_data)
|
||||
|
||||
sale_items = []
|
||||
for item in totals['items']:
|
||||
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
|
||||
remaining_after = stock_before - item['quantity']
|
||||
inventory_record_reservation(
|
||||
conn,
|
||||
item['inventory_id'],
|
||||
item.get('branch_id', branch_id),
|
||||
item['quantity'],
|
||||
sale_id=sale_id,
|
||||
cost_at_time=item.get('unit_cost'),
|
||||
remaining_stock=remaining_after
|
||||
)
|
||||
sale_items.append({
|
||||
'inventory_id': item['inventory_id'],
|
||||
'part_number': item['part_number'],
|
||||
'name': item['name'],
|
||||
'quantity': item['quantity'],
|
||||
'unit_price': item['unit_price'],
|
||||
'unit_cost': item.get('unit_cost', 0),
|
||||
'discount_pct': item['discount_pct'],
|
||||
'discount_amount': item['discount_amount'],
|
||||
'tax_rate': item['tax_rate'],
|
||||
'tax_amount': item['tax_amount'],
|
||||
'subtotal': item['subtotal'],
|
||||
})
|
||||
|
||||
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
|
||||
new_value={
|
||||
'total': totals['total'],
|
||||
'items_count': len(sale_items),
|
||||
'customer_id': customer_id,
|
||||
})
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
'employee_id': employee_id,
|
||||
'register_id': register_id,
|
||||
'sale_type': 'counter_remission',
|
||||
'payment_method': 'remission',
|
||||
'subtotal': totals['subtotal'],
|
||||
'discount_total': totals['discount_total'],
|
||||
'tax_total': totals['tax_total'],
|
||||
'total': totals['total'],
|
||||
'amount_paid': 0.0,
|
||||
'change_given': 0.0,
|
||||
'status': 'pending_payment',
|
||||
'courier_id': data.get('courier_id'),
|
||||
'items': sale_items,
|
||||
'created_at': str(created_at),
|
||||
'currency': currency,
|
||||
'exchange_rate': exchange_rate,
|
||||
}
|
||||
|
||||
|
||||
def pay_pending_sale(conn, sale_id, data):
|
||||
"""Pay a pending counter remission note and convert it into a completed sale.
|
||||
|
||||
data: {
|
||||
payment_method: str,
|
||||
amount_paid: float,
|
||||
payment_details: [{method, amount, reference}],
|
||||
register_id: int,
|
||||
reference: str
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||
currency, exchange_rate
|
||||
FROM sales WHERE id = %s
|
||||
FOR UPDATE
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Sale not found")
|
||||
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||
currency, exchange_rate) = row
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
if status != 'pending_payment':
|
||||
raise ValueError("La nota no esta pendiente de pago")
|
||||
|
||||
payment_method = data.get('payment_method', 'efectivo')
|
||||
sale_type = 'cash'
|
||||
amount_paid = float(data.get('amount_paid', 0))
|
||||
payment_details = data.get('payment_details', [])
|
||||
register_id = data.get('register_id')
|
||||
reference = data.get('reference', '')
|
||||
|
||||
if register_id:
|
||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
|
||||
reg = cur.fetchone()
|
||||
if not reg or reg[0] != 'open':
|
||||
raise ValueError("Cash register is not open")
|
||||
|
||||
totals = {'total': float(total)}
|
||||
change_given = 0.0
|
||||
if payment_method == 'efectivo':
|
||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||
|
||||
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
cur.execute("""
|
||||
UPDATE sales
|
||||
SET status = 'completed',
|
||||
sale_type = %s,
|
||||
payment_method = %s,
|
||||
amount_paid = %s,
|
||||
change_given = %s,
|
||||
register_id = COALESCE(%s, register_id),
|
||||
metodo_pago_sat = 'PUE',
|
||||
forma_pago_sat = %s
|
||||
WHERE id = %s
|
||||
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
|
||||
|
||||
if payment_details:
|
||||
for pd in payment_details:
|
||||
method = pd.get('method', payment_method)
|
||||
amt = float(pd.get('amount', 0))
|
||||
ref = pd.get('reference', '')
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
|
||||
|
||||
# Release reservation and record actual sale movement
|
||||
cur.execute("""
|
||||
SELECT inventory_id, quantity, unit_cost
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
for inv_id, qty, cost in cur.fetchall():
|
||||
inventory_release_reservation(
|
||||
conn, inv_id, branch_id, qty, sale_id=sale_id,
|
||||
notes='Pago de nota de remision'
|
||||
)
|
||||
inventory_record_sale(
|
||||
conn, inv_id, branch_id, qty,
|
||||
sale_id=sale_id, cost_at_time=float(cost) if cost else None
|
||||
)
|
||||
|
||||
# Accounting (non-blocking)
|
||||
try:
|
||||
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
|
||||
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
|
||||
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(unit_cost * quantity), 0)
|
||||
FROM sale_items WHERE sale_id = %s
|
||||
""", (sale_id,))
|
||||
cost_total = float(cur.fetchone()[0] or 0)
|
||||
record_sale_entry(conn, {
|
||||
'id': sale_id,
|
||||
'sale_type': sale_type,
|
||||
'total': total_mxn,
|
||||
'tax_total': tax_mxn,
|
||||
'subtotal': sub_mxn,
|
||||
'cost_total': cost_total,
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
|
||||
old_value={'status': 'pending_payment', 'total': totals['total']},
|
||||
new_value={'status': 'completed', 'payment_method': payment_method})
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'id': sale_id,
|
||||
'status': 'completed',
|
||||
'payment_method': payment_method,
|
||||
'amount_paid': amount_paid,
|
||||
'change_given': change_given,
|
||||
'total': totals['total'],
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
}
|
||||
|
||||
|
||||
def cancel_sale(conn, sale_id, reason):
|
||||
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
||||
|
||||
@@ -555,15 +930,15 @@ def cancel_sale(conn, sale_id, reason):
|
||||
if s_status == 'cancelled':
|
||||
raise ValueError("Sale is already cancelled")
|
||||
|
||||
# Permission check: cashiers can only cancel own sales within 30 min
|
||||
# Permission check: non-admin employees can only cancel their own docs within 30 min
|
||||
role = _safe_g('employee_role', 'cashier')
|
||||
emp_id = _safe_g('employee_id')
|
||||
|
||||
if role == 'cashier':
|
||||
if role not in ('owner', 'admin'):
|
||||
if s_emp_id != emp_id:
|
||||
raise ValueError("Cashiers can only cancel their own sales")
|
||||
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
|
||||
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
||||
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
|
||||
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
|
||||
|
||||
# Get sale items for inventory reversal
|
||||
cur.execute("""
|
||||
@@ -572,14 +947,23 @@ def cancel_sale(conn, sale_id, reason):
|
||||
""", (sale_id,))
|
||||
sale_items = cur.fetchall()
|
||||
|
||||
# Reverse inventory: create RETURN operations (positive quantity)
|
||||
from services.inventory_engine import record_return
|
||||
for inv_id, qty, cost in sale_items:
|
||||
record_return(
|
||||
conn, inv_id, s_branch, qty,
|
||||
sale_id=sale_id,
|
||||
notes=f"Cancelacion venta #{sale_id}: {reason}"
|
||||
)
|
||||
if s_status == 'pending_payment':
|
||||
# Pending remission: release reservation
|
||||
for inv_id, qty, cost in sale_items:
|
||||
inventory_release_reservation(
|
||||
conn, inv_id, s_branch, qty,
|
||||
sale_id=sale_id,
|
||||
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
|
||||
)
|
||||
else:
|
||||
# Completed sale: create RETURN operations (positive quantity)
|
||||
from services.inventory_engine import record_return
|
||||
for inv_id, qty, cost in sale_items:
|
||||
record_return(
|
||||
conn, inv_id, s_branch, qty,
|
||||
sale_id=sale_id,
|
||||
notes=f"Cancelacion venta #{sale_id}: {reason}"
|
||||
)
|
||||
|
||||
# Update sale status
|
||||
cur.execute("""
|
||||
@@ -615,7 +999,7 @@ def cancel_sale(conn, sale_id, reason):
|
||||
|
||||
# Audit log
|
||||
log_action(conn, 'CANCEL', 'sale', sale_id,
|
||||
old_value={'status': 'completed', 'total': float(s_total)},
|
||||
old_value={'status': s_status, 'total': float(s_total)},
|
||||
new_value={'status': 'cancelled', 'reason': reason})
|
||||
|
||||
# Push notification to owner/admin (best-effort, non-blocking)
|
||||
|
||||
@@ -8,23 +8,66 @@ 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 = {
|
||||
'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': [],
|
||||
status: [s for s in ORDER_STATUSES if s != status]
|
||||
for status in ORDER_STATUSES
|
||||
}
|
||||
for terminal in TERMINAL_STATUSES:
|
||||
VALID_TRANSITIONS[terminal] = []
|
||||
|
||||
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
|
||||
_LEGACY_STATUSES = {
|
||||
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
|
||||
}
|
||||
for legacy in _LEGACY_STATUSES:
|
||||
VALID_TRANSITIONS.setdefault(legacy, [])
|
||||
|
||||
|
||||
def _generate_order_number(conn):
|
||||
"""Generate SO-YYYY-NNNN order number."""
|
||||
"""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.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
year = datetime.utcnow().year
|
||||
prefix = f"SO-{year}-"
|
||||
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,))
|
||||
cur.execute("""
|
||||
SELECT order_number FROM service_orders
|
||||
WHERE order_number LIKE %s
|
||||
@@ -37,7 +80,19 @@ def _generate_order_number(conn):
|
||||
last_num = int(row[0].split('-')[-1])
|
||||
new_num = last_num + 1
|
||||
cur.close()
|
||||
return f"{prefix}{new_num:04d}"
|
||||
return f"{prefix}{new_num}"
|
||||
|
||||
|
||||
_VALID_DELIVERY_METHODS = {'pickup', 'delivery', 'courier'}
|
||||
|
||||
|
||||
def _normalize_delivery(data):
|
||||
"""Restrict delivery_method to allowed values and clear courier when not applicable."""
|
||||
dm = data.get('delivery_method')
|
||||
if dm not in _VALID_DELIVERY_METHODS:
|
||||
data['delivery_method'] = None
|
||||
if data.get('delivery_method') not in ('delivery', 'courier'):
|
||||
data['courier_id'] = None
|
||||
|
||||
|
||||
def create_service_order(conn, data):
|
||||
@@ -46,9 +101,11 @@ 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
|
||||
employee_id, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct, requires_invoice
|
||||
}
|
||||
"""
|
||||
_normalize_delivery(data)
|
||||
cur = conn.cursor()
|
||||
order_number = _generate_order_number(conn)
|
||||
|
||||
@@ -56,16 +113,23 @@ def create_service_order(conn, data):
|
||||
INSERT INTO service_orders
|
||||
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
||||
priority, reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
employee_id, mechanic_name, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct, requires_invoice,
|
||||
workshop_name, customer_address, customer_phone, vehicle_description)
|
||||
VALUES (%s, %s, %s, %s, %s, 'por_revisar', %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
||||
data.get('vehicle_id'), order_number,
|
||||
data.get('priority', 'normal'), data.get('reception_notes'),
|
||||
data.get('estimated_cost'), data.get('estimated_completion'),
|
||||
data.get('employee_id'), data.get('mileage_in'),
|
||||
data.get('fuel_level'), data.get('created_by'),
|
||||
data.get('employee_id'), data.get('mechanic_name'),
|
||||
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'),
|
||||
data.get('delivery_method'), data.get('courier_id'),
|
||||
data.get('is_direct', False), data.get('requires_invoice', False),
|
||||
data.get('workshop_name'), data.get('customer_address'),
|
||||
data.get('customer_phone'), data.get('vehicle_description'),
|
||||
))
|
||||
so_id = cur.fetchone()[0]
|
||||
|
||||
@@ -73,7 +137,7 @@ def create_service_order(conn, data):
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_status_history
|
||||
(service_order_id, new_status, changed_by, notes)
|
||||
VALUES (%s, 'received', %s, 'Orden creada')
|
||||
VALUES (%s, 'por_revisar', %s, 'Orden creada')
|
||||
""", (so_id, data.get('created_by')))
|
||||
|
||||
conn.commit()
|
||||
@@ -86,18 +150,28 @@ 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, so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
||||
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.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.created_by, so.created_at, so.updated_at
|
||||
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
|
||||
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
|
||||
WHERE so.id = %s
|
||||
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
|
||||
""", (so_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
@@ -107,35 +181,51 @@ 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],
|
||||
'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]),
|
||||
'customer_address': row[7], 'customer_price_tier': row[8],
|
||||
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12],
|
||||
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16],
|
||||
'reception_notes': row[17], 'diagnosis_notes': row[18],
|
||||
'repair_notes': row[19], 'delivery_notes': row[20],
|
||||
'estimated_cost': float(row[21]) if row[21] else None,
|
||||
'final_cost': float(row[22]) if row[22] else None,
|
||||
'estimated_completion': str(row[23]) if row[23] else None,
|
||||
'actual_completion': str(row[24]) if row[24] else None,
|
||||
'delivered_at': str(row[25]) if row[25] else None,
|
||||
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28],
|
||||
'employee_id': row[29], 'employee_name': row[30],
|
||||
'mechanic_name': row[31],
|
||||
'created_by': row[32], 'created_by_name': row[33],
|
||||
'created_at': str(row[34]), 'updated_at': str(row[35]),
|
||||
'delivery_method': row[36], 'courier_id': row[37], 'courier_name': row[38],
|
||||
'is_direct': bool(row[39]) if row[39] is not None else False,
|
||||
'requires_invoice': bool(row[40]) if row[40] is not None else False,
|
||||
'sale_id': row[41],
|
||||
'workshop_name': row[42], 'customer_address': row[43],
|
||||
'customer_phone': row[44], 'vehicle_description': row[45],
|
||||
}
|
||||
|
||||
# Items
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||
mechanic_id, observations
|
||||
FROM service_order_items
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY id
|
||||
""", (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': float(r[4]) if r[4] else 0,
|
||||
'quantity': qty,
|
||||
'unit_cost': float(r[5]) if r[5] else None,
|
||||
'unit_price': float(r[6]) if r[6] else None,
|
||||
'unit_price': price,
|
||||
'status': r[7], 'notes': r[8],
|
||||
'mechanic_id': r[9], 'observations': r[10],
|
||||
})
|
||||
total_parts += qty * price
|
||||
|
||||
# Labor
|
||||
cur.execute("""
|
||||
@@ -145,27 +235,37 @@ 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': float(r[4]) if r[4] else 0,
|
||||
'total_cost': total,
|
||||
'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 id, old_status, new_status, changed_by, notes, created_at
|
||||
FROM service_order_status_history
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY created_at
|
||||
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
|
||||
""", (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], 'notes': r[4], 'created_at': str(r[5]),
|
||||
'changed_by': r[3], 'changed_by_name': r[4],
|
||||
'notes': r[5], 'created_at': str(r[6]),
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -173,7 +273,8 @@ 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, page=1, per_page=50):
|
||||
priority=None, employee_id=None, delivery_method=None,
|
||||
is_direct=None, q=None, page=1, per_page=50):
|
||||
cur = conn.cursor()
|
||||
where_clauses = []
|
||||
params = []
|
||||
@@ -193,22 +294,49 @@ 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 = " AND ".join(where_clauses) if where_clauses else "true"
|
||||
where_clauses.append("so.is_deleted = false")
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT count(*) FROM service_orders so WHERE {where}
|
||||
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}
|
||||
""", 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,
|
||||
so.estimated_cost, so.estimated_completion, so.created_at
|
||||
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
|
||||
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
|
||||
@@ -223,13 +351,25 @@ 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],
|
||||
'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]),
|
||||
'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
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -243,7 +383,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", (so_id,))
|
||||
cur.execute("SELECT status FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
@@ -257,9 +397,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
||||
# Update status
|
||||
extra_sets = []
|
||||
extra_vals = []
|
||||
if new_status == 'ready':
|
||||
if new_status == 'reparada':
|
||||
extra_sets.append("actual_completion = NOW()")
|
||||
if new_status == 'delivered':
|
||||
if new_status == 'entregado':
|
||||
extra_sets.append("delivered_at = NOW()")
|
||||
extra_sets.append("delivered_by = %s")
|
||||
extra_vals.append(changed_by)
|
||||
@@ -288,14 +428,16 @@ def add_item(conn, so_id, item_data):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_items
|
||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||
mechanic_id, observations)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
||||
item_data.get('name'), item_data.get('quantity', 1),
|
||||
item_data.get('unit_cost'), item_data.get('unit_price'),
|
||||
item_data.get('status', 'pending'), item_data.get('notes'),
|
||||
item_data.get('mechanic_id'), item_data.get('observations'),
|
||||
))
|
||||
item_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
@@ -305,7 +447,7 @@ def add_item(conn, so_id, item_data):
|
||||
|
||||
def update_item(conn, item_id, data):
|
||||
cur = conn.cursor()
|
||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
|
||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
@@ -387,10 +529,14 @@ def remove_labor(conn, labor_id):
|
||||
|
||||
def update_service_order(conn, so_id, data):
|
||||
"""Update general service order fields."""
|
||||
_normalize_delivery(data)
|
||||
cur = conn.cursor()
|
||||
allowed = ['priority', 'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
|
||||
'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
||||
'employee_id', 'mileage_out', 'fuel_level', 'final_cost']
|
||||
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
||||
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
|
||||
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
@@ -419,19 +565,20 @@ def get_kanban_summary(conn, branch_id=None):
|
||||
cur.execute(f"""
|
||||
SELECT status, COUNT(*) as cnt
|
||||
FROM service_orders
|
||||
WHERE status != 'cancelled' {branch_filter}
|
||||
WHERE status != 'cancelled' AND is_deleted = false {branch_filter}
|
||||
GROUP BY status
|
||||
""", params)
|
||||
|
||||
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
|
||||
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
|
||||
for r in cur.fetchall():
|
||||
summary[r[0]] = r[1]
|
||||
|
||||
# Overdue orders (estimated_completion passed and not ready/delivered)
|
||||
# Overdue orders (estimated_completion passed and not delivered/invoiced)
|
||||
cur.execute(f"""
|
||||
SELECT count(*) FROM service_orders
|
||||
WHERE estimated_completion < NOW()
|
||||
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
||||
AND status NOT IN ('entregado', 'facturada', 'cancelada')
|
||||
AND is_deleted = false
|
||||
{branch_filter}
|
||||
""", params)
|
||||
overdue = cur.fetchone()[0]
|
||||
@@ -454,7 +601,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.order_number, so.branch_id
|
||||
FROM service_order_items soi
|
||||
JOIN service_orders so ON so.id = soi.service_order_id
|
||||
WHERE soi.id = %s
|
||||
@@ -466,7 +613,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 = row
|
||||
so_id, inventory_id, quantity, status, order_number, branch_id = row
|
||||
if status == "cancelled":
|
||||
cur.close()
|
||||
raise ValueError("Cannot reserve a cancelled item")
|
||||
@@ -475,10 +622,11 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
|
||||
raise ValueError("Item has no inventory linked")
|
||||
|
||||
qty = int(quantity)
|
||||
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
|
||||
if available < qty:
|
||||
cur.close()
|
||||
raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
|
||||
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}")
|
||||
|
||||
inventory_engine.record_operation(
|
||||
conn,
|
||||
@@ -743,10 +891,156 @@ 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", (so_id,))
|
||||
cur.execute("SELECT id FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
|
||||
if not cur.fetchone():
|
||||
cur.close()
|
||||
raise ValueError("Service order not found")
|
||||
@@ -760,6 +1054,21 @@ 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) ───────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -247,7 +247,8 @@ 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'
|
||||
'config.view', 'config.edit', 'config.edit_prices',
|
||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'
|
||||
]
|
||||
tenant_cur.executemany(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",
|
||||
|
||||
@@ -16,9 +16,13 @@ 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):
|
||||
@@ -34,6 +38,8 @@ 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
|
||||
@@ -100,7 +106,10 @@ 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 += PARTIAL_CUT
|
||||
buf += LINE_SPACING_DEFAULT
|
||||
buf += EMPH_OFF
|
||||
buf += FEED + b'\x05' # Feed 5 lines before cutting
|
||||
buf += CUT # Full cut
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
@@ -119,6 +128,8 @@ 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
|
||||
@@ -187,7 +198,10 @@ 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 += PARTIAL_CUT
|
||||
buf += LINE_SPACING_DEFAULT
|
||||
buf += EMPH_OFF
|
||||
buf += FEED + b'\x05'
|
||||
buf += CUT
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
@@ -224,6 +238,8 @@ 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
|
||||
@@ -338,6 +354,9 @@ 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 += PARTIAL_CUT
|
||||
buf += LINE_SPACING_DEFAULT
|
||||
buf += EMPH_OFF
|
||||
buf += FEED + b'\x05'
|
||||
buf += CUT
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
@@ -1243,3 +1243,45 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1186,6 +1186,8 @@
|
||||
.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 {
|
||||
|
||||
@@ -1209,6 +1209,16 @@
|
||||
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;
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
.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
|
||||
|
||||
@@ -713,18 +713,23 @@
|
||||
}
|
||||
|
||||
.pago-tabs {
|
||||
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: none;
|
||||
}
|
||||
.pago-tab {
|
||||
padding: var(--space-3) var(--space-5); font-family: var(--font-body);
|
||||
font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
|
||||
background: transparent; border: none; color: var(--color-text-muted);
|
||||
cursor: pointer; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px; transition: var(--transition-fast);
|
||||
display: flex; align-items: center; gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-2); font-family: var(--font-body);
|
||||
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
|
||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md); color: var(--color-text-muted);
|
||||
cursor: pointer; transition: var(--transition-fast);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
text-align: center; min-height: 44px;
|
||||
}
|
||||
.pago-tab:hover { color: var(--color-text-primary); }
|
||||
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
|
||||
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
|
||||
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||
|
||||
.tab-content { padding: var(--space-6); display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
@@ -901,8 +906,10 @@
|
||||
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: 302px; }
|
||||
.ticket-80 { width: 80mm; max-width: 80mm; }
|
||||
.ticket-58 { width: 48mm; max-width: 48mm; }
|
||||
.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; }
|
||||
@@ -921,17 +928,26 @@
|
||||
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; }
|
||||
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: break-word; }
|
||||
.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 {
|
||||
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; }
|
||||
@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; }
|
||||
.ticket .ticket-row, .ticket .folio-line, .ticket .total-line { display: flex !important; }
|
||||
.ticket .name { white-space: normal !important; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
|
||||
195
pos/static/css/remission_notes.css
Normal file
195
pos/static/css/remission_notes.css
Normal file
@@ -0,0 +1,195 @@
|
||||
/* Remission notes page — matches the Nexus POS design system */
|
||||
|
||||
/* Scrollable page content area */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
.page-content::-webkit-scrollbar { width: 6px; }
|
||||
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
|
||||
|
||||
/* Filters card */
|
||||
.filters-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
[data-theme="modern"] .filters-card {
|
||||
background: var(--color-bg-overlay);
|
||||
}
|
||||
|
||||
/* Date input reuse select-filter styling */
|
||||
.select-filter[type="date"] {
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
/* Status badges for remission notes */
|
||||
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
|
||||
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
|
||||
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
|
||||
|
||||
/* Action buttons in table */
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-2);
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.action-btn--ghost {
|
||||
background: var(--btn-ghost-bg);
|
||||
color: var(--btn-ghost-text);
|
||||
border-color: var(--btn-ghost-border);
|
||||
}
|
||||
.action-btn--ghost:hover {
|
||||
background: var(--color-surface-2);
|
||||
border-color: var(--color-border-strong);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.action-btn--primary {
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border-color: var(--btn-primary-border);
|
||||
}
|
||||
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
padding: var(--space-10) var(--space-6);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal, 1050);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.modal-overlay.is-open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
[data-theme="modern"] .modal {
|
||||
background: var(--color-bg-overlay);
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.modal__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-h6);
|
||||
font-weight: var(--heading-weight-primary);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
.modal__close:hover { color: var(--color-text-primary); }
|
||||
|
||||
.modal__body {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Ticket preview (monospace) */
|
||||
.ticket-preview {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.ticket-preview__center { text-align: center; }
|
||||
.ticket-preview__bold { font-weight: 700; }
|
||||
.ticket-preview__line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.ticket-preview__divider {
|
||||
border-top: 1px dashed var(--color-border-strong);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
.ticket-preview__items { margin: var(--space-3) 0; }
|
||||
.ticket-preview__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.ticket-preview__footer {
|
||||
text-align: center;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.page-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.search-box, .select-filter {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.toolbar__spacer { display: none; }
|
||||
}
|
||||
@@ -767,3 +767,257 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
1
pos/static/js/accounting.min.js
vendored
1
pos/static/js/accounting.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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,7 +14,8 @@ const Accounting = (() => {
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
||||
const url = path.startsWith('/pos/api/') ? path : `${API}${path}`;
|
||||
const res = await fetch(url, { headers: headers(), ...opts });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || 'Request failed');
|
||||
@@ -25,6 +26,12 @@ 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() {
|
||||
@@ -35,6 +42,30 @@ 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 => {
|
||||
@@ -90,25 +121,36 @@ const Accounting = (() => {
|
||||
|
||||
try {
|
||||
const res = await api('/aging');
|
||||
const rows = res.data || [];
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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.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';
|
||||
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';
|
||||
return `<tr>
|
||||
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
||||
<td class="td--primary">${r.name || r.customer_name || '-'}</td>
|
||||
<td class="td--primary">${r.customer_name || r.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">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
|
||||
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.showReceivableDetail(${r.sale_id})">${esc(actionLabel)}</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -123,6 +165,90 @@ 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()">✕</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');
|
||||
@@ -133,25 +259,35 @@ const Accounting = (() => {
|
||||
try {
|
||||
// Use accounts endpoint filtered for payables or a dedicated endpoint
|
||||
const res = await api('/aging?type=payable');
|
||||
const rows = res.data || [];
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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.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';
|
||||
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');
|
||||
return `<tr>
|
||||
<td class="td--mono">${r.invoice || r.folio || '-'}</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 class="td--primary">${r.vendor_name || r.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">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
|
||||
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.registerPayablePayment(${r.id || 0})">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -384,6 +520,8 @@ 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);
|
||||
@@ -423,6 +561,27 @@ 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');
|
||||
@@ -448,13 +607,20 @@ 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 =
|
||||
'<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);" />' +
|
||||
accountSelectHtml() +
|
||||
'<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()">×</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;
|
||||
@@ -468,16 +634,16 @@ const Accounting = (() => {
|
||||
|
||||
const lines = [];
|
||||
document.querySelectorAll('#entryLines .entry-line').forEach(row => {
|
||||
const account = row.querySelector('.entry-account').value.trim();
|
||||
const accountId = row.querySelector('.entry-account').value;
|
||||
const debit = parseFloat(row.querySelector('.entry-debit').value) || 0;
|
||||
const credit = parseFloat(row.querySelector('.entry-credit').value) || 0;
|
||||
if (account && (debit || credit)) {
|
||||
lines.push({ account, debit, credit });
|
||||
if (accountId && (debit || credit)) {
|
||||
lines.push({ account_id: parseInt(accountId, 10), debit, credit });
|
||||
}
|
||||
});
|
||||
|
||||
if (!lines.length) {
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos una partida.</span>';
|
||||
if (lines.length < 2) {
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos dos partidas.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -495,17 +661,23 @@ 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: "🛒" });
|
||||
@@ -514,4 +686,13 @@ 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;
|
||||
})();
|
||||
27
pos/static/js/api.js
Normal file
27
pos/static/js/api.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// /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;
|
||||
}
|
||||
@@ -43,7 +43,8 @@
|
||||
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')
|
||||
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant'),
|
||||
'workshop': 'Taller', 'mechanic': 'Mecanico'
|
||||
};
|
||||
var roleLabel = roleLabels[role] || role;
|
||||
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
|
||||
@@ -117,6 +118,7 @@
|
||||
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';
|
||||
};
|
||||
|
||||
@@ -180,6 +182,147 @@
|
||||
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', {
|
||||
@@ -197,6 +340,16 @@
|
||||
}).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) {
|
||||
@@ -207,4 +360,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
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 = [];
|
||||
|
||||
@@ -105,16 +108,22 @@ const Config = (() => {
|
||||
owner: 'Dueno',
|
||||
admin: 'Admin',
|
||||
cashier: 'Cajero',
|
||||
counter: 'Mostrador',
|
||||
warehouse: 'Almacenista',
|
||||
accountant: 'Contador'
|
||||
accountant: 'Contador',
|
||||
workshop: 'Taller',
|
||||
mechanic: 'Mecanico'
|
||||
};
|
||||
|
||||
var ROLE_BADGE = {
|
||||
owner: 'badge--owner',
|
||||
admin: 'badge--blue',
|
||||
cashier: 'badge--green',
|
||||
counter: 'badge--gray',
|
||||
warehouse: 'badge--yellow',
|
||||
accountant: 'badge--purple'
|
||||
accountant: 'badge--purple',
|
||||
workshop: 'badge--orange',
|
||||
mechanic: 'badge--teal'
|
||||
};
|
||||
|
||||
function roleBadge(role) {
|
||||
@@ -179,6 +188,7 @@ 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>';
|
||||
});
|
||||
|
||||
@@ -234,6 +244,22 @@ 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 : '');
|
||||
@@ -294,7 +320,10 @@ 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></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>'
|
||||
+ '</tr>';
|
||||
});
|
||||
|
||||
@@ -304,7 +333,7 @@ const Config = (() => {
|
||||
|
||||
async function saveEmployee(data) {
|
||||
// Check if we're editing (modal has editId) or creating
|
||||
var modal = document.getElementById('employee-modal');
|
||||
var modal = document.getElementById('modal-employee');
|
||||
var editId = modal ? modal.dataset.editId : null;
|
||||
var url = API + '/employees';
|
||||
var method = 'POST';
|
||||
@@ -358,6 +387,21 @@ 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
|
||||
@@ -368,24 +412,25 @@ const Config = (() => {
|
||||
var emp = (json.data || []).find(function(e) { return e.id === empId; });
|
||||
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
|
||||
|
||||
// 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');
|
||||
// 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');
|
||||
if (roleSelect) roleSelect.value = emp.role || 'cashier';
|
||||
var branchSelect = document.getElementById('new-emp-branch');
|
||||
var branchSelect = document.getElementById('emp-branch');
|
||||
if (branchSelect) branchSelect.value = emp.branch_id || '';
|
||||
setVal('new-emp-discount', emp.max_discount_pct || '');
|
||||
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
|
||||
setVal('emp-discount', emp.max_discount_pct || '');
|
||||
setVal('emp-pin', ''); // Don't pre-fill PIN for security
|
||||
|
||||
// Store the ID so saveEmployee knows it's an update
|
||||
var modal = document.getElementById('employee-modal');
|
||||
var modal = document.getElementById('modal-employee');
|
||||
if (modal) {
|
||||
modal.dataset.editId = empId;
|
||||
var title = modal.querySelector('.modal-title, h3');
|
||||
if (title) title.textContent = 'Editar Empleado';
|
||||
}
|
||||
openModal('employee-modal');
|
||||
openModal('modal-employee');
|
||||
} catch (e) {
|
||||
toast('Error: ' + e.message, 'error');
|
||||
}
|
||||
@@ -442,6 +487,156 @@ 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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -506,6 +701,12 @@ 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');
|
||||
});
|
||||
}
|
||||
@@ -529,6 +730,7 @@ const Config = (() => {
|
||||
|
||||
btnSaveEmp.disabled = true;
|
||||
btnSaveEmp.textContent = 'Guardando...';
|
||||
var isEdit = !!document.getElementById('modal-employee').dataset.editId;
|
||||
try {
|
||||
await saveEmployee({
|
||||
name: name,
|
||||
@@ -539,7 +741,7 @@ const Config = (() => {
|
||||
branch_id: branchId ? parseInt(branchId, 10) : null,
|
||||
max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0
|
||||
});
|
||||
toast('Empleado creado');
|
||||
toast(isEdit ? 'Empleado actualizado' : 'Empleado creado');
|
||||
closeModal('modal-employee');
|
||||
// Reset form
|
||||
document.getElementById('emp-name').value = '';
|
||||
@@ -756,25 +958,42 @@ 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 btn = event.target;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||
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;
|
||||
try {
|
||||
var data = {
|
||||
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,
|
||||
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,
|
||||
};
|
||||
var res = await fetch(API + '/modules', {
|
||||
method: 'PUT',
|
||||
@@ -786,14 +1005,257 @@ 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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -815,6 +1277,10 @@ 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) {
|
||||
@@ -842,6 +1308,11 @@ const Config = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Show permissions tab only for owner/admin
|
||||
var isAdmin = user.role === 'owner' || user.role === 'admin';
|
||||
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
|
||||
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
|
||||
|
||||
// Load real data in parallel
|
||||
loadBranches();
|
||||
loadEmployees();
|
||||
@@ -850,18 +1321,24 @@ 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: "🛒" });
|
||||
@@ -870,4 +1347,18 @@ 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
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -5,12 +5,20 @@
|
||||
* 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
|
||||
});
|
||||
@@ -23,7 +31,7 @@ const Customers = (() => {
|
||||
};
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
@@ -43,6 +51,9 @@ 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>';
|
||||
@@ -82,14 +93,27 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +160,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.openCreateModal()">Nuevo cliente</button>'
|
||||
action: '<button class="btn btn--primary btn--sm" onclick="Customers.showCreateModal()">Nuevo cliente</button>'
|
||||
}) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
@@ -279,6 +303,28 @@ 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í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í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();
|
||||
|
||||
@@ -380,15 +426,106 @@ 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
|
||||
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial, Eliminar
|
||||
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) selectCustomer(currentCustomer.id);
|
||||
if (currentCustomer) showCustomerHistory(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()">×</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 ───────────────
|
||||
@@ -580,7 +717,7 @@ const Customers = (() => {
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/payment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount, method, reference }),
|
||||
body: JSON.stringify({ amount, payment_method: method, reference }),
|
||||
});
|
||||
closePayment();
|
||||
selectCustomer(currentCustomer.id);
|
||||
@@ -599,7 +736,7 @@ const Customers = (() => {
|
||||
// ─── Init ────────────────────────────
|
||||
function init() {
|
||||
// Auth check
|
||||
if (!token) {
|
||||
if (!getToken()) {
|
||||
window.location.href = '/pos/login';
|
||||
return;
|
||||
}
|
||||
@@ -819,8 +956,18 @@ 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);
|
||||
@@ -856,15 +1003,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: "📊" });
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -367,16 +367,51 @@ const Dashboard = (() => {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Top Products (from today's sales detail)
|
||||
// 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> | <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)
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadTopProducts() {
|
||||
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`);
|
||||
// Single optimized endpoint: returns today's top products already aggregated
|
||||
const data = await apiFetch('/pos/api/dashboard/stats');
|
||||
const container = document.getElementById('top-products-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
const top = data && data.top_products ? data.top_products : [];
|
||||
|
||||
if (!top.length) {
|
||||
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',
|
||||
@@ -386,37 +421,7 @@ const Dashboard = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 sorted = top.slice(0, 5);
|
||||
const maxRev = sorted[0].revenue || 1;
|
||||
container.innerHTML = sorted.map((p, i) => {
|
||||
const pct = Math.round((p.revenue / maxRev) * 100);
|
||||
@@ -425,7 +430,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">${escHtml(p.part_number)} · ${p.qty} pzas vendidas</div>
|
||||
<div class="rank-item__sub">${p.quantity} pzas vendidas</div>
|
||||
<div class="rank-item__bar-bg">
|
||||
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
@@ -619,11 +624,12 @@ const Dashboard = (() => {
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadRecentSales() {
|
||||
const today = todayStr();
|
||||
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`);
|
||||
const data = await apiFetch(`/pos/api/sales/recent?date_from=${today}&date_to=${today}&limit=10`);
|
||||
const tbody = document.getElementById('recent-sales-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
const sales = data && data.data ? data.data : [];
|
||||
if (!sales.length) {
|
||||
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',
|
||||
@@ -633,26 +639,22 @@ const Dashboard = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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}`))
|
||||
);
|
||||
const salesToShow = sales.slice(0, 5);
|
||||
|
||||
tbody.innerHTML = salesToShow.map((sale, idx) => {
|
||||
const detail = details[idx];
|
||||
tbody.innerHTML = salesToShow.map((sale) => {
|
||||
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 detail items
|
||||
// Build products summary from items already included in the response
|
||||
let productsSummary = '';
|
||||
if (detail && detail.items && detail.items.length > 0) {
|
||||
productsSummary = detail.items.slice(0, 3).map(it =>
|
||||
const items = sale.items || [];
|
||||
if (items.length > 0) {
|
||||
productsSummary = items.slice(0, 3).map(it =>
|
||||
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
|
||||
).join(', ');
|
||||
if (detail.items.length > 3) productsSummary += '...';
|
||||
if (items.length > 3) productsSummary += '...';
|
||||
}
|
||||
|
||||
const methodClass = getPaymentBadgeClass(method);
|
||||
@@ -701,6 +703,7 @@ const Dashboard = (() => {
|
||||
loadDailySummary();
|
||||
loadHistoricalSummary();
|
||||
loadAlerts();
|
||||
loadCreditAlerts();
|
||||
loadTopProducts();
|
||||
loadChart('semana');
|
||||
loadRecentSales();
|
||||
@@ -709,6 +712,7 @@ const Dashboard = (() => {
|
||||
setInterval(() => {
|
||||
loadDailySummary();
|
||||
loadRecentSales();
|
||||
loadCreditAlerts();
|
||||
}, 120000);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ 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() {
|
||||
@@ -75,9 +85,16 @@ var Fleet = (function() {
|
||||
}
|
||||
|
||||
// New vehicle button
|
||||
document.getElementById('btnNewVehicle').addEventListener('click', function() {
|
||||
openVehicleModal();
|
||||
});
|
||||
var btnNewVehicle = document.getElementById('btnNewVehicle');
|
||||
if (btnNewVehicle) {
|
||||
if (!canCreate) {
|
||||
btnNewVehicle.style.display = 'none';
|
||||
} else {
|
||||
btnNewVehicle.addEventListener('click', function() {
|
||||
openVehicleModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadStats();
|
||||
@@ -105,6 +122,8 @@ 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) {
|
||||
@@ -115,7 +134,7 @@ var Fleet = (function() {
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('vehicleGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
|
||||
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los vehiculos.' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,7 +144,7 @@ var Fleet = (function() {
|
||||
grid.innerHTML = '<div class="empty-state">' +
|
||||
'<div class="empty-state__icon">🚚</div>' +
|
||||
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
|
||||
'<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
|
||||
(canCreate ? '<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' : '') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
@@ -135,7 +154,7 @@ var Fleet = (function() {
|
||||
var label = (v.make || '') + ' ' + (v.model || '');
|
||||
if (v.year) label += ' ' + v.year;
|
||||
|
||||
html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
|
||||
html += '<div class="vehicle-card" ' + (canEdit ? 'onclick="Fleet.viewVehicle(' + v.id + ')" style="cursor:pointer;"' : '') + '>' +
|
||||
'<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') + '">' +
|
||||
@@ -200,9 +219,11 @@ 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 || '';
|
||||
@@ -216,6 +237,7 @@ 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 = ''; });
|
||||
@@ -232,6 +254,8 @@ 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(),
|
||||
@@ -275,26 +299,36 @@ var Fleet = (function() {
|
||||
// ─── Maintenance Tab ───
|
||||
|
||||
function loadMaintenance() {
|
||||
// Load all vehicles with their schedules
|
||||
fetch(API + '/vehicles?per_page=200', {headers: headers()})
|
||||
// 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()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
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 || []};
|
||||
});
|
||||
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
|
||||
}]
|
||||
};
|
||||
});
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then(function(results) {
|
||||
renderMaintenance(results);
|
||||
})
|
||||
.catch(function() {
|
||||
.catch(function(e) {
|
||||
console.error('loadMaintenance error:', e);
|
||||
document.getElementById('maintBody').innerHTML =
|
||||
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
|
||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los programas.' }) + '</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,7 +360,7 @@ var Fleet = (function() {
|
||||
'<td>' + (next || '—') + '</td>' +
|
||||
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
|
||||
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
|
||||
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
|
||||
'<td>' + (canCreate ? '<button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button>' : '') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -344,7 +378,9 @@ 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><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>' +
|
||||
(canCreate ? '<button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button>' : '') +
|
||||
'</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,25 +399,25 @@ var Fleet = (function() {
|
||||
// ─── History Tab ───
|
||||
|
||||
function loadHistory() {
|
||||
fetch(API + '/vehicles?per_page=200', {headers: headers()})
|
||||
// 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()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
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 || []};
|
||||
});
|
||||
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]};
|
||||
});
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then(function(results) {
|
||||
renderHistory(results);
|
||||
})
|
||||
.catch(function() {
|
||||
.catch(function(e) {
|
||||
console.error('loadHistory error:', e);
|
||||
document.getElementById('historyBody').innerHTML =
|
||||
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
|
||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudo cargar el historial.' }) + '</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -437,6 +473,8 @@ 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) {
|
||||
@@ -444,7 +482,7 @@ var Fleet = (function() {
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('alertsList').innerHTML =
|
||||
'<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
|
||||
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar las alertas.' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -475,7 +513,7 @@ var Fleet = (function() {
|
||||
' ' + detail +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
|
||||
(canCreate ? '<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;
|
||||
@@ -504,6 +542,7 @@ 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; }
|
||||
|
||||
@@ -574,6 +613,7 @@ 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; }
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ var I18N = {
|
||||
'catalog': 'Catalogo',
|
||||
'inventory': 'Inventario',
|
||||
'diagrams': 'Diagramas',
|
||||
'remission_notes': 'Notas de Remisión',
|
||||
'customers': 'Clientes',
|
||||
'invoicing': 'Facturacion',
|
||||
'accounting': 'Contabilidad',
|
||||
@@ -166,6 +167,7 @@ var I18N = {
|
||||
'catalog': 'Catalog',
|
||||
'inventory': 'Inventory',
|
||||
'diagrams': 'Diagrams',
|
||||
'remission_notes': 'Remission Notes',
|
||||
'customers': 'Customers',
|
||||
'invoicing': 'Invoicing',
|
||||
'accounting': 'Accounting',
|
||||
@@ -317,7 +319,9 @@ var I18N = {
|
||||
}
|
||||
};
|
||||
|
||||
var currentLang = localStorage.getItem('pos_lang') || 'es';
|
||||
// Spanish is mandatory for all tenants; ignore any previously stored language.
|
||||
var currentLang = 'es';
|
||||
localStorage.setItem('pos_lang', 'es');
|
||||
|
||||
/**
|
||||
* Translate a key to the current language.
|
||||
@@ -328,12 +332,11 @@ window.t = function(key) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch the UI language and reload.
|
||||
* Language switcher is disabled; the system stays in Spanish.
|
||||
*/
|
||||
window.setLang = function(lang) {
|
||||
currentLang = lang;
|
||||
localStorage.setItem('pos_lang', lang);
|
||||
location.reload();
|
||||
currentLang = 'es';
|
||||
localStorage.setItem('pos_lang', 'es');
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,19 +6,38 @@
|
||||
'use strict';
|
||||
|
||||
var API = '/pos/api/inventory';
|
||||
var token = localStorage.getItem('pos_token');
|
||||
if (!token) { window.location.href = '/pos/login'; return; }
|
||||
function getToken() { return localStorage.getItem('pos_token') || ''; }
|
||||
if (!getToken()) { window.location.href = '/pos/login'; return; }
|
||||
|
||||
var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
function authHeaders() { return { 'Authorization': 'Bearer ' + getToken(), '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 ' + token } })
|
||||
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.source) compatSource = d.source;
|
||||
@@ -27,7 +46,7 @@
|
||||
|
||||
// --- API helper ---
|
||||
function apiFetch(url, opts) {
|
||||
return fetch(url, Object.assign({ headers: headers }, opts || {}))
|
||||
return fetch(url, Object.assign({ headers: authHeaders() }, opts || {}))
|
||||
.then(function (resp) {
|
||||
if (resp.status === 401) {
|
||||
localStorage.removeItem('pos_token');
|
||||
@@ -46,6 +65,15 @@
|
||||
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() {
|
||||
@@ -100,7 +128,7 @@
|
||||
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
|
||||
fetch(API + '/tier-discounts', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
@@ -171,10 +199,11 @@
|
||||
'<td>' + esc(it.location) + '</td>' +
|
||||
'<td>' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
|
||||
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
|
||||
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') +
|
||||
(canCreateItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' : '') +
|
||||
(userPerms.indexOf('marketplace.manage') !== -1 || userRole === 'owner' || userRole === 'admin' ? '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' : '') +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
|
||||
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
|
||||
'</td></tr>';
|
||||
}
|
||||
|
||||
@@ -225,20 +254,27 @@
|
||||
var tbody = document.getElementById('productTableBody');
|
||||
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
|
||||
|
||||
apiFetch(API + '/items?' + params.toString()).then(function (data) {
|
||||
if (!data) return;
|
||||
if (inventorySearchController) {
|
||||
inventorySearchController.abort();
|
||||
}
|
||||
inventorySearchController = new AbortController();
|
||||
|
||||
var items = data.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="12">' + renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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>',
|
||||
title: 'Sin productos',
|
||||
subtitle: currentSearch ? 'No se encontraron resultados para "' + esc(currentSearch) + '". Intenta con otro término.' : 'El inventario está vacío. Crea tu primer producto para empezar.',
|
||||
action: currentSearch ? '<button class="btn btn--ghost btn--sm" onclick="document.getElementById(\'productSearch\').value=\'\';loadItems(1,\'\')">Limpiar búsqueda</button>' : '<button class="btn btn--primary btn--sm" onclick="openCreateModal()">Crear producto</button>'
|
||||
}) + '</td></tr>';
|
||||
document.getElementById('productPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
apiFetch(API + '/items?' + params.toString(), { signal: inventorySearchController.signal })
|
||||
.then(function (data) {
|
||||
inventorySearchController = null;
|
||||
if (!data) return;
|
||||
|
||||
var items = data.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="12">' + renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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>',
|
||||
title: 'Sin productos',
|
||||
subtitle: currentSearch ? 'No se encontraron resultados para "' + esc(currentSearch) + '". Intenta con otro término.' : 'El inventario está vacío. Crea tu primer producto para empezar.',
|
||||
action: currentSearch ? '<button class="btn btn--ghost btn--sm" onclick="document.getElementById(\'productSearch\').value=\'\';loadItems(1,\'\')">Limpiar búsqueda</button>' : '<button class="btn btn--primary btn--sm" onclick="openCreateModal()">Crear producto</button>'
|
||||
}) + '</td></tr>';
|
||||
document.getElementById('productPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inventoryVS) {
|
||||
inventoryVS = new VirtualScroll({
|
||||
@@ -268,6 +304,9 @@
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,39 +326,49 @@
|
||||
// CREATE ITEM (createModal)
|
||||
// =====================================================================
|
||||
|
||||
function loadCategories() {
|
||||
function loadCategories(selectedId) {
|
||||
var sel = document.getElementById('newCategory');
|
||||
if (!sel) return;
|
||||
apiFetch(API + '/categories').then(function(data) {
|
||||
apiFetch(API + '/categories/all').then(function(data) {
|
||||
if (!data || !data.categories) return;
|
||||
sel.innerHTML = '<option value="">Selecciona categoría</option>';
|
||||
data.categories.forEach(function(c) {
|
||||
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
|
||||
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' : '') + '> ' + esc(s.name) + '</option>';
|
||||
});
|
||||
sel.innerHTML += '</optgroup>';
|
||||
});
|
||||
});
|
||||
}
|
||||
window.loadCategories = loadCategories;
|
||||
|
||||
function onCategoryChange(categoryId) {
|
||||
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;
|
||||
});
|
||||
// Kept for backwards compatibility; selector is now flat.
|
||||
}
|
||||
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
|
||||
@@ -337,6 +386,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
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>';
|
||||
@@ -367,58 +451,57 @@
|
||||
function closeCreateModal() {
|
||||
document.getElementById('createModal').classList.remove('is-open');
|
||||
document.getElementById('createResult').innerHTML = '';
|
||||
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; }
|
||||
resetCreateModal();
|
||||
}
|
||||
|
||||
function createItem() {
|
||||
var elPrice2 = document.getElementById('newPrice2');
|
||||
var elPrice3 = document.getElementById('newPrice3');
|
||||
var editId = document.getElementById('editItemId').value;
|
||||
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,
|
||||
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
|
||||
max_stock: parseInt(document.getElementById('newMaxStock').value) || 0,
|
||||
tax_rate: parseFloat(document.getElementById('newTaxRate').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;
|
||||
var subcategoryId = document.getElementById('newSubcategory').value;
|
||||
if (categoryId) data.category_id = parseInt(categoryId);
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
|
||||
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) {
|
||||
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>';
|
||||
@@ -444,7 +527,7 @@
|
||||
fetch(API + '/items/bulk-import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Authorization': 'Bearer ' + getToken(),
|
||||
'X-Import-Mode': mode,
|
||||
'X-Import-Strategy': strategy
|
||||
},
|
||||
@@ -474,6 +557,28 @@
|
||||
}
|
||||
window.submitBulkImport = submitBulkImport;
|
||||
|
||||
function downloadBulkImportTemplate() {
|
||||
var headers = [
|
||||
'numero_de_parte','nombre','marca','precio','cantidad','costo',
|
||||
'sku_secundario','descripcion','categoria','fabricante','modelo','anio','motor','codigo_motor'
|
||||
];
|
||||
var example = [
|
||||
'EJ-001','Filtro de aceite','ACDelco','150.00','10','90.00',
|
||||
'EJ001-ALT','Filtro para sedan','Filtros','Nissan','Sentra','2020','1.8','MR18DE'
|
||||
];
|
||||
var csv = [headers.join(','), example.join(',')].join('\n');
|
||||
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'plantilla_inventario.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
window.downloadBulkImportTemplate = downloadBulkImportTemplate;
|
||||
|
||||
// =====================================================================
|
||||
// PURCHASE / ENTRADA (purchaseModal)
|
||||
// =====================================================================
|
||||
@@ -1004,7 +1109,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);">' + esc(h.date) + '</td>' +
|
||||
'<td style="font-size:var(--text-caption);">' + formatDateTime(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>' +
|
||||
@@ -1029,10 +1134,9 @@
|
||||
|
||||
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: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||
headers: getToken() ? { 'Authorization': 'Bearer ' + getToken() } : {}
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; }
|
||||
@@ -1085,7 +1189,7 @@
|
||||
var ids = Array.from(selectedItems);
|
||||
fetch('/pos/api/marketplace-ext/inventory-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventory_ids: ids })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
@@ -1140,7 +1244,7 @@
|
||||
formData.append('file', file);
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/image', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||
body: formData
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
@@ -1165,7 +1269,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 ' + token, 'Content-Type': 'application/json' } })
|
||||
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var cats = data.categories || [];
|
||||
@@ -1212,7 +1316,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 ' + token } })
|
||||
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
meliCategoryAttrs = data.attributes || [];
|
||||
@@ -1341,7 +1445,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 ' + token, 'Content-Type': 'application/json' },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
@@ -1431,7 +1535,7 @@
|
||||
var maxAttempts = 60; // 2 min
|
||||
var interval = setInterval(function() {
|
||||
attempts++;
|
||||
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'done') {
|
||||
@@ -1468,7 +1572,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 ' + token, 'Content-Type': 'application/json' },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
@@ -1527,7 +1631,7 @@
|
||||
if (statusEl) statusEl.textContent = 'Subiendo...';
|
||||
fetch(API + '/items/' + itemId + '/image', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||
body: fd
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
@@ -1550,7 +1654,7 @@
|
||||
if (!confirm('Eliminar imagen de este producto?')) return;
|
||||
fetch(API + '/items/' + itemId + '/image', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (result) {
|
||||
@@ -1660,7 +1764,7 @@
|
||||
? '/pos/api/catalog/part/' + catalogPartId
|
||||
: '/pos/api/catalog/search?q=' + encodeURIComponent(partNumber);
|
||||
|
||||
fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch(url, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var el = document.getElementById('crossRefContent');
|
||||
@@ -1672,7 +1776,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 ' + token } })
|
||||
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r2) { return r2.json(); })
|
||||
.then(function(d2) {
|
||||
renderCrossRefs(el, d2.alternatives || [], d2.bodegas || []);
|
||||
@@ -1745,7 +1849,7 @@
|
||||
|
||||
// Load SKU aliases
|
||||
(function loadSkuAliases() {
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var el = document.getElementById('skuAliasContent');
|
||||
@@ -1777,7 +1881,7 @@
|
||||
|
||||
// Load vehicle compatibilities and makes
|
||||
(function loadCompatPanel() {
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var el = document.getElementById('compatContent');
|
||||
@@ -1803,7 +1907,7 @@
|
||||
});
|
||||
|
||||
// Load makes
|
||||
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var sel = document.getElementById('manualMake');
|
||||
@@ -1828,7 +1932,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);">' + esc(h.date) + '</td>' +
|
||||
'<td style="font-size:var(--text-caption);">' + formatDateTime(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>' +
|
||||
@@ -1847,7 +1951,7 @@
|
||||
function autoMatchCompat(itemId) {
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var msg = '';
|
||||
@@ -1870,7 +1974,7 @@
|
||||
if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
viewProductDetail(itemId);
|
||||
@@ -1903,7 +2007,7 @@
|
||||
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
|
||||
return;
|
||||
}
|
||||
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var opts = '<option value="">Selecciona modelo</option>';
|
||||
@@ -1929,7 +2033,7 @@
|
||||
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
|
||||
return;
|
||||
}
|
||||
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var opts = '<option value="">Selecciona ano</option>';
|
||||
@@ -1955,7 +2059,7 @@
|
||||
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
|
||||
return;
|
||||
}
|
||||
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var opts = '<option value="">Selecciona motor</option>';
|
||||
@@ -1983,7 +2087,7 @@
|
||||
}
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), '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) {
|
||||
@@ -2001,7 +2105,7 @@
|
||||
if (!sku) { alert('Ingresa un SKU'); return; }
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/skus', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sku: sku, label: label })
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
@@ -2014,7 +2118,7 @@
|
||||
if (!confirm('Eliminar este SKU alternativo?')) return;
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
viewProductDetail(itemId);
|
||||
|
||||
@@ -62,7 +62,10 @@ const Invoicing = (() => {
|
||||
if (name === 'notas') loadNotas();
|
||||
if (name === 'complementos') loadComplementos();
|
||||
if (name === 'cancelaciones') loadCancelaciones();
|
||||
if (name === 'config') loadFacturapiStatus();
|
||||
if (name === 'config') {
|
||||
loadFacturapiStatus();
|
||||
loadEmisorData();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Badge helpers ----
|
||||
@@ -86,46 +89,93 @@ 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;
|
||||
|
||||
try {
|
||||
const res = await api('/queue?per_page=50&type=Ingreso');
|
||||
const items = res.data || [];
|
||||
if (!items.length) {
|
||||
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>
|
||||
<td class="td--mono">${item.provisional_folio || item.id || '-'}</td>
|
||||
<td class="td--primary">${item.serie || '-'}</td>
|
||||
<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)}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.uso_cfdi || '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
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';
|
||||
|
||||
// Update footer count
|
||||
const footer = panel.querySelector('.table-footer span');
|
||||
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${res.pagination?.total || items.length} facturas`;
|
||||
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="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
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');
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || item.id || '-'}</td>
|
||||
<td class="td--primary">${item.serie || '-'}</td>
|
||||
<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.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const footer = panel.querySelector('.table-footer span');
|
||||
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${total} facturas`;
|
||||
}
|
||||
|
||||
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 ----
|
||||
async function loadNotas() {
|
||||
const panel = document.getElementById('panel-notas');
|
||||
@@ -133,18 +183,20 @@ 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="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay notas de credito.</td></tr>';
|
||||
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>';
|
||||
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.description || '-'}</td>
|
||||
<td>${item.cancel_motive || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
@@ -155,7 +207,7 @@ const Invoicing = (() => {
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,33 +218,47 @@ 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');
|
||||
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 = 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 class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
||||
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
const res = await api('/queue?per_page=50&type=pago');
|
||||
complementosCache = res.data || [];
|
||||
renderComplementos(complementosCache);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = filtered.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 class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
||||
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
|
||||
async function loadCancelaciones() {
|
||||
const panel = document.getElementById('panel-cancelaciones');
|
||||
@@ -288,10 +354,18 @@ 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}</li>`).join('') +
|
||||
status.pending_steps.map(s => `<li>${s.description || s.type || s}</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>
|
||||
@@ -301,29 +375,128 @@ 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);">CSD</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-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' });
|
||||
alert('Organización vinculada: ' + res.org_id);
|
||||
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.');
|
||||
}
|
||||
loadFacturapiStatus();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Crear / Vincular Organización';
|
||||
}
|
||||
@@ -335,6 +508,13 @@ 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');
|
||||
@@ -674,13 +854,6 @@ 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: "🛒" });
|
||||
@@ -689,4 +862,15 @@ 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,
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -82,7 +82,13 @@
|
||||
localStorage.setItem('pos_token', result.data.token);
|
||||
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
||||
localStorage.setItem('pos_tenant_id', tenantId);
|
||||
window.location.href = '/pos/catalog';
|
||||
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';
|
||||
|
||||
2
pos/static/js/login.min.js
vendored
2
pos/static/js/login.min.js
vendored
@@ -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),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),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")}();
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.startOAuth = function() {
|
||||
window.startOAuth = async function() {
|
||||
var clientId = document.getElementById('cfgClientId').value.trim();
|
||||
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
|
||||
var category = document.getElementById('cfgCategory').value.trim();
|
||||
@@ -75,15 +75,34 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Save config locally for the callback
|
||||
localStorage.setItem('meli_client_id', clientId);
|
||||
localStorage.setItem('meli_client_secret', clientSecret);
|
||||
// Purge any previously stored secret from older versions
|
||||
localStorage.removeItem('meli_client_secret');
|
||||
localStorage.removeItem('meli_client_id');
|
||||
localStorage.setItem('meli_category', category);
|
||||
localStorage.setItem('meli_shipping', shipping);
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
window.disconnectMeli = async function() {
|
||||
@@ -363,8 +382,6 @@
|
||||
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', {
|
||||
@@ -372,8 +389,6 @@
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -160,7 +160,15 @@
|
||||
delete el.dataset.originalContent;
|
||||
};
|
||||
|
||||
// ── Empty state helper ────────────────────────────────────────
|
||||
// ── 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>';
|
||||
};
|
||||
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>';
|
||||
|
||||
@@ -21,10 +21,24 @@ const POS = (() => {
|
||||
let paymentMethod = 'efectivo';
|
||||
let canViewCost = false;
|
||||
let employeeMaxDiscount = 100;
|
||||
let lastSaleId = null;
|
||||
let lastSaleId = sessionStorage.getItem('pos_last_sale_id') || 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';
|
||||
@@ -32,6 +46,16 @@ const POS = (() => {
|
||||
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
||||
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
// Price based on customer tier: base price (price_1) with tier discount.
|
||||
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
||||
function priceForTier(basePrice, tier) {
|
||||
const p = parseFloat(basePrice) || 0;
|
||||
const t = parseInt(tier, 10) || 1;
|
||||
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
||||
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
||||
return p;
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
@@ -54,6 +78,48 @@ 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
|
||||
@@ -61,9 +127,44 @@ const POS = (() => {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
||||
document.getElementById('branchName').textContent = payload.branch_name || '';
|
||||
canViewCost = (payload.permissions || []).includes('pos.view_cost');
|
||||
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');
|
||||
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 = '';
|
||||
@@ -78,6 +179,8 @@ 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);
|
||||
}
|
||||
@@ -102,8 +205,10 @@ const POS = (() => {
|
||||
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
|
||||
}
|
||||
|
||||
// Load current register
|
||||
// Load current register and receipt config
|
||||
await loadRegister();
|
||||
await loadReceiptConfig();
|
||||
await loadCouriers();
|
||||
|
||||
// Setup event listeners
|
||||
setupKeyboard();
|
||||
@@ -123,14 +228,48 @@ const POS = (() => {
|
||||
currentRegister = null;
|
||||
document.getElementById('registerInfo').innerHTML =
|
||||
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">⚠ Sin caja abierta — Clic para abrir</span>';
|
||||
// Force open register modal on first load
|
||||
showOpenRegisterModal();
|
||||
// Force open register modal on first load only for users who can sell
|
||||
if (currentPerms.includes('pos.sell')) {
|
||||
showOpenRegisterModal();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Register check failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
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 = '';
|
||||
@@ -278,6 +417,7 @@ 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');
|
||||
@@ -307,6 +447,7 @@ 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;
|
||||
@@ -321,7 +462,20 @@ 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;
|
||||
@@ -329,10 +483,13 @@ const POS = (() => {
|
||||
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
|
||||
if (p !== null) {
|
||||
const n = parseFloat(p);
|
||||
if (n >= 0) {
|
||||
cart[selectedRow].unit_price = n;
|
||||
renderCart();
|
||||
if (n < 0) { showToast('Precio no válido'); return; }
|
||||
if (!allowZeroPriceSales && n === 0) {
|
||||
showToast('No está permitido dejar el precio en $0');
|
||||
return;
|
||||
}
|
||||
cart[selectedRow].unit_price = n;
|
||||
renderCart();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,26 +676,11 @@ const POS = (() => {
|
||||
if (data.data.length === 0) {
|
||||
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
||||
if (window.BarcodeFeedback) BarcodeFeedback.error();
|
||||
} else if (data.data.length === 1 && q.length >= 8) {
|
||||
// Auto-select single result on barcode scan (long codes)
|
||||
const item = data.data[0];
|
||||
let price = item.price_1;
|
||||
if (currentCustomer) {
|
||||
const tier = currentCustomer.price_tier || 1;
|
||||
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||
}
|
||||
addFromSearch(item, price);
|
||||
input.value = '';
|
||||
hideSearchResults();
|
||||
return;
|
||||
} else {
|
||||
let html = '';
|
||||
data.data.forEach(item => {
|
||||
let price = item.price_1;
|
||||
if (currentCustomer) {
|
||||
const tier = currentCustomer.price_tier || 1;
|
||||
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||
}
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
const price = priceForTier(item.price_1, tier);
|
||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
@@ -633,7 +775,7 @@ const POS = (() => {
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
cart.forEach(item => {
|
||||
if (item.price_1 > 0) {
|
||||
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||
item.unit_price = priceForTier(item.price_1, tier);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -829,9 +971,12 @@ const POS = (() => {
|
||||
transferencia: 'refPayment',
|
||||
tarjeta: 'refPayment',
|
||||
mixto: 'mixedPayment',
|
||||
credito: 'creditPayment',
|
||||
cheque: 'chequePayment',
|
||||
pendiente: 'pendingPayment',
|
||||
};
|
||||
|
||||
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
|
||||
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
const isActive = el.id === tabs[method];
|
||||
@@ -845,6 +990,12 @@ const POS = (() => {
|
||||
const ref = document.getElementById('paymentRef');
|
||||
if (ref) ref.focus();
|
||||
}
|
||||
if (method === 'cheque') {
|
||||
const chequeAmount = document.getElementById('chequeAmount');
|
||||
if (chequeAmount) chequeAmount.value = fmt(getTotal());
|
||||
const chequeRef = document.getElementById('chequeRef');
|
||||
if (chequeRef) chequeRef.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function updateChange() {
|
||||
@@ -882,6 +1033,7 @@ const POS = (() => {
|
||||
let amountPaid = 0;
|
||||
let paymentDetails = [];
|
||||
let reference = '';
|
||||
let saleType = 'cash';
|
||||
|
||||
if (paymentMethod === 'efectivo') {
|
||||
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||
@@ -901,6 +1053,20 @@ const POS = (() => {
|
||||
}
|
||||
});
|
||||
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
|
||||
} else if (paymentMethod === 'credito') {
|
||||
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
|
||||
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
||||
if (total > available) {
|
||||
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
|
||||
return;
|
||||
}
|
||||
saleType = 'credit';
|
||||
amountPaid = 0;
|
||||
} else if (paymentMethod === 'cheque') {
|
||||
amountPaid = total;
|
||||
reference = document.getElementById('chequeRef').value.trim();
|
||||
} else if (paymentMethod === 'pendiente') {
|
||||
amountPaid = 0;
|
||||
}
|
||||
|
||||
const saleData = {
|
||||
@@ -913,7 +1079,7 @@ const POS = (() => {
|
||||
})),
|
||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||
payment_method: paymentMethod,
|
||||
sale_type: 'cash',
|
||||
sale_type: saleType,
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
amount_paid: amountPaid,
|
||||
payment_details: paymentDetails,
|
||||
@@ -921,6 +1087,12 @@ 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...';
|
||||
@@ -932,9 +1104,10 @@ const POS = (() => {
|
||||
const convertData = {
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
payment_method: paymentMethod,
|
||||
sale_type: 'cash',
|
||||
sale_type: saleType,
|
||||
amount_paid: amountPaid,
|
||||
payment_details: paymentDetails,
|
||||
reference: reference,
|
||||
};
|
||||
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
|
||||
method: 'POST',
|
||||
@@ -952,6 +1125,7 @@ const POS = (() => {
|
||||
|
||||
lastSaleId = sale.id;
|
||||
lastSaleData = sale;
|
||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
||||
closePaymentModal();
|
||||
showTicket(sale);
|
||||
|
||||
@@ -968,22 +1142,18 @@ const POS = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Credit Sale ─────────────────────
|
||||
async function creditSale() {
|
||||
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
|
||||
if (!currentRegister) { alert('No hay caja abierta.'); return; }
|
||||
|
||||
const total = getTotal();
|
||||
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
||||
|
||||
if (currentCustomer.credit_limit > 0 && total > available) {
|
||||
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
|
||||
return;
|
||||
}
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
const saleData = {
|
||||
const total = getTotal();
|
||||
const noteData = {
|
||||
items: cart.map(item => ({
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: item.quantity,
|
||||
@@ -991,34 +1161,158 @@ const POS = (() => {
|
||||
discount_pct: item.discount_pct,
|
||||
tax_rate: item.tax_rate,
|
||||
})),
|
||||
customer_id: currentCustomer.id,
|
||||
payment_method: 'credito',
|
||||
sale_type: 'credit',
|
||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||
notes: 'Nota de remision generada desde mostrador',
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
amount_paid: 0,
|
||||
courier_id: selectedCourierId,
|
||||
};
|
||||
|
||||
try {
|
||||
const sale = await api('/pos/api/sales', {
|
||||
const sale = await api('/pos/api/sales/remission', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(saleData),
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
|
||||
if (selectedCourierId) {
|
||||
const courier = couriers.find(c => c.id === selectedCourierId);
|
||||
sale.courier_name = courier ? courier.name : '';
|
||||
}
|
||||
lastSaleId = sale.id;
|
||||
lastSaleData = sale;
|
||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
||||
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: ' + e.message);
|
||||
alert('Error al generar nota de remision: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPayRemissionModal() {
|
||||
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
|
||||
document.getElementById('payRemissionFolio').value = '';
|
||||
document.getElementById('payRemissionDetail').innerHTML = '';
|
||||
document.getElementById('payRemissionResult').innerHTML = '';
|
||||
document.getElementById('payRemissionActions').style.display = 'none';
|
||||
document.getElementById('payRemissionModal').classList.add('open');
|
||||
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
|
||||
}
|
||||
|
||||
function closePayRemissionModal() {
|
||||
document.getElementById('payRemissionModal').classList.remove('open');
|
||||
}
|
||||
|
||||
let pendingRemissionToPay = null;
|
||||
|
||||
async function searchRemissionToPay() {
|
||||
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
|
||||
if (!folio) { showToast('Ingresa un folio valido'); return; }
|
||||
pendingRemissionToPay = null;
|
||||
try {
|
||||
const sale = await api('/pos/api/sales/' + folio);
|
||||
if (sale.status !== 'pending_payment') {
|
||||
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
|
||||
document.getElementById('payRemissionActions').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
pendingRemissionToPay = sale;
|
||||
let itemsHtml = (sale.items || []).map(it => `
|
||||
<div class="ticket-line">
|
||||
<span class="qty">${it.quantity}</span>
|
||||
<span class="name">${it.name || ''}</span>
|
||||
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('payRemissionDetail').innerHTML = `
|
||||
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
|
||||
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
|
||||
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
|
||||
<hr class="divider">
|
||||
${itemsHtml}
|
||||
`;
|
||||
document.getElementById('payRemissionActions').style.display = '';
|
||||
} catch (e) {
|
||||
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
||||
document.getElementById('payRemissionActions').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPayRemission() {
|
||||
if (!pendingRemissionToPay) return;
|
||||
const sale = pendingRemissionToPay;
|
||||
const paymentMethod = document.getElementById('payRemissionMethod').value;
|
||||
let amountPaid = parseFloat(sale.total);
|
||||
let paymentDetails = [];
|
||||
let reference = '';
|
||||
|
||||
if (paymentMethod === 'efectivo') {
|
||||
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
|
||||
if (received < sale.total) { alert('Monto insuficiente'); return; }
|
||||
amountPaid = received;
|
||||
} else if (paymentMethod === 'mixto') {
|
||||
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
|
||||
let sum = 0;
|
||||
rows.forEach(row => {
|
||||
const method = row.querySelector('select').value;
|
||||
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
|
||||
const ref = row.querySelectorAll('input')[1]?.value || '';
|
||||
if (amount > 0) {
|
||||
paymentDetails.push({ method, amount, reference: ref });
|
||||
sum += amount;
|
||||
}
|
||||
});
|
||||
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
|
||||
amountPaid = sum;
|
||||
} else {
|
||||
reference = document.getElementById('payRemissionReference').value.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
payment_method: paymentMethod,
|
||||
amount_paid: amountPaid,
|
||||
payment_details: paymentDetails,
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
reference: reference,
|
||||
}),
|
||||
});
|
||||
closePayRemissionModal();
|
||||
showToast(`Nota NR-${sale.id} pagada`);
|
||||
// Refresh sale object to print paid ticket
|
||||
const updated = await api('/pos/api/sales/' + sale.id);
|
||||
lastSaleId = updated.id;
|
||||
lastSaleData = updated;
|
||||
showTicket(updated);
|
||||
} catch (e) {
|
||||
alert('Error al cobrar nota: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePayRemissionMethod() {
|
||||
const method = document.getElementById('payRemissionMethod').value;
|
||||
const cashEl = document.getElementById('payRemissionCash');
|
||||
const refEl = document.getElementById('payRemissionRef');
|
||||
const mixedEl = document.getElementById('payRemissionMixed');
|
||||
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
|
||||
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
|
||||
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ─── Quotation ───────────────────────
|
||||
async function saveQuotation() {
|
||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
||||
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 => ({
|
||||
@@ -1057,7 +1351,13 @@ 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();
|
||||
@@ -1097,6 +1397,138 @@ 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', {
|
||||
@@ -1104,14 +1536,15 @@ const POS = (() => {
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
|
||||
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
|
||||
const isRemission = sale.status === 'pending_payment';
|
||||
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
||||
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
|
||||
|
||||
let itemsHtml = '';
|
||||
(sale.items || []).forEach(item => {
|
||||
const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
|
||||
itemsHtml += `
|
||||
<div class="item-line-wide">
|
||||
<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>
|
||||
@@ -1119,22 +1552,48 @@ 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 = `
|
||||
<div class="store-name">NEXUS AUTOPARTS</div>
|
||||
<div class="store-tagline">Tu conexion con las refacciones</div>
|
||||
${logoHtml}
|
||||
<div class="store-name">${storeName}</div>
|
||||
${storeTagline ? `<div class="store-tagline">${storeTagline}</div>` : ''}
|
||||
<div class="store-info">
|
||||
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
|
||||
RFC: NAU210315XX1
|
||||
${storeInfoLines.join('<br>')}
|
||||
</div>
|
||||
<hr class="divider-double">
|
||||
<div class="folio-line">
|
||||
<span>VENTA: V-${sale.id}</span>
|
||||
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
|
||||
<span>${dateStr}</span>
|
||||
</div>
|
||||
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
||||
<span>Cliente: ${customerName}</span>
|
||||
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
||||
</div>
|
||||
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
||||
<hr class="divider">
|
||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
||||
<span class="qty">Cant</span>
|
||||
@@ -1150,15 +1609,20 @@ 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>` : ''}
|
||||
<div class="total-line">
|
||||
<span>IVA 16%:</span><span>${fmt(sale.tax_total)}</span>
|
||||
</div>
|
||||
${showIva ? `<div class="total-line"><span>IVA:</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>
|
||||
@@ -1169,11 +1633,13 @@ 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">Gracias por su compra!</div>
|
||||
<div>Conserve su ticket como comprobante.</div>
|
||||
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
|
||||
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
|
||||
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1182,6 +1648,8 @@ 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');
|
||||
}
|
||||
@@ -1191,11 +1659,84 @@ const POS = (() => {
|
||||
}
|
||||
|
||||
function printTicket() {
|
||||
// Make print area visible for @media print
|
||||
const area = document.getElementById('ticketPrintArea');
|
||||
if (area) area.style.display = 'block';
|
||||
window.print();
|
||||
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
|
||||
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);
|
||||
window.print();
|
||||
}
|
||||
}, 200);
|
||||
} catch (e) {
|
||||
console.error('printTicket error:', e);
|
||||
alert('No se pudo preparar la impresion del ticket. Revise la consola.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Thermal Printing ─────────────────
|
||||
@@ -1217,7 +1758,8 @@ const POS = (() => {
|
||||
return;
|
||||
}
|
||||
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
|
||||
const ok = await NexusPrinter.printSale(lastSaleId);
|
||||
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
|
||||
const ok = await NexusPrinter.printSale(lastSaleId, paperWidth);
|
||||
if (ok) {
|
||||
showToast('Ticket enviado a impresora termica');
|
||||
} else {
|
||||
@@ -1286,6 +1828,10 @@ const POS = (() => {
|
||||
e.preventDefault();
|
||||
openDrawer();
|
||||
break;
|
||||
case 'F7':
|
||||
e.preventDefault();
|
||||
createServiceOrder();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
if (document.getElementById('paymentModal').classList.contains('open')) {
|
||||
@@ -1294,6 +1840,8 @@ 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');
|
||||
@@ -1361,6 +1909,14 @@ 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();
|
||||
|
||||
@@ -1372,7 +1928,10 @@ const POS = (() => {
|
||||
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
||||
checkout, confirmPayment, closePaymentModal,
|
||||
selectPaymentMethod, updateChange, updateMixedTotal,
|
||||
creditSale, saveQuotation, createLayaway,
|
||||
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
|
||||
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
|
||||
saveQuotation, createLayaway,
|
||||
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
|
||||
showLastSale, openDrawer,
|
||||
showTicket, closeTicketModal, printTicket,
|
||||
connectThermal, thermalPrint,
|
||||
@@ -1380,12 +1939,5 @@ 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: "📊" });
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -54,7 +54,20 @@ const Reports = (() => {
|
||||
}
|
||||
|
||||
// Track which tabs have been loaded
|
||||
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
|
||||
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false };
|
||||
|
||||
function currentUser() {
|
||||
return (typeof window.POS_USER !== 'undefined') ? window.POS_USER : {};
|
||||
}
|
||||
function hasPerm(p) {
|
||||
var u = currentUser();
|
||||
return (u.role === 'owner' || u.role === 'admin') || (u.permissions || []).indexOf(p) !== -1;
|
||||
}
|
||||
function isLimitedUser() {
|
||||
var u = currentUser();
|
||||
var r = (u.role || '').toLowerCase();
|
||||
return (r === 'cashier' || r === 'counter') && !hasPerm('pos.view');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Theme switcher
|
||||
@@ -86,6 +99,7 @@ const Reports = (() => {
|
||||
else if (id === 'clientes') loadClientes();
|
||||
else if (id === 'financieros') loadFinancieros();
|
||||
else if (id === 'historico') loadHistorico();
|
||||
else if (id === 'cortes') loadCortes();
|
||||
}
|
||||
}
|
||||
window.switchTab = switchTab;
|
||||
@@ -504,7 +518,7 @@ const Reports = (() => {
|
||||
agingEl.innerHTML = spinner();
|
||||
|
||||
try {
|
||||
var data = await apiFetch('/pos/api/accounting/aging');
|
||||
var data = await apiFetch('/pos/api/accounting/aging-summary');
|
||||
var clients = data.data || [];
|
||||
var totals = data.totals || {};
|
||||
|
||||
@@ -737,6 +751,153 @@ const Reports = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TAB 6: CORTES DE CAJA
|
||||
// =========================================================================
|
||||
async function loadCortes() {
|
||||
loaded.cortes = true;
|
||||
var dateFrom = document.getElementById('cortes-date-from').value;
|
||||
var dateTo = document.getElementById('cortes-date-to').value;
|
||||
var employeeId = document.getElementById('cortes-employee').value;
|
||||
|
||||
// Cashiers/counters without pos.view can only see their own cuts
|
||||
var u = currentUser();
|
||||
if (isLimitedUser()) {
|
||||
employeeId = u.employeeId || '';
|
||||
var empSel = document.getElementById('cortes-employee');
|
||||
if (empSel) empSel.value = employeeId;
|
||||
}
|
||||
|
||||
var kpiEl = document.getElementById('cortes-kpis');
|
||||
var detalleEl = document.getElementById('cortes-detalle');
|
||||
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
|
||||
kpiEl.innerHTML = spinner();
|
||||
detalleEl.innerHTML = spinner();
|
||||
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
|
||||
|
||||
var params = new URLSearchParams();
|
||||
if (dateFrom) params.set('date_from', dateFrom);
|
||||
if (dateTo) params.set('date_to', dateTo);
|
||||
if (employeeId) params.set('employee_id', employeeId);
|
||||
params.set('per_page', '200');
|
||||
|
||||
try {
|
||||
var data = await apiFetch('/pos/api/register/history?' + params.toString());
|
||||
var regs = data.data || [];
|
||||
|
||||
var totalEsperado = 0;
|
||||
var totalCierre = 0;
|
||||
var totalDiferencia = 0;
|
||||
regs.forEach(function(r) {
|
||||
totalEsperado += r.expected_amount || 0;
|
||||
totalCierre += r.closing_amount || 0;
|
||||
totalDiferencia += r.difference || 0;
|
||||
});
|
||||
|
||||
kpiEl.innerHTML =
|
||||
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
|
||||
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
|
||||
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
|
||||
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
|
||||
|
||||
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
|
||||
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
|
||||
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
||||
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
|
||||
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
|
||||
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
|
||||
'<th>Acciones</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
regs.forEach(function(r) {
|
||||
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
|
||||
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
|
||||
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
|
||||
'<td class="td-mono">#' + r.register_number + '</td>' +
|
||||
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
|
||||
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
|
||||
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
|
||||
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
|
||||
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
|
||||
});
|
||||
cHtml += '</tbody></table></div>';
|
||||
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
|
||||
} catch (err) {
|
||||
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
|
||||
detalleEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Detail: sales for a selected cash cut
|
||||
// -------------------------------------------------------------------------
|
||||
async function showCorteVentas(registerId) {
|
||||
var panel = document.getElementById('corte-ventas-detalle');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'block';
|
||||
panel.innerHTML = spinner();
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
|
||||
var methodLabels = {
|
||||
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
|
||||
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
|
||||
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
|
||||
};
|
||||
|
||||
try {
|
||||
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
|
||||
var sales = data.sales || [];
|
||||
var summary = data.summary || {};
|
||||
|
||||
var total = summary.total || 0;
|
||||
var count = summary.count || 0;
|
||||
var byMethod = summary.by_method || {};
|
||||
|
||||
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
|
||||
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
|
||||
|
||||
// Summary by payment method
|
||||
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
|
||||
if (methods.length) {
|
||||
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
|
||||
methods.forEach(function(m) {
|
||||
var label = methodLabels[m[0]] || m[0];
|
||||
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
|
||||
});
|
||||
hHtml += '</div>';
|
||||
}
|
||||
|
||||
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
||||
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
|
||||
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
|
||||
'<th class="align-right">Total</th><th>Estado</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
sales.forEach(function(s) {
|
||||
var statusPill = s.status === 'completed' ? 'pill--success' :
|
||||
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
|
||||
var statusLabel = s.status === 'completed' ? 'Completada' :
|
||||
s.status === 'cancelled' ? 'Cancelada' : s.status;
|
||||
var method = methodLabels[s.payment_method] || s.payment_method || '--';
|
||||
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
|
||||
'<td>' + fmtDateTime(s.created_at) + '</td>' +
|
||||
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
|
||||
'<td>' + method + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
|
||||
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
|
||||
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
|
||||
});
|
||||
hHtml += '</tbody></table></div>';
|
||||
|
||||
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
|
||||
} catch (err) {
|
||||
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
|
||||
}
|
||||
}
|
||||
window.showCorteVentas = showCorteVentas;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Init
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -753,6 +914,9 @@ const Reports = (() => {
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
var u = currentUser();
|
||||
var limited = isLimitedUser();
|
||||
|
||||
// Set default date range: first day of current month to today
|
||||
var now = new Date();
|
||||
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
@@ -761,6 +925,49 @@ const Reports = (() => {
|
||||
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
|
||||
if (toEl) toEl.value = now.toISOString().substring(0, 10);
|
||||
|
||||
// Set default date range for cortes de caja
|
||||
var cortesFrom = document.getElementById('cortes-date-from');
|
||||
var cortesTo = document.getElementById('cortes-date-to');
|
||||
if (cortesFrom) cortesFrom.value = firstDay.toISOString().substring(0, 10);
|
||||
if (cortesTo) cortesTo.value = now.toISOString().substring(0, 10);
|
||||
|
||||
// Populate cajero filter for cortes de caja (admins/owners only see it)
|
||||
var empSel = document.getElementById('cortes-employee');
|
||||
if (empSel && !limited) {
|
||||
apiFetch('/pos/api/config/employees?per_page=200').then(function(data) {
|
||||
(data.data || []).forEach(function(e) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = e.id;
|
||||
opt.textContent = e.name;
|
||||
empSel.appendChild(opt);
|
||||
});
|
||||
}).catch(function() {});
|
||||
} else if (empSel && limited) {
|
||||
empSel.value = u.employeeId || '';
|
||||
}
|
||||
|
||||
// For cashiers/counters without pos.view, limit the page to "Mis cortes"
|
||||
if (limited) {
|
||||
['ventas', 'inventario', 'clientes', 'financieros', 'historico'].forEach(function(id) {
|
||||
var btn = document.querySelector('.tab-btn[onclick="switchTab(\'' + id + '\', this)"]');
|
||||
if (btn) btn.style.display = 'none';
|
||||
});
|
||||
var empFilter = document.getElementById('cortes-employee-filter');
|
||||
if (empFilter) empFilter.style.display = 'none';
|
||||
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||
var cortesPanel = document.getElementById('panel-cortes');
|
||||
var cortesBtn = document.querySelector('.tab-btn[onclick="switchTab(\'cortes\', this)"]');
|
||||
if (cortesPanel) cortesPanel.classList.add('is-active');
|
||||
if (cortesBtn) {
|
||||
cortesBtn.classList.add('is-active');
|
||||
var svg = '<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1" y="3" width="13" height="10" rx="1"/><path d="M4 7h7M4 10h5"/><circle cx="11" cy="10" r="1.5" fill="currentColor"/></svg>';
|
||||
cortesBtn.innerHTML = svg + '<span>Mis cortes de caja</span>';
|
||||
}
|
||||
loadCortes();
|
||||
}
|
||||
|
||||
// Populate financial period selectors
|
||||
var monthSel = document.getElementById('fin-month');
|
||||
var yearSel = document.getElementById('fin-year');
|
||||
@@ -784,15 +991,18 @@ const Reports = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load the default active tab (ventas)
|
||||
loadVentas();
|
||||
// Load the default active tab (ventas) only for privileged users
|
||||
if (!limited) {
|
||||
loadVentas();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
return {
|
||||
init, setTheme, switchTab,
|
||||
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
|
||||
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
|
||||
showCorteVentas, fmt
|
||||
};
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
|
||||
@@ -15,7 +15,6 @@ 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') {
|
||||
@@ -31,30 +30,73 @@ 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: [
|
||||
{ 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)},
|
||||
{ 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); })},
|
||||
{ label: _t('nav_management'), items: [
|
||||
{ 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)},
|
||||
{ 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); })},
|
||||
{ label: _t('nav_system'), items: [
|
||||
{ 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"/>' },
|
||||
]},
|
||||
{ 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); })},
|
||||
];
|
||||
|
||||
// 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>';
|
||||
}
|
||||
@@ -82,16 +124,6 @@ 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) {
|
||||
@@ -109,7 +141,6 @@ 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">'
|
||||
@@ -122,14 +153,14 @@ window.renderSidebar = function(modulesOverride) {
|
||||
+ '</div>';
|
||||
|
||||
// Replace existing sidebar
|
||||
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
|
||||
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
|
||||
if (existing) {
|
||||
existing.className = 'pos-sidebar';
|
||||
existing.className = 'pos-sidebar sidebar';
|
||||
existing.innerHTML = sidebarHtml;
|
||||
existing.removeAttribute('style');
|
||||
} else {
|
||||
var el = document.createElement('aside');
|
||||
el.className = 'pos-sidebar';
|
||||
el.className = 'pos-sidebar sidebar';
|
||||
el.innerHTML = sidebarHtml;
|
||||
document.body.insertBefore(el, document.body.firstChild);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
// /home/Autopartes/pos/static/pwa/sw.js
|
||||
// Nexus POS — Service Worker v17
|
||||
// Nexus POS — Service Worker
|
||||
// Self-contained vanilla JS. No external imports.
|
||||
//
|
||||
// Bump CACHE_NAME whenever static assets change significantly.
|
||||
// Bump VERSION 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 CACHE_NAME = 'nexus-pos-v20';
|
||||
const VERSION = 54;
|
||||
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
||||
|
||||
const APP_SHELL = [
|
||||
'/pos/static/css/tokens.css',
|
||||
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/accounting.css?v=33"></head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -228,19 +227,18 @@
|
||||
<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 class="select-filter">
|
||||
<option>Todos los estados</option>
|
||||
<option>Vigente</option>
|
||||
<option>Vencida</option>
|
||||
<option>Parcial</option>
|
||||
<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>
|
||||
<select class="select-filter">
|
||||
<select class="select-filter" title="Filtro de sucursal (próximamente)">
|
||||
<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">
|
||||
<button class="btn btn--ghost btn--sm" onclick="window.exportarCuentasPorCobrar()">
|
||||
<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>
|
||||
@@ -281,13 +279,15 @@
|
||||
<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 class="select-filter">
|
||||
<option>Todos los estados</option>
|
||||
<option>Vigente</option>
|
||||
<option>Vencida</option>
|
||||
<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>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary btn--sm">
|
||||
<button id="cxp-register-payment" class="btn btn--primary btn--sm" onclick="Accounting.registerPayablePayment()">
|
||||
<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,13 +424,11 @@
|
||||
=============================================================== -->
|
||||
<div class="tab-panel" id="panel-cierre">
|
||||
<div class="toolbar">
|
||||
<select class="select-filter">
|
||||
<option>Marzo 2026</option>
|
||||
<option>Febrero 2026 (cerrado)</option>
|
||||
<option>Enero 2026 (cerrado)</option>
|
||||
<select id="cierre-period-filter" class="select-filter" title="Período a cerrar (próximamente)">
|
||||
<option>Seleccionar período</option>
|
||||
</select>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary">
|
||||
<button id="cierre-run-btn" class="btn btn--primary" onclick="Accounting.runPeriodClose()">
|
||||
<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>
|
||||
@@ -478,7 +476,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;">
|
||||
<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);" />
|
||||
<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="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()">×</button>
|
||||
@@ -494,12 +492,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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/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/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>
|
||||
|
||||
@@ -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=2" />
|
||||
<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/chat.css" />
|
||||
@@ -17,8 +17,7 @@
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/catalog.css">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/catalog.css"></head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -124,7 +123,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" />
|
||||
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" aria-label="Buscar productos" />
|
||||
<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>
|
||||
@@ -195,7 +194,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;" />
|
||||
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" aria-label="Filtrar niveles de catalogo" />
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
@@ -315,17 +314,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/app-init.js" 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/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/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=2" defer></script>
|
||||
<script src="/pos/static/js/onboarding.js?v=33" defer></script>
|
||||
<script>
|
||||
if('serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'}).then(function(reg){
|
||||
@@ -341,6 +340,6 @@
|
||||
}
|
||||
</script>
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
<script src="/pos/static/js/brand-catalog.js?v=10" defer></script>
|
||||
<script src="/pos/static/js/brand-catalog.js?v=33" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,14 +8,45 @@
|
||||
<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=2" />
|
||||
<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="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=2">
|
||||
<link rel="stylesheet" href="/pos/static/css/config.css?v=34">
|
||||
<style>
|
||||
.cfg-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfg-tab-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.cfg-tab-btn:hover { background: var(--color-surface-3); }
|
||||
.cfg-tab-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.settings-section[data-tab] { display: none !important; }
|
||||
.settings-section[data-tab].active { display: block !important; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -130,7 +161,7 @@
|
||||
<h1 class="page-header__title">Configuración</h1>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<button class="btn btn--primary">
|
||||
<button id="btn-save-all" class="btn btn--primary" type="button">
|
||||
<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>
|
||||
@@ -139,11 +170,20 @@
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="content-scroll">
|
||||
<!-- Config tabs -->
|
||||
<div class="cfg-tabs" id="configTabs">
|
||||
<button class="cfg-tab-btn active" data-tab="general" onclick="Config.switchTab('general')">General</button>
|
||||
<button class="cfg-tab-btn" data-tab="fiscal" onclick="Config.switchTab('fiscal')">Fiscal</button>
|
||||
<button class="cfg-tab-btn" data-tab="catalog" onclick="Config.switchTab('catalog')">Catálogo</button>
|
||||
<button class="cfg-tab-btn" data-tab="employees" onclick="Config.switchTab('employees')">Empleados</button>
|
||||
<button class="cfg-tab-btn cfg-tab-btn--permissions" data-tab="permissions" onclick="Config.switchTab('permissions')" style="display:none;">Permisos</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ===============================================================
|
||||
SECTION 1: APARIENCIA / TEMA
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
@@ -206,7 +246,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 2: DATOS DE LA EMPRESA
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
@@ -255,9 +295,88 @@
|
||||
</div>
|
||||
|
||||
<!-- ===============================================================
|
||||
SECTION 3: MÓDULOS E INTEGRACIONES
|
||||
SECTION 3: PERSONALIZACIÓN DE TICKET
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
</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__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>
|
||||
@@ -309,6 +428,26 @@
|
||||
<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>
|
||||
@@ -318,13 +457,13 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 4: USUARIOS Y PERMISOS
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="employees">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="settings-section__title">Usuarios y Permisos</div>
|
||||
<div class="settings-section__title">Empleados</div>
|
||||
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,7 +500,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 4: IMPRESORAS
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||
@@ -387,7 +526,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 5: SUCURSALES
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="fiscal">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
@@ -406,7 +545,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 6: PARÁMETROS FISCALES
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="fiscal">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
@@ -542,7 +681,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 7: PREFERENCIAS DEL SISTEMA
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
@@ -614,13 +753,33 @@
|
||||
<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">
|
||||
<div class="settings-section" data-tab="catalog">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
@@ -653,7 +812,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 9: MARCAS DE PARTES PERMITIDAS
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="catalog">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
|
||||
@@ -714,6 +873,64 @@
|
||||
</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 -->
|
||||
@@ -822,8 +1039,11 @@
|
||||
<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">
|
||||
@@ -849,13 +1069,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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/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/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/config.js?v=3" defer></script>
|
||||
<script src="/pos/static/js/config.js?v=41" 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>
|
||||
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/customers.css?v=34"></head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -298,15 +297,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()" />
|
||||
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" aria-label="Buscar clientes" />
|
||||
</div>
|
||||
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter">
|
||||
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter" aria-label="Filtrar por tipo de cliente">
|
||||
<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">
|
||||
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter" aria-label="Filtrar por estado de cliente">
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="Activo">Activo</option>
|
||||
<option value="Inactivo">Inactivo</option>
|
||||
@@ -342,15 +341,7 @@
|
||||
<!-- Pagination -->
|
||||
<div class="table-footer">
|
||||
<div class="table-footer__info" id="tableInfo"></div>
|
||||
<div class="pagination">
|
||||
<button class="page-btn">‹</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">›</button>
|
||||
</div>
|
||||
<div class="pagination" id="customersPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -375,12 +366,12 @@
|
||||
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<div class="customer-avatar" id="detailAvatar">MA</div>
|
||||
<div class="customer-avatar" id="detailAvatar">—</div>
|
||||
<div class="detail-header__info">
|
||||
<div class="detail-header__name" id="detailName">MIGUEL ÁNGEL TORRES</div>
|
||||
<div class="detail-header__rfc" id="detailRFC">TOAM820115HDF</div>
|
||||
<div class="detail-header__name" id="detailName">—</div>
|
||||
<div class="detail-header__rfc" id="detailRFC">—</div>
|
||||
<div class="detail-header__meta">
|
||||
<span class="tipo-chip tipo-chip--taller" id="detailTipo">Taller</span>
|
||||
<span class="tipo-chip tipo-chip--taller" id="detailTipo">—</span>
|
||||
<span class="badge badge--active" id="detailStatus"><span class="badge-dot"></span>Activo</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,7 +392,7 @@
|
||||
</div>
|
||||
<div class="info-row info-row--full">
|
||||
<span class="info-label">Dirección</span>
|
||||
<span class="info-value" id="detailAddress">Av. Insurgentes Sur 1602, Col. Crédito Constructor, CDMX</span>
|
||||
<span class="info-value" id="detailAddress">—</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">CP</span>
|
||||
@@ -460,6 +451,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicles -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section__title">Vehículos</div>
|
||||
<div id="detailVehicles" style="display:flex;flex-direction:column;gap:var(--space-2);">
|
||||
<span style="color:var(--color-text-muted);">Sin vehículos registrados</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section__title">Acciones Rápidas</div>
|
||||
@@ -498,6 +497,15 @@
|
||||
</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>
|
||||
|
||||
@@ -642,13 +650,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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/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" defer></script>
|
||||
<script src="/pos/static/js/customers.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/customers.js?v=35" 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>
|
||||
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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=3">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=33"></head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -159,6 +158,16 @@
|
||||
Ventas Históricas
|
||||
</a>
|
||||
|
||||
<a href="/pos/remission-notes" class="nav-link">
|
||||
<span class="nav-link__icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2 3h12v10H2z"/>
|
||||
<path d="M5 7h6M5 10h4" stroke-width="1.2"/>
|
||||
</svg>
|
||||
</span>
|
||||
Notas de Remisión
|
||||
</a>
|
||||
|
||||
<div class="sidebar__section-label">Gestión</div>
|
||||
|
||||
<a href="/pos/marketplace" class="nav-link">
|
||||
@@ -511,6 +520,40 @@
|
||||
</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 →</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
|
||||
================================================================= -->
|
||||
@@ -562,15 +605,15 @@
|
||||
|
||||
|
||||
<script src="/pos/static/js/chart.umd.min.js" 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/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/sync-engine.js" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js?v=34',{scope:'/pos/'});}</script>
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
@@ -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=2" />
|
||||
<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/onboarding.css" />
|
||||
@@ -17,8 +17,7 @@
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/diagrams.css">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/diagrams.css"></head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
|
||||
@@ -149,12 +148,12 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/app-init.js" 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/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/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>
|
||||
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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">
|
||||
@@ -303,11 +302,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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/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/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>
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
.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>
|
||||
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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=8">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34"></head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -193,7 +192,7 @@
|
||||
<h1 class="page-header__title">Inventario</h1>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
||||
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Importar CSV
|
||||
</button>
|
||||
@@ -205,7 +204,7 @@
|
||||
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
|
||||
Sincronizar
|
||||
</button>
|
||||
<button class="btn btn--primary" onclick="showCreateModal()">
|
||||
<button class="btn btn--primary" id="btnHeaderNewProduct" onclick="showCreateModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Producto
|
||||
</button>
|
||||
@@ -337,7 +336,7 @@
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
|
||||
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
|
||||
</button>
|
||||
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
|
||||
<button class="btn btn--primary btn--sm" id="btnStockNewProduct" onclick="showCreateModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Producto
|
||||
</button>
|
||||
@@ -711,42 +710,48 @@
|
||||
|
||||
<!-- ===== MODALS ===== -->
|
||||
|
||||
<!-- Create Item Modal -->
|
||||
<!-- Create/Edit Item Modal -->
|
||||
<div class="inv-modal-overlay" id="createModal">
|
||||
<div class="inv-modal">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Nuevo Producto</h3>
|
||||
<h3 id="createModalTitle">Nuevo Producto</h3>
|
||||
<button class="inv-modal__close" onclick="closeCreateModal()">×</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" 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 class="select-filter" id="newCategory" style="width:100%;">
|
||||
<option value="">Sin categoría</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>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>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>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
|
||||
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" 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>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" onclick="createItem()">Crear Producto</button>
|
||||
<button class="btn btn--primary" id="createModalBtn" onclick="createItem()">Crear Producto</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1013,6 +1018,9 @@
|
||||
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">×</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);" />
|
||||
@@ -1034,8 +1042,8 @@
|
||||
</div>
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
|
||||
<strong>Columnas esperadas:</strong>
|
||||
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
|
||||
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
||||
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, sku_secondary, description, category, make, model, year, engine, engine_code</code>
|
||||
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, sku_secundario, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
||||
</div>
|
||||
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
|
||||
</div>
|
||||
@@ -1053,13 +1061,13 @@
|
||||
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
||||
</div>
|
||||
|
||||
<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/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/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>
|
||||
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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>
|
||||
|
||||
@@ -339,25 +338,14 @@
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="Buscar folio, RFC, cliente…" />
|
||||
<input id="facturas-search" type="text" placeholder="Buscar folio, RFC, cliente…" oninput="Invoicing.filterFacturas()" aria-label="Buscar facturas" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<select id="facturas-status-filter" class="select-filter" aria-label="Filtrar por estatus" onchange="Invoicing.loadFacturas()">
|
||||
<option value="">Todas</option>
|
||||
<option value="timbrada">Timbradas</option>
|
||||
<option value="pendiente">Pendientes</option>
|
||||
<option value="cancelada">Canceladas</option>
|
||||
<option value="ppd">PPD</option>
|
||||
<option value="stamped">Timbradas</option>
|
||||
<option value="pending">Pendientes</option>
|
||||
<option value="cancelled">Canceladas</option>
|
||||
</select>
|
||||
|
||||
<div class="toolbar__spacer"></div>
|
||||
@@ -372,7 +360,7 @@
|
||||
Factura Global
|
||||
</button>
|
||||
|
||||
<button class="btn btn--ghost">
|
||||
<button id="facturas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportFacturasCSV()">
|
||||
<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"/>
|
||||
@@ -422,10 +410,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…" />
|
||||
<input type="text" placeholder="Buscar nota de crédito…" aria-label="Buscar notas de credito" />
|
||||
</div>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--ghost">
|
||||
<button id="notas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportNotasCSV()">
|
||||
<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"/>
|
||||
@@ -433,7 +421,7 @@
|
||||
</svg>
|
||||
Exportar
|
||||
</button>
|
||||
<button class="btn btn--primary">
|
||||
<button id="notas-new" class="btn btn--primary" onclick="Invoicing.newCreditNote()">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
@@ -478,9 +466,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…" />
|
||||
<input type="text" placeholder="Buscar complemento de pago…" aria-label="Buscar complementos de pago" />
|
||||
</div>
|
||||
<select class="select-filter" aria-label="Método de pago">
|
||||
<select id="complementos-method-filter" class="select-filter" aria-label="Método de pago" onchange="Invoicing.loadComplementos()">
|
||||
<option value="">Todos los métodos</option>
|
||||
<option value="03">03 Transferencia electrónica</option>
|
||||
<option value="04">04 Tarjeta de crédito</option>
|
||||
@@ -489,7 +477,7 @@
|
||||
<option value="99">99 Por definir</option>
|
||||
</select>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary">
|
||||
<button id="complementos-new" class="btn btn--primary" onclick="Invoicing.newPaymentComplement()">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
@@ -519,17 +507,7 @@
|
||||
|
||||
<div class="table-footer">
|
||||
<span></span>
|
||||
<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 class="pagination" id="complementos-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /panel-complementos -->
|
||||
@@ -779,11 +757,47 @@
|
||||
<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);">
|
||||
<button class="btn btn--ghost">Cancelar</button>
|
||||
<button class="btn btn--primary">
|
||||
<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()">
|
||||
<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"/>
|
||||
@@ -888,6 +902,44 @@
|
||||
</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">
|
||||
@@ -1085,12 +1137,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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/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/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>
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
------------------------------------------------------------------ */
|
||||
const state = {
|
||||
selectedUser: null,
|
||||
selectedUserId: null,
|
||||
pin: [],
|
||||
maxPinLength: 6,
|
||||
};
|
||||
@@ -325,18 +326,21 @@
|
||||
localStorage.setItem('pos_device_id', deviceId);
|
||||
}
|
||||
|
||||
// Auto-redirect if already logged in with valid token
|
||||
// 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.
|
||||
(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) {
|
||||
window.location.href = '/pos/catalog';
|
||||
return;
|
||||
if (payload.exp * 1000 <= Date.now() + 30000) {
|
||||
localStorage.removeItem('pos_token');
|
||||
localStorage.removeItem('pos_employee');
|
||||
}
|
||||
} catch(e) {}
|
||||
localStorage.removeItem('pos_token');
|
||||
} catch(e) {
|
||||
localStorage.removeItem('pos_token');
|
||||
localStorage.removeItem('pos_employee');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -351,6 +355,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tenant_id: parseInt(tenantId),
|
||||
employee_id: state.selectedUserId,
|
||||
pin: enteredPin,
|
||||
device_id: deviceId
|
||||
})
|
||||
@@ -374,13 +379,19 @@
|
||||
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() {
|
||||
window.location.href = '/pos/catalog';
|
||||
var role = (result.data.employee.role || '').toLowerCase();
|
||||
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
||||
window.location.href = '/pos/workshop';
|
||||
} else {
|
||||
window.location.href = '/pos/catalog';
|
||||
}
|
||||
}, 1000);
|
||||
})
|
||||
.catch(function() {
|
||||
@@ -398,6 +409,7 @@
|
||||
|
||||
function resetLoginState() {
|
||||
state.selectedUser = null;
|
||||
state.selectedUserId = null;
|
||||
state.pin = [];
|
||||
|
||||
userBtns.forEach(b => {
|
||||
@@ -487,6 +499,7 @@
|
||||
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);
|
||||
@@ -506,6 +519,7 @@
|
||||
btn.classList.add('selected');
|
||||
btn.setAttribute('aria-checked', 'true');
|
||||
state.selectedUser = emp.initials;
|
||||
state.selectedUserId = emp.id;
|
||||
state.pin = [];
|
||||
enablePinPad();
|
||||
updatePinDisplay();
|
||||
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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">
|
||||
|
||||
@@ -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=2" />
|
||||
<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" />
|
||||
@@ -28,8 +28,7 @@
|
||||
.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>
|
||||
|
||||
@@ -343,12 +342,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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=2" />
|
||||
<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/chat.css" />
|
||||
@@ -16,8 +16,7 @@
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/pos.css?v=4">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/pos.css?v=35"></head>
|
||||
|
||||
<body class="pos-shell" id="appBody">
|
||||
|
||||
@@ -86,7 +85,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)" style="display:none;">C/M</button>
|
||||
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" aria-label="Mostrar costo y margen" style="display:none;">C/M</button>
|
||||
<span class="cart-header__status">Activa</span>
|
||||
</div>
|
||||
|
||||
@@ -199,15 +198,31 @@
|
||||
</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>
|
||||
@@ -239,6 +254,9 @@
|
||||
<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>
|
||||
@@ -290,6 +308,15 @@
|
||||
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
|
||||
Mixto
|
||||
</button>
|
||||
<button class="pago-tab" data-method="credito" onclick="POS.selectPaymentMethod('credito', this)">
|
||||
Crédito
|
||||
</button>
|
||||
<button class="pago-tab" data-method="cheque" onclick="POS.selectPaymentMethod('cheque', this)">
|
||||
Cheque
|
||||
</button>
|
||||
<button class="pago-tab" data-method="pendiente" onclick="POS.selectPaymentMethod('pendiente', this)">
|
||||
Pendiente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Efectivo -->
|
||||
@@ -325,26 +352,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">Metodo 1</label>
|
||||
<select class="form-input" style="margin-bottom:var(--space-2);">
|
||||
<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">
|
||||
<option value="efectivo">Efectivo</option>
|
||||
<option value="tarjeta">Tarjeta</option>
|
||||
<option value="transferencia">Transferencia</option>
|
||||
</select>
|
||||
<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);" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mixed-row" style="margin-bottom:var(--space-3);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Metodo 2</label>
|
||||
<select class="form-input" style="margin-bottom:var(--space-2);">
|
||||
<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">
|
||||
<option value="tarjeta">Tarjeta</option>
|
||||
<option value="efectivo">Efectivo</option>
|
||||
<option value="transferencia">Transferencia</option>
|
||||
</select>
|
||||
<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);" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-remaining">
|
||||
@@ -353,6 +380,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Crédito -->
|
||||
<div class="tab-content" id="creditPayment">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Venta a crédito</label>
|
||||
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Se registrará como venta a crédito para el cliente seleccionado y se agregará a su cuenta.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Cheque -->
|
||||
<div class="tab-content" id="chequePayment">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Monto</label>
|
||||
<input type="text" class="form-input form-input-lg" id="chequeAmount" readonly />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. de cheque / referencia</label>
|
||||
<input type="text" class="form-input" id="chequeRef" placeholder="No. de cheque" />
|
||||
</div>
|
||||
<div class="form-hint">Verificar que el cheque sea válido antes de confirmar</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Pendiente -->
|
||||
<div class="tab-content" id="pendingPayment">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pago pendiente</label>
|
||||
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">La venta quedará pendiente de pago. Podrá cobrarse más tarde desde el listado de ventas o notas de remisión.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CFDI Checkbox -->
|
||||
<div class="cfdi-check">
|
||||
<input type="checkbox" id="cfdiCheck" />
|
||||
@@ -368,6 +424,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
PAY REMISSION NOTE MODAL
|
||||
================================================================ -->
|
||||
<div class="modal-overlay" id="payRemissionModal">
|
||||
<div class="modal-pago">
|
||||
<div class="modal-header">
|
||||
<h3>Cobrar Nota de Remisión</h3>
|
||||
<button class="modal-close" onclick="POS.closePayRemissionModal()">✕</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
|
||||
================================================================ -->
|
||||
@@ -484,10 +602,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"><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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,17 +679,61 @@
|
||||
================================================================ -->
|
||||
<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()">✕</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ículo</label>
|
||||
<input type="text" class="form-input" id="soVehicle" placeholder="Marca / Modelo / Placas" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Vía de entrega</label>
|
||||
<select class="form-input" id="soDelivery">
|
||||
<option value="">—</option>
|
||||
<option value="pickup">Pasa cliente</option>
|
||||
<option value="delivery">Envío a domicilio</option>
|
||||
<option value="courier">Motociclista</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notas de recepció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" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=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/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/push.js" defer></script>
|
||||
<script src="/pos/static/js/printer.js" defer></script>
|
||||
<script src="/pos/static/js/pos.js?v=7" defer></script>
|
||||
<script src="/pos/static/js/pos.js?v=42" defer></script>
|
||||
|
||||
<script>
|
||||
// Cancel sale button wiring
|
||||
|
||||
@@ -8,17 +8,16 @@
|
||||
<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=2" />
|
||||
<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/quotations.css">
|
||||
</head>
|
||||
<link rel="stylesheet" href="/pos/static/css/quotations.css"></head>
|
||||
<body>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="page">
|
||||
<h1 class="page-title">Cotizaciones</h1>
|
||||
|
||||
430
pos/templates/remission_notes.html
Normal file
430
pos/templates/remission_notes.html
Normal file
@@ -0,0 +1,430 @@
|
||||
<!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 — 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()">×</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' : ''}>←</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' : ''}>→</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>
|
||||
@@ -8,15 +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=2" />
|
||||
<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="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>
|
||||
|
||||
@@ -230,6 +229,14 @@
|
||||
</svg>
|
||||
Histórico
|
||||
</button>
|
||||
<button class="tab-btn" onclick="switchTab('cortes', this)">
|
||||
<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<rect x="1" y="3" width="13" height="10" rx="1"/>
|
||||
<path d="M4 7h7M4 10h5"/>
|
||||
<circle cx="11" cy="10" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
Cortes de caja
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================================================================
|
||||
@@ -356,6 +363,38 @@
|
||||
<!-- Sales detail table -->
|
||||
<div class="table-card mb-5" id="historico-detalle"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ==================================================================
|
||||
TAB 6: CORTES DE CAJA
|
||||
================================================================== -->
|
||||
<div class="tab-panel" id="panel-cortes">
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<span class="filter-bar__label">Desde</span>
|
||||
<input type="date" class="filter-input" id="cortes-date-from" />
|
||||
<span class="filter-bar__label">Hasta</span>
|
||||
<input type="date" class="filter-input" id="cortes-date-to" />
|
||||
<span id="cortes-employee-filter">
|
||||
<span class="filter-bar__label">Cajero</span>
|
||||
<select class="filter-select" id="cortes-employee">
|
||||
<option value="">Todos</option>
|
||||
</select>
|
||||
</span>
|
||||
<div class="filter-bar__spacer"></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="Reports.loadCortes()">Generar</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards (dynamic) -->
|
||||
<div class="kpi-grid" id="cortes-kpis"></div>
|
||||
|
||||
<!-- Cortes detail table -->
|
||||
<div class="table-card mb-5" id="cortes-detalle"></div>
|
||||
|
||||
<!-- Detail of sales for the selected cash cut -->
|
||||
<div class="table-card mb-5" id="corte-ventas-detalle" style="display:none;"></div>
|
||||
|
||||
</div>
|
||||
<!-- End panels -->
|
||||
|
||||
@@ -366,12 +405,12 @@
|
||||
</div>
|
||||
<!-- End app-shell -->
|
||||
|
||||
<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/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/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>
|
||||
|
||||
@@ -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=2" />
|
||||
<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="manifest" href="/pos/static/pwa/manifest.json" />
|
||||
@@ -49,8 +49,7 @@
|
||||
.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 -->
|
||||
@@ -127,9 +126,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,15 +11,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=2" />
|
||||
<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="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">
|
||||
@@ -132,12 +131,12 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
||||
</script>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<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/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/chat.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -8,14 +8,19 @@
|
||||
<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=2" />
|
||||
<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="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=2">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -28,13 +33,13 @@
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header__title-group">
|
||||
<span class="page-header__eyebrow">Operación · 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á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>
|
||||
@@ -50,7 +55,7 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
|
||||
</div>
|
||||
<div class="summary-card__body">
|
||||
<div class="summary-card__label">Recibidos</div>
|
||||
<div class="summary-card__label">Por revisar</div>
|
||||
<div class="summary-card__value" id="statReceived">--</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +64,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ón</div>
|
||||
<div class="summary-card__label">En reparación</div>
|
||||
<div class="summary-card__value" id="statRepair">--</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +73,7 @@
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
|
||||
</div>
|
||||
<div class="summary-card__body">
|
||||
<div class="summary-card__label">Listos</div>
|
||||
<div class="summary-card__label">Por entregar</div>
|
||||
<div class="summary-card__value" id="statReady">--</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,8 +88,81 @@
|
||||
</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ó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ón parcial</option>
|
||||
<option value="en_reparacion">En reparació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ías de entrega</option>
|
||||
<option value="pickup">Mostrador</option>
|
||||
<option value="delivery">Enví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í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">
|
||||
<div class="kanban-board" id="kanbanBoard" style="display:none;">
|
||||
<!-- Columns injected by JS -->
|
||||
</div>
|
||||
</main>
|
||||
@@ -115,36 +193,72 @@
|
||||
</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>
|
||||
<select class="form-input" id="noCustomer" required></select>
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noVehicle">Vehículo</label>
|
||||
<select class="form-input" id="noVehicle"></select>
|
||||
<label class="form-label" for="noWorkshopName">Taller</label>
|
||||
<input class="form-input" id="noWorkshopName" placeholder="Nombre del taller" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noMechanic">Mecánico asignado</label>
|
||||
<label class="form-label" for="noCustomerPhone">Telé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ón</label>
|
||||
<input class="form-input" id="noCustomerAddress" placeholder="Calle, número, colonia" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noVehicleDescription">Vehículo</label>
|
||||
<input class="form-input" id="noVehicleDescription" placeholder="Ej. Versa 2020" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noDelivery">Vía de entrega</label>
|
||||
<select class="form-input" id="noDelivery">
|
||||
<option value="">—</option>
|
||||
<option value="pickup">Mostrador</option>
|
||||
<option value="delivery">Enví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ánico asignado (usuario)</label>
|
||||
<select class="form-input" id="noMechanic"></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noPriority">Prioridad</label>
|
||||
<select class="form-input" id="noPriority">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
<label class="form-label" for="noMechanicName">Mecánico asignado (nombre libre)</label>
|
||||
<input class="form-input" type="text" id="noMechanicName" placeholder="Ej. Juan Pérez" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
|
||||
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
|
||||
<label class="form-label" for="noEstimatedCost">Presupuesto</label>
|
||||
<input class="form-input" type="number" id="noEstimatedCost" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noMileage">Kilometraje</label>
|
||||
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
|
||||
<label class="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>
|
||||
</div>
|
||||
<div class="form-field form-field--span2">
|
||||
<label class="form-label" for="noNotes">Notas de recepción</label>
|
||||
<label class="form-label" for="noNotes">Observaciones</label>
|
||||
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
|
||||
</div>
|
||||
</form>
|
||||
@@ -156,11 +270,184 @@
|
||||
</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()">×</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é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ón</label>
|
||||
<input class="form-input" id="eoCustomerAddress" placeholder="Calle, número, colonia" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoVehicleDescription">Vehículo</label>
|
||||
<input class="form-input" id="eoVehicleDescription" placeholder="Ej. Versa 2020" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoDelivery">Vía de entrega</label>
|
||||
<select class="form-input" id="eoDelivery">
|
||||
<option value="">—</option>
|
||||
<option value="pickup">Mostrador</option>
|
||||
<option value="delivery">Enví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ánico asignado (usuario)</label>
|
||||
<select class="form-input" id="eoMechanic"></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoMechanicName">Mecá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ículo</h2>
|
||||
<button class="modal__close" onclick="Workshop.closeNewVehicleModal()">×</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ñ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í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()">×</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é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ón</label>
|
||||
<input class="form-input" id="ncAddress" placeholder="Calle, nú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álogo de servicios</h2>
|
||||
<h2 class="modal__title">Catálogo de servicios</h2>
|
||||
<button class="modal__close" onclick="Workshop.closeCatalogModal()">×</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
@@ -171,11 +458,11 @@
|
||||
<div class="form-field">
|
||||
<input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="form-field price-col">
|
||||
<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ó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>
|
||||
@@ -188,8 +475,8 @@
|
||||
<tr>
|
||||
<th>Servicio</th>
|
||||
<th>Horas</th>
|
||||
<th>Precio/hora</th>
|
||||
<th>Total sugerido</th>
|
||||
<th class="price-col">Precio/hora</th>
|
||||
<th class="price-col">Total sugerido</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -200,16 +487,19 @@
|
||||
</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" 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/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/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=57" 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>
|
||||
|
||||
@@ -64,17 +64,20 @@ def conn():
|
||||
return MockConn(MockCursor())
|
||||
|
||||
|
||||
def test_generate_order_number_first_of_year(conn):
|
||||
def test_generate_order_number_first_of_day(conn):
|
||||
conn._cursor.responses = [(None,)]
|
||||
number = engine._generate_order_number(conn)
|
||||
assert number.startswith("SO-")
|
||||
assert number.endswith("-0001")
|
||||
# Format DDMMYYYY-N
|
||||
assert len(number.split("-")) == 2
|
||||
assert number.split("-")[1] == "1"
|
||||
|
||||
|
||||
def test_generate_order_number_increments(conn):
|
||||
conn._cursor.responses = [("SO-2026-0042",)]
|
||||
from datetime import datetime
|
||||
today = datetime.utcnow().strftime('%d%m%Y')
|
||||
conn._cursor.responses = [(f"{today}-42",)]
|
||||
number = engine._generate_order_number(conn)
|
||||
assert number.endswith("-0043")
|
||||
assert number == f"{today}-43"
|
||||
|
||||
|
||||
@mock.patch("services.inventory_engine.get_stock", return_value=10)
|
||||
@@ -82,11 +85,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"), # item lookup
|
||||
(1, 5, 3, "pending", "SO-2026-0001", 2), # item lookup (branch_id=2)
|
||||
None, # update
|
||||
]
|
||||
|
||||
result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9)
|
||||
result = engine.reserve_item(conn, 7, branch_id=99, employee_id=9)
|
||||
|
||||
assert result["reserved"] == 3
|
||||
mock_stock.assert_called_once_with(conn, 5, 2)
|
||||
@@ -99,11 +102,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"),
|
||||
(1, 5, 3, "pending", "SO-2026-0001", 2),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="Insufficient stock"):
|
||||
engine.reserve_item(conn, 7, branch_id=2)
|
||||
engine.reserve_item(conn, 7, branch_id=99)
|
||||
|
||||
|
||||
@mock.patch("services.inventory_engine.record_operation", return_value=124)
|
||||
|
||||
@@ -8,6 +8,7 @@ flask-sqlalchemy>=3.1
|
||||
PyJWT>=2.8
|
||||
bcrypt>=4.0
|
||||
openpyxl>=3.1
|
||||
fpdf2>=2.8
|
||||
orjson
|
||||
quart
|
||||
asyncpg
|
||||
|
||||
70
scripts/allocate_existing_customer_payments.py
Normal file
70
scripts/allocate_existing_customer_payments.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/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.')
|
||||
79
scripts/capture_rached_network.js
Normal file
79
scripts/capture_rached_network.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
function apiLogin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify({
|
||||
grant_type: 'password',
|
||||
client_secret: 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
||||
client_id: 2,
|
||||
username: 'IVAN@flechasyventiladores-rached.com',
|
||||
password: 'Nexus01'
|
||||
});
|
||||
const req = http.request({
|
||||
hostname: 'appapi.flechasyventiladores-rached.com',
|
||||
path: '/api/seguridad/login',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'http://app.flechasyventiladores-rached.com',
|
||||
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const outDir = path.resolve(__dirname, '../data/rached_import/har');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const loginResp = await apiLogin();
|
||||
const sesion = JSON.stringify(loginResp.datos);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const allRequests = [];
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request();
|
||||
allRequests.push({ method: req.method(), url: req.url(), headers: req.headers(), postData: req.postData() });
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(s => { localStorage.setItem('sesion', s); }, sesion);
|
||||
|
||||
const routes = [
|
||||
'/administracion/servicios/ordenesservicio',
|
||||
'/administracion/catalogos/clientes',
|
||||
'/administracion/catalogos/articulos',
|
||||
'/administracion/catalogos/mecanicos',
|
||||
'/administracion/catalogos/sucursales',
|
||||
'/administracion/catalogos/motociclistas',
|
||||
'/administracion/catalogos/viasentrega',
|
||||
'/administracion/catalogos/telefonostipos',
|
||||
'/administracion/catalogos/direccionestipos',
|
||||
];
|
||||
for (const r of routes) {
|
||||
await page.goto('http://app.flechasyventiladores-rached.com' + r, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outDir, 'all_requests.json'), JSON.stringify(allRequests, null, 2));
|
||||
await page.screenshot({ path: path.join(outDir, 'last_screen.png'), fullPage: true });
|
||||
await browser.close();
|
||||
console.log('Captured', allRequests.length, 'total requests');
|
||||
})();
|
||||
142
scripts/clone_inventory.py
Normal file
142
scripts/clone_inventory.py
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/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()
|
||||
97
scripts/import_pz_stock_la_casita.py
Normal file
97
scripts/import_pz_stock_la_casita.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/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()
|
||||
442
scripts/import_rached_workshop.py
Normal file
442
scripts/import_rached_workshop.py
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import Rached legacy workshop data into the Nexus tenant_refaccionaria_rached DB."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = BASE_DIR / 'data' / 'rached_import'
|
||||
TENANT_DB = 'tenant_refaccionaria_rached'
|
||||
|
||||
# Load DB URL from environment or use localhost defaults
|
||||
DB_URL = os.environ.get(
|
||||
'TENANT_DB_URL',
|
||||
f'postgresql://postgres@localhost/{TENANT_DB}'
|
||||
)
|
||||
|
||||
|
||||
def normalize_name(s):
|
||||
if not s:
|
||||
return ''
|
||||
return re.sub(r'[^a-z0-9]', '', s.lower().replace('sucursal', '').strip())
|
||||
|
||||
|
||||
def load_json(name):
|
||||
with open(DATA_DIR / f'{name}.json', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_detail_json(cid):
|
||||
path = DATA_DIR / 'client_details' / f'client_{cid}.json'
|
||||
if path.exists():
|
||||
with open(path, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg2.connect(DB_URL)
|
||||
|
||||
|
||||
ORDER_STATUS_MAP = {
|
||||
1: 'por_recolectar',
|
||||
2: 'por_revisar',
|
||||
3: 'en_revision',
|
||||
4: 'revisada',
|
||||
5: 'cotizada',
|
||||
6: 'por_autorizar',
|
||||
7: 'autorizada',
|
||||
8: 'autorizacion_parcial',
|
||||
9: 'cancelada',
|
||||
10: 'en_reparacion',
|
||||
11: 'reparada',
|
||||
12: 'por_entregar',
|
||||
13: 'enviado',
|
||||
14: 'entregado',
|
||||
15: 'por_facturar',
|
||||
16: 'facturada',
|
||||
17: 'por_enviar',
|
||||
}
|
||||
|
||||
ITEM_STATUS_MAP = {
|
||||
1: 'por_revisar',
|
||||
2: 'revisando',
|
||||
3: 'revisado',
|
||||
4: 'cotizado',
|
||||
5: 'por_autorizar',
|
||||
6: 'autorizado',
|
||||
7: 'cancelado',
|
||||
8: 'en_reparacion',
|
||||
9: 'reparado',
|
||||
10: 'por_entregar',
|
||||
11: 'enviado',
|
||||
12: 'entregado',
|
||||
13: 'por_enviar',
|
||||
}
|
||||
|
||||
|
||||
def get_default_branch(cur):
|
||||
cur.execute("SELECT id FROM branches WHERE is_main = true LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def ensure_branches(cur):
|
||||
cur.execute("SELECT id, name FROM branches")
|
||||
existing = {normalize_name(name): id for id, name in cur.fetchall()}
|
||||
catalog = load_json('catalog_sucursales')['datos']
|
||||
mapping = {}
|
||||
for s in catalog:
|
||||
name = (s.get('nombre') or '').strip()
|
||||
# Skip the erroneous Rached branch
|
||||
if name.upper() == 'CREMALLERAS':
|
||||
continue
|
||||
key = normalize_name(name)
|
||||
if key in existing:
|
||||
mapping[s['id']] = existing[key]
|
||||
else:
|
||||
cur.execute(
|
||||
"INSERT INTO branches (name, is_active) VALUES (%s, true) RETURNING id",
|
||||
(s['nombre'],)
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
existing[key] = new_id
|
||||
mapping[s['id']] = new_id
|
||||
print(f"Created branch {s['nombre']} -> {new_id}")
|
||||
return mapping
|
||||
|
||||
|
||||
def ensure_customers(cur, branch_map):
|
||||
# collect unique client ids referenced in orders
|
||||
client_ids = set()
|
||||
for f in DATA_DIR.glob('details/order_*.json'):
|
||||
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
|
||||
cid = d.get('idCliente')
|
||||
if cid:
|
||||
client_ids.add(cid)
|
||||
|
||||
# load existing customers by name
|
||||
cur.execute("SELECT id, name FROM customers")
|
||||
existing_by_name = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||
|
||||
client_map = {}
|
||||
default_branch = get_default_branch(cur)
|
||||
for cid in sorted(client_ids):
|
||||
detail = load_detail_json(cid)
|
||||
datos = detail.get('datos', {}) if detail else {}
|
||||
# Name: prefer 'taller' if meaningful, else full name
|
||||
taller = (datos.get('taller') or '').strip()
|
||||
nombre = (datos.get('nombre') or '').strip()
|
||||
ap1 = (datos.get('primerApellido') or '').strip()
|
||||
ap2 = (datos.get('segundoApellido') or '').strip()
|
||||
if taller:
|
||||
name = taller
|
||||
else:
|
||||
name = ' '.join([nombre, ap1, ap2]).strip()
|
||||
if not name:
|
||||
name = f"Cliente Rached {cid}"
|
||||
|
||||
if name.lower() in existing_by_name:
|
||||
client_map[cid] = existing_by_name[name.lower()]
|
||||
continue
|
||||
|
||||
# phone / address
|
||||
phone = None
|
||||
for t in datos.get('telefonos', []):
|
||||
num = t.get('numero')
|
||||
if num:
|
||||
phone = str(num)
|
||||
break
|
||||
address = None
|
||||
for drec in datos.get('direcciones', []):
|
||||
address = drec.get('completa')
|
||||
if address:
|
||||
break
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO customers (branch_id, name, phone, address, price_tier, is_active)
|
||||
VALUES (%s, %s, %s, %s, 2, true) RETURNING id
|
||||
""",
|
||||
(default_branch, name, phone, address)
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
existing_by_name[name.lower()] = new_id
|
||||
client_map[cid] = new_id
|
||||
print(f"Created customer {cid}: {name}")
|
||||
return client_map
|
||||
|
||||
|
||||
def ensure_employees(cur, branch_map, role, catalog, existing_names=None):
|
||||
default_branch = get_default_branch(cur)
|
||||
emp_map = {}
|
||||
if existing_names is None:
|
||||
cur.execute("SELECT id, name FROM employees")
|
||||
existing_names = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||
for e in catalog:
|
||||
name = (e.get('nombre') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in existing_names:
|
||||
emp_map[e['id']] = existing_names[key]
|
||||
continue
|
||||
is_active = bool(e.get('activo', True))
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO employees (name, role, branch_id, is_active)
|
||||
VALUES (%s, %s, %s, %s) RETURNING id
|
||||
""",
|
||||
(name, role, default_branch, is_active)
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
existing_names[key] = new_id
|
||||
emp_map[e['id']] = new_id
|
||||
print(f"Created employee {role} {e['id']}: {name}")
|
||||
return emp_map
|
||||
|
||||
|
||||
def ensure_couriers(cur, catalog, tenant_id=31):
|
||||
cur.execute("SELECT id, name FROM couriers WHERE tenant_id = %s", (tenant_id,))
|
||||
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||
courier_map = {}
|
||||
for c in catalog:
|
||||
name = (c.get('nombre') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in existing:
|
||||
courier_map[c['id']] = existing[key]
|
||||
continue
|
||||
cur.execute(
|
||||
"INSERT INTO couriers (tenant_id, name, code, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
||||
(tenant_id, name, f"MOT-{c['id']}")
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
existing[key] = new_id
|
||||
courier_map[c['id']] = new_id
|
||||
print(f"Created courier {c['id']}: {name}")
|
||||
return courier_map
|
||||
|
||||
|
||||
def ensure_articles(cur, branch_map):
|
||||
"""Rached articles are kept as free-text lines, not inventory products."""
|
||||
catalog = load_json('catalog_articulos')
|
||||
article_map = {}
|
||||
for a in catalog:
|
||||
name = (a.get('nombre') or '').strip()
|
||||
if name:
|
||||
article_map[a['id']] = name
|
||||
return article_map
|
||||
|
||||
|
||||
def ensure_users(cur, branch_map, order_details):
|
||||
# Map Rached user ids to employees
|
||||
default_branch = get_default_branch(cur)
|
||||
cur.execute("SELECT id, name FROM employees")
|
||||
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
||||
user_map = {}
|
||||
for d in order_details:
|
||||
datos = d.get('datos', {})
|
||||
uid = datos.get('idUsuario')
|
||||
name = (datos.get('usuarioNombre') or '').strip()
|
||||
if not uid or not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in existing:
|
||||
user_map[uid] = existing[key]
|
||||
continue
|
||||
cur.execute(
|
||||
"INSERT INTO employees (name, role, branch_id, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
||||
(name, 'workshop', default_branch)
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
existing[key] = new_id
|
||||
user_map[uid] = new_id
|
||||
print(f"Created user {uid}: {name}")
|
||||
return user_map
|
||||
|
||||
|
||||
def import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map):
|
||||
order_files = sorted(DATA_DIR.glob('details/order_*.json'))
|
||||
imported = 0
|
||||
skipped = 0
|
||||
for f in order_files:
|
||||
d = json.loads(f.read_text(encoding='utf-8'))
|
||||
datos = d.get('datos', {})
|
||||
order_number = datos.get('numero')
|
||||
if not order_number:
|
||||
continue
|
||||
cur.execute("SELECT id FROM service_orders WHERE order_number = %s", (order_number,))
|
||||
if cur.fetchone():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
branch_id = branch_map.get(datos.get('idSucursal'))
|
||||
customer_id = client_map.get(datos.get('idCliente'))
|
||||
status = ORDER_STATUS_MAP.get(datos.get('idEstatus'), 'por_revisar')
|
||||
|
||||
# delivery method
|
||||
via = datos.get('idViaEntrega')
|
||||
mot_rec = datos.get('idMotociclistaRecoleccion')
|
||||
mot_ent = datos.get('idMotociclistaEntrega')
|
||||
courier_id = None
|
||||
if via == 1:
|
||||
delivery_method = 'pickup'
|
||||
elif via == 2:
|
||||
if mot_ent:
|
||||
delivery_method = 'courier'
|
||||
courier_id = courier_map.get(mot_ent)
|
||||
elif mot_rec:
|
||||
delivery_method = 'courier'
|
||||
courier_id = courier_map.get(mot_rec)
|
||||
else:
|
||||
delivery_method = 'delivery'
|
||||
else:
|
||||
delivery_method = None
|
||||
|
||||
# customer address/phone from order overrides if present? order detail only has ids.
|
||||
# Prefill from customer record (already stored in customers)
|
||||
workshop_name = None
|
||||
customer_phone = None
|
||||
customer_address = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, phone, address FROM customers WHERE id = %s", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
workshop_name, customer_phone, customer_address = row
|
||||
|
||||
created_by = user_map.get(datos.get('idUsuario'))
|
||||
fecha = datos.get('fecha')
|
||||
created_at = datetime.strptime(fecha, '%Y-%m-%d %H:%M:%S') if fecha else datetime.now()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO service_orders
|
||||
(tenant_id, branch_id, customer_id, order_number, status,
|
||||
workshop_name, customer_address, customer_phone, vehicle_description,
|
||||
reception_notes, estimated_cost, final_cost,
|
||||
delivery_method, courier_id, is_direct, requires_invoice,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
31, # tenant_id for Rached
|
||||
branch_id,
|
||||
customer_id,
|
||||
order_number,
|
||||
status,
|
||||
workshop_name,
|
||||
customer_address,
|
||||
customer_phone,
|
||||
datos.get('vehiculo'),
|
||||
datos.get('observaciones'),
|
||||
datos.get('presupuesto') or 0,
|
||||
datos.get('total') or 0,
|
||||
delivery_method,
|
||||
courier_id,
|
||||
bool(datos.get('ordenDirecta')),
|
||||
bool(datos.get('requiereFactura')),
|
||||
created_by,
|
||||
created_at,
|
||||
created_at,
|
||||
)
|
||||
)
|
||||
so_id = cur.fetchone()[0]
|
||||
|
||||
# status history
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO service_order_status_history (service_order_id, new_status, changed_by, notes, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(so_id, status, created_by, 'Importado desde app Rached', created_at)
|
||||
)
|
||||
|
||||
# items (free-text lines; not linked to inventory)
|
||||
for it in datos.get('detalles', []):
|
||||
articulo = it.get('articulo', {})
|
||||
art_id = articulo.get('id')
|
||||
art_name = article_map.get(art_id) or articulo.get('nombre') or 'Concepto'
|
||||
mech_id = mech_map.get(it.get('idMecanico'))
|
||||
item_status = ITEM_STATUS_MAP.get(it.get('idEstatusDetalle'), 'por_revisar')
|
||||
qty = it.get('cantidad', 1)
|
||||
price = it.get('precio') or 0
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO service_order_items
|
||||
(service_order_id, inventory_id, part_number, name, quantity,
|
||||
unit_cost, unit_price, status, mechanic_id, observations)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
so_id,
|
||||
None,
|
||||
None,
|
||||
art_name,
|
||||
qty,
|
||||
price,
|
||||
price,
|
||||
item_status,
|
||||
mech_id,
|
||||
it.get('observaciones'),
|
||||
)
|
||||
)
|
||||
|
||||
imported += 1
|
||||
if imported % 100 == 0:
|
||||
print(f"Imported {imported} orders...")
|
||||
|
||||
return imported, skipped
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Importing Rached workshop data into {TENANT_DB}")
|
||||
conn = connect()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
branch_map = ensure_branches(cur)
|
||||
print(f"Branch map: {branch_map}")
|
||||
|
||||
client_map = ensure_customers(cur, branch_map)
|
||||
print(f"Customers to import: {len(client_map)}")
|
||||
|
||||
mech_catalog = load_json('catalog_mecanicos')
|
||||
mech_map = ensure_employees(cur, branch_map, 'mechanic', mech_catalog)
|
||||
print(f"Mechanics map: {len(mech_map)}")
|
||||
|
||||
courier_catalog = load_json('catalog_motociclistas')
|
||||
courier_map = ensure_couriers(cur, courier_catalog, tenant_id=31)
|
||||
print(f"Couriers map: {len(courier_map)}")
|
||||
|
||||
article_map = ensure_articles(cur, branch_map)
|
||||
print(f"Articles map: {len(article_map)}")
|
||||
|
||||
order_details = [json.loads(f.read_text(encoding='utf-8')) for f in DATA_DIR.glob('details/order_*.json')]
|
||||
user_map = ensure_users(cur, branch_map, order_details)
|
||||
print(f"Users map: {len(user_map)}")
|
||||
|
||||
imported, skipped = import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map)
|
||||
print(f"Imported: {imported}, Skipped (already exist): {skipped}")
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"ERROR: {e}")
|
||||
raise
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user