Compare commits
1 Commits
main
...
desarrollo
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f753376e3 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -91,7 +91,3 @@ backups/
|
|||||||
# Local tools (AWS CLI)
|
# Local tools (AWS CLI)
|
||||||
tools/
|
tools/
|
||||||
|
|
||||||
# Rached migration session artifacts (tokens / captures / samples)
|
|
||||||
rached_*.json
|
|
||||||
rached_*.txt
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
# Auditoría y Mejoras del Sistema Nexus POS — 15 de junio de 2026
|
|
||||||
|
|
||||||
## Resumen ejecutivo
|
|
||||||
|
|
||||||
Se realizó una revisión completa del sistema Nexus POS enfocada en corregir errores críticos y mayores, eliminar código muerto, cerrar brechas de seguridad menores y estandarizar la experiencia de usuario. También se aplicaron optimizaciones de rendimiento en consultas frecuentes y se mejoró la accesibilidad de los formularios.
|
|
||||||
|
|
||||||
- **Rama/base:** `main`
|
|
||||||
- **Tenant de pruebas:** `tenant_refaccionaria_la_casita` (`tenant_id = 33`)
|
|
||||||
- **Servicio:** `nexus-pos.service` (puerto 5001)
|
|
||||||
- **Estado final:** operativo, tests pasando
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problemas críticos corregidos
|
|
||||||
|
|
||||||
| Problema | Solución | Archivos principales |
|
|
||||||
|----------|----------|----------------------|
|
|
||||||
| `@require_auth` sin paréntesis en `tasks_bp` | Se cambió al decorador correcto | `pos/blueprints/tasks_bp.py` |
|
|
||||||
| Permiso inexistente `accounting.read` | Se reemplazó por `accounting.view` | `pos/blueprints/accounting_bp.py` |
|
|
||||||
| `historical_sales.html` no cargaba `api.js` | Se incluyó el helper compartido | `pos/templates/historical_sales.html`, `pos/static/js/api.js` |
|
|
||||||
| `featureProximamente` no estaba disponible en `customers.js` | Se expuso la función en el módulo | `pos/static/js/customers.js` |
|
|
||||||
| `pos_engine` usaba `threading` sin importar y la caja sin `FOR UPDATE` | Se agregó `import threading` y lock pesimista de caja | `pos/services/pos_engine.py` |
|
|
||||||
| Race condition en stock de ventas | Se agregó `SELECT ... FOR UPDATE` sobre `inventory_stock` | `pos/services/pos_engine.py` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Problemas mayores corregidos
|
|
||||||
|
|
||||||
| Problema | Solución | Archivos principales |
|
|
||||||
|----------|----------|----------------------|
|
|
||||||
| N+1 en `complete_layaway` | Se cargan todos los items en una sola consulta | `pos/services/pos_engine.py` |
|
|
||||||
| Race conditions en folios CFDI y números de póliza | Se usó `pg_advisory_xact_lock(hashtext(prefix))` | `pos/services/cfdi_queue.py`, `pos/services/accounting_engine.py` |
|
|
||||||
| N+1 en flotilla (schedules/history) | Se crearon endpoints bulk `/vehicles/schedules` y `/vehicles/history` | `pos/blueprints/fleet_bp.py`, `pos/static/js/fleet.js` |
|
|
||||||
| N+1 en dashboard (ventas recientes) y `global_invoice` | Se consolidaron consultas | `pos/blueprints/dashboard_stats_bp.py`, `pos/services/global_invoice.py` |
|
|
||||||
| `client_secret` de MercadoLibre expuesto en frontend | Se movió el flujo OAuth a backend con endpoint `/connect/init` | `pos/blueprints/marketplace_external_bp.py`, `pos/static/js/marketplace_external.js` |
|
|
||||||
| Botones/filtros sin handler en config, customers y accounting | Se conectaron eventos y handlers faltantes | `pos/static/js/config.js`, `pos/static/js/customers.js`, `pos/static/js/accounting.v9.js`, `pos/templates/*.html` |
|
|
||||||
| Botones/filtros sin handler en invoicing | Se implementaron filtros, export CSV y acciones | `pos/static/js/invoicing.js`, `pos/templates/invoicing.html` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Mejoras de rendimiento
|
|
||||||
|
|
||||||
- **Locks atómicos:** los folios de órdenes de servicio, CFDI y pólizas contables ahora se generan bajo `pg_advisory_xact_lock` para evitar duplicados bajo concurrencia.
|
|
||||||
- **Consultas bulk:** flotilla y dashboard redujeron drásticamente el número de queries al backend.
|
|
||||||
- **Virtual scroll:** se mantiene en tablas grandes (clientes, programas de mantenimiento, historial) para renderizado eficiente.
|
|
||||||
- **Cache busting:** se actualizaron los query strings estáticos a `?v=32` y el Service Worker a `v32` para forzar la actualización de assets en navegadores y PWA.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Mejoras de UX y accesibilidad
|
|
||||||
|
|
||||||
- **Formularios accesibles:** se agregaron `aria-label`, `id` y `<label>` explícitos en:
|
|
||||||
- POS: pago mixto, toggle de costo/margen, campos de vehículo del modal de cliente.
|
|
||||||
- Clientes: búsqueda y filtros.
|
|
||||||
- Catálogo: búsqueda y filtro de niveles.
|
|
||||||
- Facturación: búsquedas de facturas, notas de crédito y complementos de pago.
|
|
||||||
- **Estados de carga consistentes:** se añadió `renderLoadingState()` en `pos-utils.js` con estilo en `pos-ui.css`, y se aplicó en:
|
|
||||||
- Clientes (`customers.js`)
|
|
||||||
- Facturación (`invoicing.js`)
|
|
||||||
- Flotilla (`fleet.js`): vehículos, programas, historial y alertas.
|
|
||||||
- **Empty states normalizados:** los errores y listas vacías ahora usan `renderEmptyState()` en lugar de mensajes inline inconsistentes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Limpieza de código
|
|
||||||
|
|
||||||
- Se eliminaron las versiones obsoletas `pos/static/js/accounting.js` y `pos/static/js/accounting.min.js`.
|
|
||||||
- Se consolidó la lógica contable en `pos/static/js/accounting.v9.js`.
|
|
||||||
- Se removieron `console.log`/`console.error` de depuración de `accounting.v9.js`.
|
|
||||||
- Se limpió encabezado comentado de `accounting.v9.js`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Archivos añadidos
|
|
||||||
|
|
||||||
```text
|
|
||||||
pos/migrations/v4.10_fleet_permissions.sql
|
|
||||||
pos/migrations/v4.7_workshop_business.sql
|
|
||||||
pos/migrations/v4.8_workshop_permissions.sql
|
|
||||||
pos/migrations/v4.9_workshop_customers_view.sql
|
|
||||||
pos/static/js/accounting.v9.js
|
|
||||||
pos/static/js/api.js
|
|
||||||
scripts/allocate_existing_customer_payments.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Estadísticas del cambio
|
|
||||||
|
|
||||||
```text
|
|
||||||
54 archivos modificados
|
|
||||||
+2485 / -1300 líneas aproximadas
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Validación
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sintaxis de JavaScript
|
|
||||||
node --check pos/static/js/pos-utils.js
|
|
||||||
node --check pos/static/js/customers.js
|
|
||||||
node --check pos/static/js/invoicing.js
|
|
||||||
node --check pos/static/js/fleet.js
|
|
||||||
node --check pos/static/js/accounting.v9.js
|
|
||||||
node --check pos/static/js/pos.js
|
|
||||||
node --check pos/static/js/catalog.js
|
|
||||||
|
|
||||||
# Tests de backend
|
|
||||||
python3 -m pytest pos/tests/test_service_order_integration.py \
|
|
||||||
pos/tests/test_bulk_import.py \
|
|
||||||
pos/tests/test_facturapi_service.py -q
|
|
||||||
# Resultado: 35 passed
|
|
||||||
```
|
|
||||||
|
|
||||||
Servicio `nexus-pos.service` verificado como **activo**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Instrucciones de despliegue
|
|
||||||
|
|
||||||
1. Aplicar migraciones SQL nuevas según el orden numérico:
|
|
||||||
- `v4.7_workshop_business.sql`
|
|
||||||
- `v4.8_workshop_permissions.sql`
|
|
||||||
- `v4.9_workshop_customers_view.sql`
|
|
||||||
- `v4.10_fleet_permissions.sql`
|
|
||||||
2. Reiniciar el servicio para cargar cambios de backend:
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart nexus-pos.service
|
|
||||||
```
|
|
||||||
3. Refrescar la PWA en los navegadores/clientes para que `sw.js` actualice el cache a `v32`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Notas para el equipo
|
|
||||||
|
|
||||||
- Los permisos de flotilla (`fleet.view/create/edit/delete`) ya están sembrados para `owner`/`admin` y `view` para `accountant`/`workshop`.
|
|
||||||
- El Service Worker se sirve correctamente en `/pos/sw.js` desde `static/pwa/sw.js`.
|
|
||||||
- El helper global `api()` fue creado para páginas standalone como `historical_sales.html`.
|
|
||||||
- No se realizaron mutaciones de git no solicitadas; el push a Gitea se hace como parte de esta entrega.
|
|
||||||
@@ -74,7 +74,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = / {
|
location = / {
|
||||||
return 302 /pos/login2;
|
return 302 /pos/login;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
46
pos/app.py
46
pos/app.py
@@ -1,4 +1,4 @@
|
|||||||
from flask import Flask, request, redirect, make_response
|
from flask import Flask
|
||||||
from json_provider import OrjsonProvider
|
from json_provider import OrjsonProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -10,20 +10,12 @@ def create_app():
|
|||||||
from middleware_tenant import resolve_tenant
|
from middleware_tenant import resolve_tenant
|
||||||
app.before_request(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 ──────
|
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
||||||
@app.route('/pos/sw.js')
|
@app.route('/pos/sw.js')
|
||||||
def pos_sw():
|
def pos_sw():
|
||||||
from flask import send_from_directory, make_response
|
from flask import send_from_directory
|
||||||
response = make_response(send_from_directory('static/pwa', 'sw.js',
|
return send_from_directory('static/pwa', 'sw.js',
|
||||||
mimetype='application/javascript'))
|
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
|
# Register blueprints
|
||||||
from blueprints.auth_bp import auth_bp
|
from blueprints.auth_bp import auth_bp
|
||||||
@@ -140,23 +132,11 @@ def create_app():
|
|||||||
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
|
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
|
||||||
|
|
||||||
@app.route('/pos/login')
|
@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():
|
def pos_login():
|
||||||
response = make_response(render_template('login.html',
|
return render_template('login.html',
|
||||||
tenant_id=getattr(g, 'tenant_id', None),
|
tenant_id=getattr(g, 'tenant_id', None),
|
||||||
tenant_name=getattr(g, 'tenant_name', 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')
|
@app.route('/pos/supplier-catalog')
|
||||||
def supplier_catalog_page():
|
def supplier_catalog_page():
|
||||||
@@ -188,11 +168,7 @@ def create_app():
|
|||||||
|
|
||||||
@app.route('/pos/dashboard')
|
@app.route('/pos/dashboard')
|
||||||
def pos_dashboard():
|
def pos_dashboard():
|
||||||
response = make_response(render_template('dashboard.html'))
|
return 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')
|
@app.route('/pos/config')
|
||||||
def pos_config():
|
def pos_config():
|
||||||
@@ -234,14 +210,6 @@ def create_app():
|
|||||||
def pos_historical_sales():
|
def pos_historical_sales():
|
||||||
return render_template('historical_sales.html')
|
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>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ NUMERIC(14,2) in the database.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import csv
|
from datetime import date, datetime
|
||||||
import io
|
from flask import Blueprint, request, jsonify, g
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from flask import Blueprint, request, jsonify, g, Response
|
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.accounting_engine import create_manual_entry
|
from services.accounting_engine import create_manual_entry
|
||||||
@@ -567,140 +565,21 @@ def balance_sheet():
|
|||||||
@accounting_bp.route('/aging', methods=['GET'])
|
@accounting_bp.route('/aging', methods=['GET'])
|
||||||
@require_auth('accounting.view')
|
@require_auth('accounting.view')
|
||||||
def aging_report():
|
def aging_report():
|
||||||
"""Antiguedad de saldos.
|
"""Antiguedad de saldos (accounts receivable aging).
|
||||||
|
|
||||||
Returns individual credit sales with outstanding balance when type=receivable
|
Groups outstanding credit sales by age:
|
||||||
(default), or purchase orders payable to suppliers when type=payable.
|
- Corriente (not yet due)
|
||||||
|
- 1-30 dias
|
||||||
|
- 31-60 dias
|
||||||
|
- 61-90 dias
|
||||||
|
- 90+ dias
|
||||||
"""
|
"""
|
||||||
report_type = request.args.get('type', 'receivable')
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
if report_type == 'payable':
|
|
||||||
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
|
|
||||||
cur.execute("""
|
|
||||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
|
||||||
po.status, s.id, s.name
|
|
||||||
FROM purchase_orders po
|
|
||||||
JOIN suppliers s ON s.id = po.supplier_id
|
|
||||||
WHERE po.status NOT IN ('paid', 'cancelled')
|
|
||||||
ORDER BY po.created_at DESC
|
|
||||||
""")
|
|
||||||
for r in cur.fetchall():
|
|
||||||
po_id = r[0]
|
|
||||||
invoice = r[1] or f'OC-{po_id}'
|
|
||||||
total = float(r[2]) if r[2] else 0
|
|
||||||
created_at = r[3]
|
|
||||||
expected_date = r[4]
|
|
||||||
status = r[5]
|
|
||||||
vendor_name = r[7]
|
|
||||||
|
|
||||||
paid = 0 # TODO: sum supplier payments when that table is added
|
|
||||||
balance = round(total - paid, 2)
|
|
||||||
if balance <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
|
|
||||||
if days_overdue > 0:
|
|
||||||
po_status = 'overdue'
|
|
||||||
label = 'Vencida'
|
|
||||||
elif paid > 0:
|
|
||||||
po_status = 'partial'
|
|
||||||
label = 'Parcial'
|
|
||||||
else:
|
|
||||||
po_status = 'pending'
|
|
||||||
label = 'Pendiente'
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
'po_id': po_id,
|
|
||||||
'invoice': invoice,
|
|
||||||
'vendor_name': vendor_name,
|
|
||||||
'issue_date': created_at.isoformat() if created_at else None,
|
|
||||||
'due_date': expected_date.isoformat() if expected_date else None,
|
|
||||||
'total': total,
|
|
||||||
'paid': paid,
|
|
||||||
'balance': balance,
|
|
||||||
'days_overdue': days_overdue,
|
|
||||||
'status': po_status,
|
|
||||||
'status_label': label,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# Accounts receivable: credit sales to customers
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.id, s.total, s.created_at, s.status,
|
|
||||||
c.id, c.name, c.rfc,
|
|
||||||
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
|
||||||
FROM sales s
|
|
||||||
JOIN customers c ON c.id = s.customer_id
|
|
||||||
WHERE s.sale_type = 'credit'
|
|
||||||
AND s.status = 'completed'
|
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
""")
|
|
||||||
for r in cur.fetchall():
|
|
||||||
sale_id = r[0]
|
|
||||||
total = float(r[1]) if r[1] else 0
|
|
||||||
created_at = r[2]
|
|
||||||
status = r[3]
|
|
||||||
customer_name = r[5]
|
|
||||||
payments_total = float(r[7]) if r[7] else 0
|
|
||||||
paid = payments_total
|
|
||||||
balance = round(total - paid, 2)
|
|
||||||
|
|
||||||
if balance <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
due_date = created_at + timedelta(days=30) if created_at else None
|
|
||||||
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
|
|
||||||
|
|
||||||
if days_overdue > 0:
|
|
||||||
sale_status = 'overdue'
|
|
||||||
label = 'Vencida'
|
|
||||||
elif paid > 0:
|
|
||||||
sale_status = 'partial'
|
|
||||||
label = 'Parcial'
|
|
||||||
else:
|
|
||||||
sale_status = 'pending'
|
|
||||||
label = 'Vigente'
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
'sale_id': sale_id,
|
|
||||||
'invoice': f'VTA-{sale_id}',
|
|
||||||
'customer_name': customer_name,
|
|
||||||
'issue_date': created_at.isoformat() if created_at else None,
|
|
||||||
'due_date': due_date.isoformat() if due_date else None,
|
|
||||||
'total': total,
|
|
||||||
'paid': paid,
|
|
||||||
'balance': balance,
|
|
||||||
'days_overdue': days_overdue,
|
|
||||||
'status': sale_status,
|
|
||||||
'status_label': label,
|
|
||||||
})
|
|
||||||
|
|
||||||
totals = {
|
|
||||||
'count': len(rows),
|
|
||||||
'total': round(sum(r['total'] for r in rows), 2),
|
|
||||||
'paid': round(sum(r['paid'] for r in rows), 2),
|
|
||||||
'balance': round(sum(r['balance'] for r in rows), 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'data': rows, 'totals': totals})
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/aging-summary', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def aging_summary():
|
|
||||||
"""Customer-level accounts receivable summary (used by reports)."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
||||||
s.id as sale_id,
|
s.id as sale_id, s.total, s.created_at,
|
||||||
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
|
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN sales s ON s.customer_id = c.id
|
JOIN sales s ON s.customer_id = c.id
|
||||||
@@ -713,11 +592,6 @@ def aging_summary():
|
|||||||
customers = {}
|
customers = {}
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
cust_id = r[0]
|
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:
|
if cust_id not in customers:
|
||||||
customers[cust_id] = {
|
customers[cust_id] = {
|
||||||
'id': r[0], 'name': r[1], 'rfc': r[2],
|
'id': r[0], 'name': r[1], 'rfc': r[2],
|
||||||
@@ -727,25 +601,29 @@ def aging_summary():
|
|||||||
'total': 0,
|
'total': 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if days <= 0:
|
amount = float(r[6]) if r[6] else 0
|
||||||
customers[cust_id]['corriente'] += balance
|
days = r[8] or 0
|
||||||
elif days <= 30:
|
|
||||||
customers[cust_id]['d1_30'] += balance
|
|
||||||
elif days <= 60:
|
|
||||||
customers[cust_id]['d31_60'] += balance
|
|
||||||
elif days <= 90:
|
|
||||||
customers[cust_id]['d61_90'] += balance
|
|
||||||
else:
|
|
||||||
customers[cust_id]['d90_plus'] += balance
|
|
||||||
|
|
||||||
customers[cust_id]['total'] += balance
|
if days <= 0:
|
||||||
|
customers[cust_id]['corriente'] += amount
|
||||||
|
elif days <= 30:
|
||||||
|
customers[cust_id]['d1_30'] += amount
|
||||||
|
elif days <= 60:
|
||||||
|
customers[cust_id]['d31_60'] += amount
|
||||||
|
elif days <= 90:
|
||||||
|
customers[cust_id]['d61_90'] += amount
|
||||||
|
else:
|
||||||
|
customers[cust_id]['d90_plus'] += amount
|
||||||
|
|
||||||
|
customers[cust_id]['total'] += amount
|
||||||
|
|
||||||
result = list(customers.values())
|
result = list(customers.values())
|
||||||
|
# Round all amounts
|
||||||
for c in result:
|
for c in result:
|
||||||
c['credit_balance'] = round(c['total'], 2)
|
|
||||||
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
||||||
c[key] = round(c[key], 2)
|
c[key] = round(c[key], 2)
|
||||||
|
|
||||||
|
# Totals row
|
||||||
totals = {
|
totals = {
|
||||||
'corriente': round(sum(c['corriente'] for c in result), 2),
|
'corriente': round(sum(c['corriente'] for c in result), 2),
|
||||||
'd1_30': round(sum(c['d1_30'] for c in result), 2),
|
'd1_30': round(sum(c['d1_30'] for c in result), 2),
|
||||||
@@ -753,7 +631,6 @@ def aging_summary():
|
|||||||
'd61_90': round(sum(c['d61_90'] for c in result), 2),
|
'd61_90': round(sum(c['d61_90'] for c in result), 2),
|
||||||
'd90_plus': round(sum(c['d90_plus'] 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),
|
'total': round(sum(c['total'] for c in result), 2),
|
||||||
'count': len(result),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -761,161 +638,6 @@ def aging_summary():
|
|||||||
return jsonify({'data': result, 'totals': totals})
|
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 ────────────────────────────────
|
# ─── Fiscal Periods ────────────────────────────────
|
||||||
|
|
||||||
@accounting_bp.route('/periods', methods=['GET'])
|
@accounting_bp.route('/periods', methods=['GET'])
|
||||||
@@ -1022,7 +744,7 @@ def close_period():
|
|||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/stats', methods=['GET'])
|
@accounting_bp.route('/stats', methods=['GET'])
|
||||||
@require_auth('accounting.view')
|
@require_auth('accounting.read')
|
||||||
def api_accounting_stats():
|
def api_accounting_stats():
|
||||||
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import jwt
|
|||||||
import bcrypt
|
import bcrypt
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from flask import Blueprint, request, jsonify, g, make_response
|
from flask import Blueprint, request, jsonify, g
|
||||||
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
from 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 tenant_db import get_tenant_conn, get_master_conn
|
||||||
from middleware import require_auth
|
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
||||||
|
|
||||||
@@ -60,7 +59,6 @@ def login_pin():
|
|||||||
# Subdomain-resolved tenant takes priority over body param
|
# Subdomain-resolved tenant takes priority over body param
|
||||||
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
|
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
|
||||||
pin = data.get('pin', '')
|
pin = data.get('pin', '')
|
||||||
employee_id = data.get('employee_id')
|
|
||||||
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
|
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
|
||||||
# Optional: branch_id from the device for PIN search optimization
|
# Optional: branch_id from the device for PIN search optimization
|
||||||
device_branch_id = data.get('branch_id')
|
device_branch_id = data.get('branch_id')
|
||||||
@@ -91,26 +89,39 @@ def login_pin():
|
|||||||
|
|
||||||
matched_employee = None
|
matched_employee = None
|
||||||
|
|
||||||
if not employee_id:
|
if device_branch_id:
|
||||||
_record_attempt(device_id, False)
|
# Try branch employees first (fast path for known devices)
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'error': 'Empleado no seleccionado'}), 400
|
|
||||||
|
|
||||||
# Verify the PIN only for the selected employee.
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
||||||
FROM employees e
|
FROM employees e
|
||||||
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL
|
WHERE e.is_active = true AND e.pin IS NOT NULL AND e.branch_id = %s
|
||||||
""", (employee_id,))
|
""", (device_branch_id,))
|
||||||
emp = cur.fetchone()
|
for emp in cur.fetchall():
|
||||||
if emp:
|
|
||||||
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = 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()):
|
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
|
||||||
matched_employee = {
|
matched_employee = {
|
||||||
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
||||||
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched_employee:
|
||||||
|
# Fallback: check ALL active employees (covers owners, admins, roaming staff)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
||||||
|
FROM employees e
|
||||||
|
WHERE e.is_active = true AND e.pin IS NOT NULL
|
||||||
|
""")
|
||||||
|
employees = cur.fetchall()
|
||||||
|
|
||||||
|
for emp in employees:
|
||||||
|
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
|
||||||
|
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
|
||||||
|
matched_employee = {
|
||||||
|
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
||||||
|
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
if not matched_employee:
|
if not matched_employee:
|
||||||
_record_attempt(device_id, False)
|
_record_attempt(device_id, False)
|
||||||
@@ -146,77 +157,9 @@ def login_pin():
|
|||||||
}
|
}
|
||||||
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||||
|
|
||||||
response = make_response(jsonify({
|
|
||||||
'token': token,
|
|
||||||
'employee': matched_employee,
|
|
||||||
'permissions': permissions
|
|
||||||
}))
|
|
||||||
# Cookie used by server-side route guards; JS can also read it for quick checks.
|
|
||||||
response.set_cookie(
|
|
||||||
'pos_role', matched_employee['role'],
|
|
||||||
path='/pos', samesite='Lax', httponly=False
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/refresh', methods=['POST'])
|
|
||||||
@require_auth()
|
|
||||||
def refresh_token():
|
|
||||||
"""Reissue the JWT with the employee's current permissions from the DB.
|
|
||||||
|
|
||||||
This lets permission changes take effect without forcing a full re-login.
|
|
||||||
The original expiration time is preserved.
|
|
||||||
"""
|
|
||||||
auth_header = request.headers.get('Authorization', '')
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return jsonify({'error': 'Invalid token'}), 401
|
|
||||||
|
|
||||||
tenant_id = payload.get('tenant_id')
|
|
||||||
employee_id = payload.get('employee_id')
|
|
||||||
conn = get_tenant_conn(tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
|
|
||||||
FROM employees e
|
|
||||||
WHERE e.id = %s AND e.is_active = true
|
|
||||||
""",
|
|
||||||
(employee_id,)
|
|
||||||
)
|
|
||||||
emp = cur.fetchone()
|
|
||||||
if not emp:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Employee not found or inactive'}), 404
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
|
||||||
(emp[0],)
|
|
||||||
)
|
|
||||||
permissions = [r[0] for r in cur.fetchall()]
|
|
||||||
cur.close(); conn.close()
|
|
||||||
|
|
||||||
new_payload = {
|
|
||||||
'tenant_id': tenant_id,
|
|
||||||
'employee_id': emp[0],
|
|
||||||
'name': emp[1],
|
|
||||||
'role': emp[2],
|
|
||||||
'branch_id': emp[3],
|
|
||||||
'max_discount_pct': float(emp[4]) if emp[4] else 0,
|
|
||||||
'permissions': permissions,
|
|
||||||
'device_id': payload.get('device_id', 'unknown'),
|
|
||||||
'type': 'pos_access',
|
|
||||||
'exp': payload.get('exp'),
|
|
||||||
'iat': datetime.now(timezone.utc),
|
|
||||||
}
|
|
||||||
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'token': token,
|
'token': token,
|
||||||
'employee': {
|
'employee': matched_employee,
|
||||||
'id': emp[0], 'name': emp[1], 'role': emp[2],
|
|
||||||
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
|
|
||||||
},
|
|
||||||
'permissions': permissions
|
'permissions': permissions
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,7 +197,7 @@ def list_login_employees(tenant_id=None):
|
|||||||
name = row[1]
|
name = row[1]
|
||||||
parts = name.split()
|
parts = name.split()
|
||||||
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
||||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
|
||||||
employees.append({
|
employees.append({
|
||||||
'id': row[0],
|
'id': row[0],
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|||||||
@@ -3,26 +3,16 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
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'])
|
@cashregister_bp.route('/open', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def open_register():
|
def open_register():
|
||||||
if not _can_operate_register():
|
|
||||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
|
||||||
"""Open a cash register session.
|
"""Open a cash register session.
|
||||||
|
|
||||||
Body: {register_number: int, opening_amount: float}
|
Body: {register_number: int, opening_amount: float}
|
||||||
@@ -94,7 +84,7 @@ def open_register():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/current', methods=['GET'])
|
@cashregister_bp.route('/current', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def current_register():
|
def current_register():
|
||||||
"""Get the current open register for this employee."""
|
"""Get the current open register for this employee."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -125,10 +115,8 @@ def current_register():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/movement', methods=['POST'])
|
@cashregister_bp.route('/movement', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def cash_movement():
|
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.
|
"""Record a cash in/out movement with mandatory reason.
|
||||||
|
|
||||||
Body: {type: 'in'|'out', amount: float, reason: str}
|
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||||
@@ -285,10 +273,8 @@ def _compute_register_summary(conn, register_id):
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def cut_x():
|
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."""
|
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -315,10 +301,8 @@ def cut_x():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def cut_z():
|
def cut_z():
|
||||||
if not _can_operate_register():
|
|
||||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
|
||||||
"""Final cut (corte Z): close the register.
|
"""Final cut (corte Z): close the register.
|
||||||
|
|
||||||
Body: {closing_amount: float} (the amount physically counted in the register)
|
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||||
@@ -395,18 +379,12 @@ def cut_z():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/history', methods=['GET'])
|
@cashregister_bp.route('/history', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.view')
|
||||||
def register_history():
|
def register_history():
|
||||||
"""List closed registers with summary.
|
"""List closed registers with summary.
|
||||||
|
|
||||||
Query params: date_from, date_to, employee_id, page, per_page
|
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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -424,9 +402,6 @@ def register_history():
|
|||||||
date_to = request.args.get('date_to')
|
date_to = request.args.get('date_to')
|
||||||
employee_id = request.args.get('employee_id')
|
employee_id = request.args.get('employee_id')
|
||||||
|
|
||||||
if not can_view_all:
|
|
||||||
employee_id = g.employee_id
|
|
||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
where_clauses.append("cr.closed_at >= %s")
|
where_clauses.append("cr.closed_at >= %s")
|
||||||
params.append(date_from)
|
params.append(date_from)
|
||||||
@@ -601,89 +576,3 @@ def daily_summary():
|
|||||||
'movements_out': movements['out'],
|
'movements_out': movements['out'],
|
||||||
'registers': registers,
|
'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,7 +1,6 @@
|
|||||||
# /home/Autopartes/pos/blueprints/config_bp.py
|
# /home/Autopartes/pos/blueprints/config_bp.py
|
||||||
"""Config blueprint: tenant configuration, branches, theming."""
|
"""Config blueprint: tenant configuration, branches, theming."""
|
||||||
|
|
||||||
import json
|
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
@@ -9,188 +8,6 @@ from tenant_db import get_tenant_conn
|
|||||||
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
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'])
|
@config_bp.route('/branches', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def list_branches():
|
def list_branches():
|
||||||
@@ -333,58 +150,8 @@ def update_branch(branch_id):
|
|||||||
return jsonify({'ok': True, 'message': 'Branch updated'})
|
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'])
|
@config_bp.route('/employees', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('config.view')
|
||||||
def list_employees():
|
def list_employees():
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -433,7 +200,7 @@ def create_employee():
|
|||||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||||
|
|
||||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
|
||||||
if data['role'] not in valid_roles:
|
if data['role'] not in valid_roles:
|
||||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||||
|
|
||||||
@@ -448,8 +215,26 @@ def create_employee():
|
|||||||
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
||||||
emp_id = cur.fetchone()[0]
|
emp_id = cur.fetchone()[0]
|
||||||
|
|
||||||
# Set default permissions by role (configurable per tenant)
|
# Set default permissions by role
|
||||||
for perm in _get_role_permissions(conn, data['role']):
|
role_permissions = {
|
||||||
|
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||||
|
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||||
|
'catalog.view', 'catalog.edit',
|
||||||
|
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||||
|
'invoicing.view', 'invoicing.create',
|
||||||
|
'reports.view', 'reports.financial',
|
||||||
|
'config.view', 'config.edit', 'config.edit_prices'],
|
||||||
|
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
|
||||||
|
'catalog.view', 'customers.view', 'customers.create'],
|
||||||
|
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||||
|
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||||
|
'accountant': ['accounting.view', 'accounting.create',
|
||||||
|
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||||
|
'reports.view', 'reports.financial',
|
||||||
|
'customers.view'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for perm in role_permissions.get(data['role'], []):
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
(emp_id, perm)
|
(emp_id, perm)
|
||||||
@@ -465,105 +250,6 @@ def create_employee():
|
|||||||
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
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'])
|
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
||||||
@require_auth('config.edit')
|
@require_auth('config.edit')
|
||||||
def update_employee(emp_id):
|
def update_employee(emp_id):
|
||||||
@@ -609,15 +295,6 @@ def update_employee(emp_id):
|
|||||||
params.append(emp_id)
|
params.append(emp_id)
|
||||||
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
|
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
|
from services.audit import log_action
|
||||||
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
||||||
new_value={k: v for k, v in data.items() if k != 'pin'})
|
new_value={k: v for k, v in data.items() if k != 'pin'})
|
||||||
@@ -628,53 +305,6 @@ def update_employee(emp_id):
|
|||||||
return jsonify({'ok': True, 'message': 'Employee updated'})
|
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'])
|
@config_bp.route('/currency', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_currency():
|
def get_currency():
|
||||||
@@ -759,14 +389,7 @@ def get_business():
|
|||||||
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
|
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
|
||||||
'rfc': cfg.get('tenant_rfc', ''),
|
'rfc': cfg.get('tenant_rfc', ''),
|
||||||
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
|
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
|
||||||
'cp': cfg.get('tenant_cp', ''),
|
|
||||||
'direccion': cfg.get('tenant_direccion', ''),
|
'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', ''),
|
'telefono': cfg.get('tenant_telefono', ''),
|
||||||
'email': cfg.get('tenant_email', ''),
|
'email': cfg.get('tenant_email', ''),
|
||||||
})
|
})
|
||||||
@@ -782,22 +405,14 @@ def update_business():
|
|||||||
'nombre': 'tenant_nombre',
|
'nombre': 'tenant_nombre',
|
||||||
'rfc': 'tenant_rfc',
|
'rfc': 'tenant_rfc',
|
||||||
'regimen_fiscal': 'tenant_regimen_fiscal',
|
'regimen_fiscal': 'tenant_regimen_fiscal',
|
||||||
'cp': 'tenant_cp',
|
|
||||||
'direccion': 'tenant_direccion',
|
'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',
|
'telefono': 'tenant_telefono',
|
||||||
'email': 'tenant_email',
|
'email': 'tenant_email',
|
||||||
# Tax params (also keep cfdi_* aliases in sync)
|
# Tax params
|
||||||
'tax_iva': 'tax_iva',
|
'tax_iva': 'tax_iva',
|
||||||
'tax_ieps': 'tax_ieps',
|
'tax_ieps': 'tax_ieps',
|
||||||
'invoice_serie': 'invoice_serie',
|
'invoice_serie': 'invoice_serie',
|
||||||
'invoice_folio': 'invoice_folio',
|
'invoice_folio': 'invoice_folio',
|
||||||
'cfdi_serie': 'cfdi_serie',
|
|
||||||
'default_currency': 'default_currency',
|
'default_currency': 'default_currency',
|
||||||
'default_payment_method': 'default_payment_method',
|
'default_payment_method': 'default_payment_method',
|
||||||
}
|
}
|
||||||
@@ -1048,7 +663,7 @@ def update_whatsapp_config():
|
|||||||
|
|
||||||
|
|
||||||
@config_bp.route('/modules', methods=['GET'])
|
@config_bp.route('/modules', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('config.view')
|
||||||
def get_modules():
|
def get_modules():
|
||||||
"""Get enabled modules for this tenant."""
|
"""Get enabled modules for this tenant."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -1066,7 +681,6 @@ def get_modules():
|
|||||||
'marketplace': _bool('module_marketplace'),
|
'marketplace': _bool('module_marketplace'),
|
||||||
'meli': _bool('module_meli'),
|
'meli': _bool('module_meli'),
|
||||||
'catalog': _bool('module_catalog'),
|
'catalog': _bool('module_catalog'),
|
||||||
'workshop': _bool('module_workshop'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1083,7 +697,6 @@ def update_modules():
|
|||||||
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||||
'module_meli': 'true' if data.get('meli') else 'false',
|
'module_meli': 'true' if data.get('meli') else 'false',
|
||||||
'module_catalog': 'true' if data.get('catalog') 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():
|
for key, value in settings.items():
|
||||||
@@ -1100,78 +713,9 @@ def update_modules():
|
|||||||
'whatsapp': data.get('whatsapp'),
|
'whatsapp': data.get('whatsapp'),
|
||||||
'marketplace': data.get('marketplace'),
|
'marketplace': data.get('marketplace'),
|
||||||
'meli': data.get('meli'),
|
'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'])
|
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_onboarding_status():
|
def get_onboarding_status():
|
||||||
@@ -1201,102 +745,3 @@ def set_onboarding_status():
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'completed': completed == 'true'})
|
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,20 +10,10 @@ from services.audit import log_action
|
|||||||
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
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 ─────────────────────────────
|
# ─── Customer CRUD ─────────────────────────────
|
||||||
|
|
||||||
@customers_bp.route('', methods=['GET'])
|
@customers_bp.route('', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def list_customers():
|
def list_customers():
|
||||||
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||||
|
|
||||||
@@ -33,8 +23,6 @@ def list_customers():
|
|||||||
per_page: items per page (default 50, max 200)
|
per_page: items per page (default 50, max 200)
|
||||||
branch_id: filter by branch (default: current user's branch)
|
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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -42,24 +30,10 @@ def list_customers():
|
|||||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||||
search = request.args.get('q', '').strip()
|
search = request.args.get('q', '').strip()
|
||||||
branch_id = request.args.get('branch_id')
|
branch_id = request.args.get('branch_id')
|
||||||
price_tier = request.args.get('price_tier', '').strip()
|
|
||||||
status = request.args.get('status', '').strip().lower()
|
|
||||||
|
|
||||||
where_clauses = []
|
where_clauses = ["c.is_active = true"]
|
||||||
params = []
|
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:
|
if branch_id:
|
||||||
where_clauses.append("c.branch_id = %s")
|
where_clauses.append("c.branch_id = %s")
|
||||||
params.append(int(branch_id))
|
params.append(int(branch_id))
|
||||||
@@ -68,20 +42,8 @@ def list_customers():
|
|||||||
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
|
"(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)
|
params.extend([f'%{search}%'] * 4)
|
||||||
if price_tier:
|
|
||||||
# Support numeric tier or Spanish labels
|
|
||||||
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
|
|
||||||
tier_val = tier_map.get(price_tier.lower())
|
|
||||||
if tier_val is None:
|
|
||||||
try:
|
|
||||||
tier_val = int(price_tier)
|
|
||||||
except ValueError:
|
|
||||||
tier_val = None
|
|
||||||
if tier_val in (1, 2, 3):
|
|
||||||
where_clauses.append("c.price_tier = %s")
|
|
||||||
params.append(tier_val)
|
|
||||||
|
|
||||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
# Count
|
# Count
|
||||||
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
||||||
@@ -92,8 +54,7 @@ def list_customers():
|
|||||||
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
||||||
c.address, c.cp,
|
c.address, c.cp,
|
||||||
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
||||||
c.branch_id, c.is_active, c.created_at,
|
c.branch_id
|
||||||
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
|
|
||||||
FROM customers c
|
FROM customers c
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY c.name
|
ORDER BY c.name
|
||||||
@@ -110,9 +71,6 @@ def list_customers():
|
|||||||
'credit_balance': float(r[10]) if r[10] else 0,
|
'credit_balance': float(r[10]) if r[10] else 0,
|
||||||
'vehicle_info': r[11],
|
'vehicle_info': r[11],
|
||||||
'branch_id': r[12],
|
'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()
|
cur.close()
|
||||||
@@ -126,10 +84,8 @@ def list_customers():
|
|||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def get_customer(customer_id):
|
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."""
|
"""Get customer details with credit info, vehicle history, and recent purchases."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -179,71 +135,19 @@ def get_customer(customer_id):
|
|||||||
float(customer['credit_limit']) - float(customer['credit_balance']), 2
|
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()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify(customer)
|
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'])
|
@customers_bp.route('', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('customers.create')
|
||||||
def create_customer():
|
def create_customer():
|
||||||
"""Create a new customer.
|
"""Create a new customer.
|
||||||
|
|
||||||
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||||
phone, address, price_tier, credit_limit, vehicle_info}
|
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 {}
|
data = request.get_json() or {}
|
||||||
if not data.get('name'):
|
if not data.get('name'):
|
||||||
return jsonify({'error': 'name is required'}), 400
|
return jsonify({'error': 'name is required'}), 400
|
||||||
@@ -336,57 +240,9 @@ def update_customer(customer_id):
|
|||||||
return jsonify({'message': 'Customer updated'})
|
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'])
|
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def customer_statement(customer_id):
|
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.
|
"""Account statement: sales (invoices), payments, running balance.
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
@@ -485,10 +341,8 @@ def customer_statement(customer_id):
|
|||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def customer_vehicles(customer_id):
|
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.
|
"""Get customer's vehicle list with last purchases per vehicle.
|
||||||
|
|
||||||
Vehicle info is stored as JSONB in customers.vehicle_info:
|
Vehicle info is stored as JSONB in customers.vehicle_info:
|
||||||
@@ -585,32 +439,6 @@ def record_customer_payment(customer_id):
|
|||||||
UPDATE customers SET credit_balance = %s WHERE id = %s
|
UPDATE customers SET credit_balance = %s WHERE id = %s
|
||||||
""", (new_balance, customer_id))
|
""", (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
|
# Record cash movement on register if cash payment
|
||||||
if register_id and payment_method == 'efectivo':
|
if register_id and payment_method == 'efectivo':
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
|
|||||||
@@ -118,70 +118,3 @@ def get_employee_stats():
|
|||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.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 ─────────────────────────────
|
# ─── Vehicles CRUD ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles', methods=['GET'])
|
@fleet_bp.route('/vehicles', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def list_vehicles():
|
def list_vehicles():
|
||||||
"""List fleet vehicles with pagination and search.
|
"""List fleet vehicles with pagination and search.
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def list_vehicles():
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def get_vehicle(vehicle_id):
|
def get_vehicle(vehicle_id):
|
||||||
"""Vehicle detail with maintenance schedules and recent logs."""
|
"""Vehicle detail with maintenance schedules and recent logs."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -155,128 +155,16 @@ def get_vehicle(vehicle_id):
|
|||||||
return jsonify(vehicle)
|
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'])
|
@fleet_bp.route('/vehicles', methods=['POST'])
|
||||||
@require_auth('fleet.create')
|
@require_auth()
|
||||||
def create_vehicle():
|
def create_vehicle():
|
||||||
"""Create a fleet vehicle.
|
"""Create a fleet vehicle.
|
||||||
|
|
||||||
Body: {customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
Body: {plate, vin, 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 {}
|
data = request.get_json() or {}
|
||||||
if not data.get('make') or not data.get('model'):
|
if not data.get('plate') and not data.get('vin'):
|
||||||
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
|
return jsonify({'error': 'plate or vin is required'}), 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)
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
|
|
||||||
@@ -284,23 +172,17 @@ def create_vehicle():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
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("""
|
cur.execute("""
|
||||||
INSERT INTO fleet_vehicles
|
INSERT INTO fleet_vehicles
|
||||||
(branch_id, customer_id, plate, make, model, year,
|
(branch_id, plate, vin, make, model, year,
|
||||||
current_mileage, fuel_type, color, owner_name, notes)
|
current_mileage, fuel_type, color, owner_name, notes)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
branch_id, data['customer_id'], data.get('plate'),
|
branch_id, data.get('plate'), data.get('vin'),
|
||||||
data.get('make'), data.get('model'), data.get('year'),
|
data.get('make'), data.get('model'), data.get('year'),
|
||||||
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
||||||
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'),
|
data.get('color'), data.get('owner_name'), data.get('notes'),
|
||||||
))
|
))
|
||||||
vehicle_id = cur.fetchone()[0]
|
vehicle_id = cur.fetchone()[0]
|
||||||
|
|
||||||
@@ -319,7 +201,7 @@ def create_vehicle():
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
|
||||||
@require_auth('fleet.edit')
|
@require_auth()
|
||||||
def update_vehicle(vehicle_id):
|
def update_vehicle(vehicle_id):
|
||||||
"""Update vehicle fields including mileage.
|
"""Update vehicle fields including mileage.
|
||||||
|
|
||||||
@@ -363,7 +245,7 @@ def update_vehicle(vehicle_id):
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
|
||||||
@require_auth('fleet.delete')
|
@require_auth()
|
||||||
def deactivate_vehicle(vehicle_id):
|
def deactivate_vehicle(vehicle_id):
|
||||||
"""Soft-delete: set is_active = false."""
|
"""Soft-delete: set is_active = false."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -384,7 +266,7 @@ def deactivate_vehicle(vehicle_id):
|
|||||||
# ─── Maintenance Schedules ─────────────────────────────
|
# ─── Maintenance Schedules ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def list_schedules(vehicle_id):
|
def list_schedules(vehicle_id):
|
||||||
"""Maintenance schedules for a vehicle."""
|
"""Maintenance schedules for a vehicle."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -417,7 +299,7 @@ def list_schedules(vehicle_id):
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
|
||||||
@require_auth('fleet.create')
|
@require_auth()
|
||||||
def create_schedule(vehicle_id):
|
def create_schedule(vehicle_id):
|
||||||
"""Create maintenance schedule for a vehicle.
|
"""Create maintenance schedule for a vehicle.
|
||||||
|
|
||||||
@@ -462,7 +344,7 @@ def create_schedule(vehicle_id):
|
|||||||
# ─── Maintenance Logs ─────────────────────────────
|
# ─── Maintenance Logs ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
|
||||||
@require_auth('fleet.create')
|
@require_auth()
|
||||||
def record_maintenance(vehicle_id):
|
def record_maintenance(vehicle_id):
|
||||||
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
|
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
|
||||||
|
|
||||||
@@ -545,7 +427,7 @@ def record_maintenance(vehicle_id):
|
|||||||
# ─── Alerts ─────────────────────────────
|
# ─── Alerts ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/alerts', methods=['GET'])
|
@fleet_bp.route('/alerts', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def fleet_alerts():
|
def fleet_alerts():
|
||||||
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
|
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -590,7 +472,7 @@ def fleet_alerts():
|
|||||||
# ─── Stats ─────────────────────────────
|
# ─── Stats ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/stats', methods=['GET'])
|
@fleet_bp.route('/stats', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def fleet_stats():
|
def fleet_stats():
|
||||||
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
|
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|||||||
@@ -103,13 +103,8 @@ def list_items():
|
|||||||
# branch_id no longer filters inventory rows (shared catalog).
|
# branch_id no longer filters inventory rows (shared catalog).
|
||||||
# It is used only to show per-branch stock.
|
# It is used only to show per-branch stock.
|
||||||
if search:
|
if search:
|
||||||
# Search also matches alternate / alias SKUs.
|
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
||||||
where_clauses.append(
|
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||||
"(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:
|
if category:
|
||||||
where_clauses.append("i.category_id = %s")
|
where_clauses.append("i.category_id = %s")
|
||||||
params.append(int(category))
|
params.append(int(category))
|
||||||
@@ -426,7 +421,7 @@ def bulk_import_items():
|
|||||||
'skip' ignores missing compat; 'reject' requires all compat.
|
'skip' ignores missing compat; 'reject' requires all compat.
|
||||||
Expected CSV columns (case-insensitive):
|
Expected CSV columns (case-insensitive):
|
||||||
sku/part_number, name, brand, price, stock, cost,
|
sku/part_number, name, brand, price, stock, cost,
|
||||||
sku_secondary, description, category, make, model, year, engine, engine_code
|
location, description, category, make, model, year, engine, engine_code
|
||||||
Optional compat columns: make, model, year, engine, engine_code
|
Optional compat columns: make, model, year, engine, engine_code
|
||||||
"""
|
"""
|
||||||
from services.qwen_fitment import get_vehicle_fitment
|
from services.qwen_fitment import get_vehicle_fitment
|
||||||
@@ -450,17 +445,8 @@ def bulk_import_items():
|
|||||||
try:
|
try:
|
||||||
ext = os.path.splitext(file.filename)[1].lower()
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
if ext == '.csv':
|
if ext == '.csv':
|
||||||
raw = file.stream.read()
|
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
|
||||||
decoded = None
|
reader = csv.DictReader(stream)
|
||||||
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)
|
rows = list(reader)
|
||||||
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
||||||
try:
|
try:
|
||||||
@@ -504,7 +490,6 @@ def bulk_import_items():
|
|||||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||||
'ubicacion': 'location', 'categoria': 'category',
|
'ubicacion': 'location', 'categoria': 'category',
|
||||||
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
|
|
||||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||||
@@ -535,10 +520,6 @@ def bulk_import_items():
|
|||||||
db_name = db_name_row[0] if db_name_row else None
|
db_name = db_name_row[0] if db_name_row else None
|
||||||
mcur.close(); mconn.close()
|
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):
|
for row_num, row in enumerate(rows, start=1):
|
||||||
part_number = str(row.get('part_number', '')).strip()
|
part_number = str(row.get('part_number', '')).strip()
|
||||||
name = str(row.get('name', '')).strip()
|
name = str(row.get('name', '')).strip()
|
||||||
@@ -567,10 +548,7 @@ def bulk_import_items():
|
|||||||
cost = _to_decimal(row.get('cost'), 0)
|
cost = _to_decimal(row.get('cost'), 0)
|
||||||
location = str(row.get('location', '')).strip()
|
location = str(row.get('location', '')).strip()
|
||||||
description = str(row.get('description', '')).strip()
|
description = str(row.get('description', '')).strip()
|
||||||
category_name = str(row.get('category', '')).strip()
|
category = 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)
|
# Check if item already exists (catalog is shared across branches)
|
||||||
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
|
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
|
||||||
@@ -588,10 +566,10 @@ def bulk_import_items():
|
|||||||
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
||||||
location = COALESCE(NULLIF(%s,''), location),
|
location = COALESCE(NULLIF(%s,''), location),
|
||||||
description = COALESCE(NULLIF(%s,''), description),
|
description = COALESCE(NULLIF(%s,''), description),
|
||||||
category_id = COALESCE(%s, category_id)
|
category = COALESCE(NULLIF(%s,''), category)
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
|
(name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
|
||||||
)
|
)
|
||||||
was_inserted = False
|
was_inserted = False
|
||||||
# Record stock adjustment for existing item if stock > 0
|
# Record stock adjustment for existing item if stock > 0
|
||||||
@@ -604,11 +582,11 @@ def bulk_import_items():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO inventory
|
INSERT INTO inventory
|
||||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, unit)
|
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA')
|
(part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
|
||||||
)
|
)
|
||||||
item_id = cur.fetchone()[0]
|
item_id = cur.fetchone()[0]
|
||||||
was_inserted = True
|
was_inserted = True
|
||||||
@@ -621,20 +599,7 @@ def bulk_import_items():
|
|||||||
created_ids.append(item_id)
|
created_ids.append(item_id)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
# ---------- 2. Secondary SKU alias ----------
|
# ---------- 2. Vehicle compatibility ----------
|
||||||
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()
|
make = str(row.get('make', '')).strip()
|
||||||
model = str(row.get('model', '')).strip()
|
model = str(row.get('model', '')).strip()
|
||||||
year_str = str(row.get('year', '')).strip()
|
year_str = str(row.get('year', '')).strip()
|
||||||
@@ -2306,22 +2271,6 @@ def list_inventory_subcategories(category_id):
|
|||||||
conn.close()
|
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 ───────────────────────
|
# ─── Global Tier Discounts ───────────────────────
|
||||||
|
|
||||||
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from flask import Blueprint, g, jsonify, request
|
|||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from services import facturapi_service
|
from services import facturapi_service
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
from services.facturapi_service import FacturapiError
|
|
||||||
from services.cfdi_facturapi_builder import (
|
from services.cfdi_facturapi_builder import (
|
||||||
build_egreso_payload,
|
build_egreso_payload,
|
||||||
build_ingreso_payload,
|
build_ingreso_payload,
|
||||||
@@ -44,16 +43,9 @@ def _get_issuer_config(cur, branch_id=None):
|
|||||||
result = {
|
result = {
|
||||||
"rfc": config.get("tenant_rfc", ""),
|
"rfc": config.get("tenant_rfc", ""),
|
||||||
"razon_social": config.get("tenant_razon_social", ""),
|
"razon_social": config.get("tenant_razon_social", ""),
|
||||||
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"),
|
"regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
|
||||||
"cp": config.get("tenant_cp", "00000"),
|
"cp": config.get("tenant_cp", "00000"),
|
||||||
"direccion": config.get("tenant_direccion", ""),
|
"serie": config.get("cfdi_serie", "A"),
|
||||||
"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_key": config.get("cfdi_facturapi_key", ""),
|
||||||
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
|
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
|
||||||
}
|
}
|
||||||
@@ -493,7 +485,7 @@ def get_sale_pdf(sale_id):
|
|||||||
|
|
||||||
|
|
||||||
@invoicing_bp.route("/stats", methods=["GET"])
|
@invoicing_bp.route("/stats", methods=["GET"])
|
||||||
@require_auth("invoicing.view")
|
@require_auth("invoicing.read")
|
||||||
def api_invoicing_stats():
|
def api_invoicing_stats():
|
||||||
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -675,7 +667,6 @@ def facturapi_setup():
|
|||||||
(result["org_id"],),
|
(result["org_id"],),
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get("api_key"):
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tenant_config (key, value)
|
INSERT INTO tenant_config (key, value)
|
||||||
@@ -691,30 +682,18 @@ def facturapi_setup():
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
status_conn = get_tenant_conn(g.tenant_id)
|
return jsonify(
|
||||||
try:
|
{
|
||||||
status_cur = status_conn.cursor()
|
"org_id": result["org_id"],
|
||||||
status = facturapi_service.get_org_status(_get_issuer_config(status_cur))
|
"message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
|
||||||
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:
|
except ValueError as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"error": str(e)}), 400
|
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:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ Routes:
|
|||||||
POST /pos/api/marketplace-ext/webhook/meli
|
POST /pos/api/marketplace-ext/webhook/meli
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn, get_master_conn
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
@@ -83,49 +81,6 @@ def get_config():
|
|||||||
conn.close()
|
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"])
|
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def connect_meli():
|
def connect_meli():
|
||||||
@@ -135,21 +90,12 @@ def connect_meli():
|
|||||||
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
code = data.get("code")
|
code = data.get("code")
|
||||||
|
client_id = data.get("client_id")
|
||||||
|
client_secret = data.get("client_secret")
|
||||||
redirect_uri = data.get("redirect_uri", "")
|
redirect_uri = data.get("redirect_uri", "")
|
||||||
|
|
||||||
if not code:
|
if not code or not client_id or not client_secret:
|
||||||
return jsonify({"error": "code required"}), 400
|
return jsonify({"error": "code, client_id and client_secret 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:
|
try:
|
||||||
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ from middleware import require_auth, has_permission
|
|||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.pos_engine import (
|
from services.pos_engine import (
|
||||||
process_sale, cancel_sale, calculate_totals,
|
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.inventory_engine import get_stock
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
@@ -23,53 +22,6 @@ from config import JWT_SECRET
|
|||||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
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):
|
def _enrich_items(cur, items, customer_id=None):
|
||||||
"""Look up inventory data for items that lack unit_price/tax_rate.
|
"""Look up inventory data for items that lack unit_price/tax_rate.
|
||||||
|
|
||||||
@@ -151,11 +103,20 @@ def create_sale():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
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)
|
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:
|
try:
|
||||||
_validate_stock_availability(conn, data.get('items', []), branch_id)
|
|
||||||
_validate_zero_price(conn, data.get('items', []))
|
|
||||||
sale = process_sale(conn, data)
|
sale = process_sale(conn, data)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -170,80 +131,8 @@ def create_sale():
|
|||||||
return jsonify({'error': str(e)}), 500
|
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'])
|
@pos_bp.route('/sales', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.view')
|
||||||
def list_sales():
|
def list_sales():
|
||||||
"""List sales with filters.
|
"""List sales with filters.
|
||||||
|
|
||||||
@@ -257,13 +146,6 @@ def list_sales():
|
|||||||
page: int (default 1)
|
page: int (default 1)
|
||||||
per_page: int (default 50, max 200)
|
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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -278,10 +160,6 @@ def list_sales():
|
|||||||
employee_id = request.args.get('employee_id')
|
employee_id = request.args.get('employee_id')
|
||||||
customer_id = request.args.get('customer_id')
|
customer_id = request.args.get('customer_id')
|
||||||
status = request.args.get('status')
|
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')
|
register_id = request.args.get('register_id')
|
||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
@@ -299,18 +177,6 @@ def list_sales():
|
|||||||
if status:
|
if status:
|
||||||
where_clauses.append("s.status = %s")
|
where_clauses.append("s.status = %s")
|
||||||
params.append(status)
|
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:
|
if register_id:
|
||||||
where_clauses.append("s.register_id = %s")
|
where_clauses.append("s.register_id = %s")
|
||||||
params.append(int(register_id))
|
params.append(int(register_id))
|
||||||
@@ -329,14 +195,12 @@ def list_sales():
|
|||||||
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
|
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.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||||
s.tax_total, s.total, s.amount_paid, s.change_given,
|
s.tax_total, s.total, s.amount_paid, s.change_given,
|
||||||
s.status, s.created_at, s.courier_id,
|
s.status, s.created_at,
|
||||||
e.name as employee_name,
|
e.name as employee_name,
|
||||||
c.name as customer_name,
|
c.name as customer_name
|
||||||
co.name as courier_name
|
|
||||||
FROM sales s
|
FROM sales s
|
||||||
LEFT JOIN employees e ON s.employee_id = e.id
|
LEFT JOIN employees e ON s.employee_id = e.id
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
LEFT JOIN couriers co ON s.courier_id = co.id
|
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
@@ -355,9 +219,7 @@ def list_sales():
|
|||||||
'amount_paid': float(r[11]) if r[11] else 0,
|
'amount_paid': float(r[11]) if r[11] else 0,
|
||||||
'change_given': float(r[12]) if r[12] else 0,
|
'change_given': float(r[12]) if r[12] else 0,
|
||||||
'status': r[13], 'created_at': str(r[14]),
|
'status': r[13], 'created_at': str(r[14]),
|
||||||
'courier_id': r[15],
|
'employee_name': r[15], 'customer_name': r[16],
|
||||||
'employee_name': r[16], 'customer_name': r[17],
|
|
||||||
'courier_name': r[18],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -370,75 +232,6 @@ def list_sales():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/sales/recent', methods=['GET'])
|
|
||||||
@require_auth('pos.view')
|
|
||||||
def recent_sales():
|
|
||||||
"""Return recent sales with their items in a single response.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
date_from: YYYY-MM-DD (defaults to today)
|
|
||||||
date_to: YYYY-MM-DD (defaults to today)
|
|
||||||
limit: int (default 10, max 50)
|
|
||||||
"""
|
|
||||||
from datetime import date
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
date_from = request.args.get('date_from') or str(date.today())
|
|
||||||
date_to = request.args.get('date_to') or date_from
|
|
||||||
limit = min(int(request.args.get('limit', 10)), 50)
|
|
||||||
|
|
||||||
where_clauses = [
|
|
||||||
"s.created_at >= %s",
|
|
||||||
"s.created_at < %s::date + interval '1 day'",
|
|
||||||
"s.status != 'cancelled'"
|
|
||||||
]
|
|
||||||
params = [date_from, date_to]
|
|
||||||
|
|
||||||
if g.branch_id:
|
|
||||||
where_clauses.append("s.branch_id = %s")
|
|
||||||
params.append(g.branch_id)
|
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT s.id, s.customer_id, s.payment_method, s.total, s.status, s.created_at,
|
|
||||||
c.name as customer_name
|
|
||||||
FROM sales s
|
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
LIMIT %s
|
|
||||||
""", params + [limit])
|
|
||||||
|
|
||||||
sales = []
|
|
||||||
sale_ids = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
sale_ids.append(r[0])
|
|
||||||
sales.append({
|
|
||||||
'id': r[0], 'customer_id': r[1], 'payment_method': r[2],
|
|
||||||
'total': float(r[3]) if r[3] else 0, 'status': r[4],
|
|
||||||
'created_at': str(r[5]), 'customer_name': r[6],
|
|
||||||
'items': []
|
|
||||||
})
|
|
||||||
|
|
||||||
if sale_ids:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT sale_id, name, quantity
|
|
||||||
FROM sale_items
|
|
||||||
WHERE sale_id = ANY(%s)
|
|
||||||
ORDER BY sale_id, id
|
|
||||||
""", (sale_ids,))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
for sale in sales:
|
|
||||||
if sale['id'] == r[0]:
|
|
||||||
sale['items'].append({'name': r[1], 'quantity': r[2]})
|
|
||||||
break
|
|
||||||
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'data': sales})
|
|
||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/historical-sales', methods=['GET'])
|
@pos_bp.route('/historical-sales', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def list_historical_sales():
|
def list_historical_sales():
|
||||||
@@ -476,16 +269,6 @@ def list_historical_sales():
|
|||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
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)
|
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
|
||||||
total = cur.fetchone()[0]
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
@@ -527,13 +310,9 @@ def list_historical_sales():
|
|||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.view')
|
||||||
def get_sale(sale_id):
|
def get_sale(sale_id):
|
||||||
"""Get sale detail with items."""
|
"""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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -599,17 +378,12 @@ def get_sale(sale_id):
|
|||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
|
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def api_cancel_sale(sale_id):
|
def api_cancel_sale(sale_id):
|
||||||
"""Cancel a sale. Requires mandatory reason.
|
"""Cancel a sale. Requires mandatory reason.
|
||||||
|
|
||||||
Body: {reason: str}
|
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 {}
|
data = request.get_json() or {}
|
||||||
reason = data.get('reason', '').strip()
|
reason = data.get('reason', '').strip()
|
||||||
|
|
||||||
@@ -746,8 +520,6 @@ def create_quotation():
|
|||||||
# Enrich items with inventory data (price, tax, etc.)
|
# Enrich items with inventory data (price, tax, etc.)
|
||||||
try:
|
try:
|
||||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
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:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
@@ -910,23 +682,9 @@ def list_quotations():
|
|||||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
|
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
|
||||||
@require_auth('pos.sell')
|
@require_auth('pos.sell')
|
||||||
def delete_quotation(quot_id):
|
def delete_quotation(quot_id):
|
||||||
"""Delete a quotation, release its stock reservations and remove its items."""
|
"""Delete a quotation and its items."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
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 quotation_items WHERE quotation_id = %s", (quot_id,))
|
||||||
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
|
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
|
||||||
deleted = cur.rowcount
|
deleted = cur.rowcount
|
||||||
@@ -1136,8 +894,6 @@ def update_quotation(quot_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
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:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
@@ -1230,8 +986,6 @@ def patch_quotation(quot_id):
|
|||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': 'Quotation not found'}), 404
|
return jsonify({'error': 'Quotation not found'}), 404
|
||||||
|
|
||||||
old_status = row[1]
|
|
||||||
|
|
||||||
fields = []
|
fields = []
|
||||||
params = []
|
params = []
|
||||||
if 'customer_id' in data:
|
if 'customer_id' in data:
|
||||||
@@ -1243,10 +997,9 @@ def patch_quotation(quot_id):
|
|||||||
if 'valid_until' in data:
|
if 'valid_until' in data:
|
||||||
fields.append('valid_until = %s')
|
fields.append('valid_until = %s')
|
||||||
params.append(data['valid_until'])
|
params.append(data['valid_until'])
|
||||||
new_status = data.get('status')
|
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
|
||||||
if new_status and new_status in ('active', 'cancelled', 'expired'):
|
|
||||||
fields.append('status = %s')
|
fields.append('status = %s')
|
||||||
params.append(new_status)
|
params.append(data['status'])
|
||||||
|
|
||||||
if not fields:
|
if not fields:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
@@ -1254,20 +1007,6 @@ def patch_quotation(quot_id):
|
|||||||
|
|
||||||
params.append(quot_id)
|
params.append(quot_id)
|
||||||
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
|
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()
|
conn.commit()
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'message': 'Quotation updated'})
|
return jsonify({'message': 'Quotation updated'})
|
||||||
@@ -1617,15 +1356,12 @@ def convert_quotation(quot_id):
|
|||||||
'register_id': data.get('register_id'),
|
'register_id': data.get('register_id'),
|
||||||
'amount_paid': data.get('amount_paid', 0),
|
'amount_paid': data.get('amount_paid', 0),
|
||||||
'payment_details': data.get('payment_details', []),
|
'payment_details': data.get('payment_details', []),
|
||||||
'reference': data.get('reference', ''),
|
|
||||||
'notes': f'Convertida de cotizacion #{quot_id}',
|
'notes': f'Convertida de cotizacion #{quot_id}',
|
||||||
'currency': quot_currency,
|
'currency': quot_currency,
|
||||||
'exchange_rate': quot_rate,
|
'exchange_rate': quot_rate,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_validate_stock_availability(conn, items, g.branch_id)
|
|
||||||
_validate_zero_price(conn, items)
|
|
||||||
sale = process_sale(conn, sale_data)
|
sale = process_sale(conn, sale_data)
|
||||||
|
|
||||||
# Mark quotation as converted
|
# Mark quotation as converted
|
||||||
@@ -1784,8 +1520,6 @@ def create_layaway():
|
|||||||
# Enrich items with inventory data
|
# Enrich items with inventory data
|
||||||
try:
|
try:
|
||||||
enriched = _enrich_items(cur, items, customer_id)
|
enriched = _enrich_items(cur, items, customer_id)
|
||||||
_validate_stock_availability(conn, enriched, g.branch_id)
|
|
||||||
_validate_zero_price(conn, enriched)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
@@ -2177,14 +1911,10 @@ def complete_layaway(layaway_id):
|
|||||||
|
|
||||||
# Create sale_items (no inventory deduction — already reserved)
|
# Create sale_items (no inventory deduction — already reserved)
|
||||||
sale_items = []
|
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']:
|
for item in totals_calc['items']:
|
||||||
inv = inv_map.get(item['inventory_id'], ('', '', 0))
|
cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
|
||||||
|
(item['inventory_id'],))
|
||||||
|
inv = cur.fetchone()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO sale_items
|
INSERT INTO sale_items
|
||||||
(sale_id, inventory_id, part_number, name, quantity,
|
(sale_id, inventory_id, part_number, name, quantity,
|
||||||
@@ -2193,9 +1923,9 @@ def complete_layaway(layaway_id):
|
|||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
""", (
|
""", (
|
||||||
sale_id, item['inventory_id'],
|
sale_id, item['inventory_id'],
|
||||||
inv[0] or '', inv[1] or '',
|
inv[0] if inv else '', inv[1] if inv else '',
|
||||||
item['quantity'], item['unit_price'],
|
item['quantity'], item['unit_price'],
|
||||||
float(inv[2]) if inv[2] else 0,
|
float(inv[2]) if inv and inv[2] else 0,
|
||||||
item['discount_pct'], item['discount_amount'],
|
item['discount_pct'], item['discount_amount'],
|
||||||
item['tax_rate'], item['tax_amount'], item['subtotal']
|
item['tax_rate'], item['tax_amount'], item['subtotal']
|
||||||
))
|
))
|
||||||
@@ -2337,7 +2067,7 @@ def create_return():
|
|||||||
try:
|
try:
|
||||||
# Validate sale exists and is completed
|
# Validate sale exists and is completed
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, customer_id, total, status, branch_id, sale_type
|
SELECT id, customer_id, total, status, branch_id
|
||||||
FROM sales WHERE id = %s
|
FROM sales WHERE id = %s
|
||||||
""", (sale_id,))
|
""", (sale_id,))
|
||||||
sale = cur.fetchone()
|
sale = cur.fetchone()
|
||||||
@@ -2348,7 +2078,6 @@ def create_return():
|
|||||||
|
|
||||||
sale_customer_id = sale[1]
|
sale_customer_id = sale[1]
|
||||||
sale_branch_id = sale[4] or g.branch_id
|
sale_branch_id = sale[4] or g.branch_id
|
||||||
sale_type = sale[5]
|
|
||||||
|
|
||||||
# Validate each return item against original sale items
|
# Validate each return item against original sale items
|
||||||
total_refund = 0
|
total_refund = 0
|
||||||
@@ -2450,10 +2179,10 @@ def create_return():
|
|||||||
new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
|
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))
|
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
|
||||||
|
|
||||||
# Update customer credit if the original sale was on credit
|
# Update customer credit if applicable
|
||||||
if sale_customer_id and sale_type == 'credit':
|
if sale_customer_id:
|
||||||
cur.execute("""
|
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
|
WHERE id = %s
|
||||||
""", (total_refund, sale_customer_id))
|
""", (total_refund, sale_customer_id))
|
||||||
|
|
||||||
@@ -2692,79 +2421,6 @@ def print_ticket(sale_id):
|
|||||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
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 HTML Template ─────────────────────────────────────────────
|
||||||
|
|
||||||
PUBLIC_QUOTE_TEMPLATE = """
|
PUBLIC_QUOTE_TEMPLATE = """
|
||||||
|
|||||||
@@ -3,21 +3,16 @@
|
|||||||
Prefix: /pos/api/service-orders
|
Prefix: /pos/api/service-orders
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from flask import Blueprint, g, jsonify, request
|
from flask import Blueprint, g, jsonify, request
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from services.service_order_engine import (
|
from services.service_order_engine import (
|
||||||
add_item,
|
add_item,
|
||||||
add_labor,
|
add_labor,
|
||||||
assign_mechanic,
|
assign_mechanic,
|
||||||
convert_to_remission,
|
|
||||||
convert_to_sale,
|
convert_to_sale,
|
||||||
create_service_catalog_item,
|
create_service_catalog_item,
|
||||||
create_service_order,
|
create_service_order,
|
||||||
delete_service_catalog_item,
|
delete_service_catalog_item,
|
||||||
delete_service_order,
|
|
||||||
get_kanban_summary,
|
get_kanban_summary,
|
||||||
get_service_order,
|
get_service_order,
|
||||||
list_service_catalog,
|
list_service_catalog,
|
||||||
@@ -33,136 +28,17 @@ from services.service_order_engine import (
|
|||||||
update_status,
|
update_status,
|
||||||
)
|
)
|
||||||
from tenant_db import get_tenant_conn
|
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')
|
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'])
|
@service_order_bp.route('', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def list_orders():
|
def list_orders():
|
||||||
status = request.args.get('status')
|
status = request.args.get('status')
|
||||||
priority = request.args.get('priority')
|
priority = request.args.get('priority')
|
||||||
customer_id = request.args.get('customer_id', type=int)
|
customer_id = request.args.get('customer_id', type=int)
|
||||||
employee_id = request.args.get('employee_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))
|
page = int(request.args.get('page', 1))
|
||||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||||
|
|
||||||
@@ -171,65 +47,15 @@ def list_orders():
|
|||||||
result = list_service_orders(
|
result = list_service_orders(
|
||||||
conn, status=status, branch_id=g.branch_id,
|
conn, status=status, branch_id=g.branch_id,
|
||||||
customer_id=customer_id, priority=priority,
|
customer_id=customer_id, priority=priority,
|
||||||
employee_id=employee_id, delivery_method=delivery_method,
|
employee_id=employee_id, page=page, per_page=per_page
|
||||||
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)
|
return jsonify(result)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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'])
|
@service_order_bp.route('', methods=['POST'])
|
||||||
@require_workshop_action('create_order')
|
@require_auth()
|
||||||
def create_order():
|
def create_order():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -247,14 +73,6 @@ def create_order():
|
|||||||
'mileage_in': data.get('mileage_in'),
|
'mileage_in': data.get('mileage_in'),
|
||||||
'fuel_level': data.get('fuel_level'),
|
'fuel_level': data.get('fuel_level'),
|
||||||
'created_by': getattr(g, 'employee_id', None),
|
'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
|
return jsonify(result), 201
|
||||||
finally:
|
finally:
|
||||||
@@ -262,29 +80,22 @@ def create_order():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def get_order(so_id):
|
def get_order(so_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
order = get_service_order(conn, so_id)
|
order = get_service_order(conn, so_id)
|
||||||
if not order:
|
if not order:
|
||||||
return jsonify({'error': 'Service order not found'}), 404
|
return jsonify({'error': 'Service order not found'}), 404
|
||||||
if order.get('status') not in _visible_statuses():
|
return jsonify(order)
|
||||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
|
||||||
return jsonify(_redact_order_for_restricted(order))
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def update_order(so_id):
|
def update_order(so_id):
|
||||||
data = request.get_json() or {}
|
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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
ok = update_service_order(conn, so_id, data)
|
ok = update_service_order(conn, so_id, data)
|
||||||
@@ -295,32 +106,13 @@ def update_order(so_id):
|
|||||||
conn.close()
|
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'])
|
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||||
@require_workshop_action('change_status')
|
@require_auth()
|
||||||
def change_status(so_id):
|
def change_status(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
new_status = data.get('status')
|
new_status = data.get('status')
|
||||||
if not new_status:
|
if not new_status:
|
||||||
return jsonify({'error': 'status is required'}), 400
|
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)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
result = update_status(
|
result = update_status(
|
||||||
@@ -338,7 +130,7 @@ def change_status(so_id):
|
|||||||
# ─── Items (Parts) ─────────────────────────────
|
# ─── Items (Parts) ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def add_order_item(so_id):
|
def add_order_item(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -350,7 +142,7 @@ def add_order_item(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def update_order_item(item_id):
|
def update_order_item(item_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -364,7 +156,7 @@ def update_order_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def delete_order_item(item_id):
|
def delete_order_item(item_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -377,7 +169,7 @@ def delete_order_item(item_id):
|
|||||||
# ─── Labor ─────────────────────────────
|
# ─── Labor ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||||
@require_workshop_action('add_labor')
|
@require_auth()
|
||||||
def add_order_labor(so_id):
|
def add_order_labor(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('description'):
|
if not data.get('description'):
|
||||||
@@ -391,7 +183,7 @@ def add_order_labor(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||||
@require_workshop_action('add_labor')
|
@require_auth()
|
||||||
def update_order_labor(labor_id):
|
def update_order_labor(labor_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -405,7 +197,7 @@ def update_order_labor(labor_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||||
@require_workshop_action('add_labor')
|
@require_auth()
|
||||||
def delete_order_labor(labor_id):
|
def delete_order_labor(labor_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -418,7 +210,7 @@ def delete_order_labor(labor_id):
|
|||||||
# ─── Kanban Summary ─────────────────────────────
|
# ─── Kanban Summary ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def kanban_summary():
|
def kanban_summary():
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -432,7 +224,7 @@ def kanban_summary():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def reserve_order_item(so_id, item_id):
|
def reserve_order_item(so_id, item_id):
|
||||||
"""Reserve inventory for a service order item."""
|
"""Reserve inventory for a service order item."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -446,7 +238,7 @@ def reserve_order_item(so_id, item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def release_order_item(so_id, item_id):
|
def release_order_item(so_id, item_id):
|
||||||
"""Release a previous inventory reservation."""
|
"""Release a previous inventory reservation."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -464,7 +256,6 @@ def release_order_item(so_id, item_id):
|
|||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
|
||||||
@require_auth('pos.sell')
|
@require_auth('pos.sell')
|
||||||
@require_workshop_action('convert_to_sale')
|
|
||||||
def convert_order_to_sale(so_id):
|
def convert_order_to_sale(so_id):
|
||||||
"""Convert a service order into a POS sale.
|
"""Convert a service order into a POS sale.
|
||||||
|
|
||||||
@@ -499,43 +290,11 @@ def convert_order_to_sale(so_id):
|
|||||||
conn.close()
|
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 ──────────────────────────
|
# ─── Mechanic assignment ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||||
@require_workshop_action('assign_mechanic')
|
@require_auth()
|
||||||
def assign_mechanic_endpoint(so_id):
|
def assign_mechanic_endpoint(so_id):
|
||||||
"""Assign a mechanic/technician to a service order."""
|
"""Assign a mechanic/technician to a service order."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -553,144 +312,11 @@ def assign_mechanic_endpoint(so_id):
|
|||||||
conn.close()
|
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 catalog (reusable labor) ─────────────
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def list_catalog():
|
def list_catalog():
|
||||||
"""List reusable labor/service concepts."""
|
"""List reusable labor/service concepts."""
|
||||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||||
@@ -703,7 +329,7 @@ def list_catalog():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def create_catalog_item():
|
def create_catalog_item():
|
||||||
"""Create a reusable labor concept."""
|
"""Create a reusable labor concept."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -719,7 +345,7 @@ def create_catalog_item():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def update_catalog_item(item_id):
|
def update_catalog_item(item_id):
|
||||||
"""Update a reusable labor concept."""
|
"""Update a reusable labor concept."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -734,7 +360,7 @@ def update_catalog_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def delete_catalog_item(item_id):
|
def delete_catalog_item(item_id):
|
||||||
"""Soft-delete a reusable labor concept."""
|
"""Soft-delete a reusable labor concept."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -749,7 +375,7 @@ def delete_catalog_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def print_service_order_ticket(so_id):
|
def print_service_order_ticket(so_id):
|
||||||
"""Generate a printable ticket for a service order.
|
"""Generate a printable ticket for a service order.
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def enqueue_report():
|
|||||||
|
|
||||||
|
|
||||||
@tasks_bp.route('/<task_id>/status', methods=['GET'])
|
@tasks_bp.route('/<task_id>/status', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth
|
||||||
def task_status(task_id):
|
def task_status(task_id):
|
||||||
"""Get status of a background task."""
|
"""Get status of a background task."""
|
||||||
from celery_app import celery
|
from celery_app import celery
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
|
|||||||
# Check permissions
|
# Check permissions
|
||||||
if required_permissions:
|
if required_permissions:
|
||||||
missing = set(required_permissions) - g.permissions
|
missing = set(required_permissions) - g.permissions
|
||||||
# owner/admin roles bypass all permission checks
|
# owner role bypasses all permission checks
|
||||||
if g.employee_role not in ('owner', 'admin') and missing:
|
if g.employee_role != 'owner' and missing:
|
||||||
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
|
|||||||
|
|
||||||
def has_permission(permission):
|
def has_permission(permission):
|
||||||
"""Check if current user has a specific permission. Use inside a route."""
|
"""Check if current user has a specific permission. Use inside a route."""
|
||||||
return g.employee_role in ('owner', 'admin') or permission in g.permissions
|
return g.employee_role == 'owner' or permission in g.permissions
|
||||||
|
|||||||
@@ -52,24 +52,6 @@ MIGRATIONS = {
|
|||||||
"v4.3": "v4.3_facturapi.sql",
|
"v4.3": "v4.3_facturapi.sql",
|
||||||
"v4.4": "v4.4_workshop.sql",
|
"v4.4": "v4.4_workshop.sql",
|
||||||
"v4.5": "v4.5_customer_max_discount.sql",
|
"v4.5": "v4.5_customer_max_discount.sql",
|
||||||
"v4.6": "v4.6_inventory_support.sql",
|
|
||||||
"v4.7": "v4.7_workshop_business.sql",
|
|
||||||
"v4.8": "v4.8_workshop_permissions.sql",
|
|
||||||
"v4.9": "v4.9_workshop_customers_view.sql",
|
|
||||||
"v4.10": "v4.10_fleet_permissions.sql",
|
|
||||||
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
|
|
||||||
"v4.12": "v4.12_service_order_soft_delete.sql",
|
|
||||||
"v4.13": "v4.13_fleet_vehicle_customer.sql",
|
|
||||||
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
|
|
||||||
"v4.15": "v4.15_counter_remission.sql",
|
|
||||||
"v4.16": "v4.16_remission_courier.sql",
|
|
||||||
"v4.17": "v4.17_service_order_invoice.sql",
|
|
||||||
"v4.18": "v4.18_rached_workshop.sql",
|
|
||||||
"v4.19": "v4.19_counter_inventory_create.sql",
|
|
||||||
"v4.20": "v4.20_cashier_sell_permissions.sql",
|
|
||||||
"v4.21": "v4.21_cashier_workshop_permissions.sql",
|
|
||||||
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
|
|
||||||
"v4.23": "v4.23_service_order_mechanic_name.sql",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -125,10 +107,7 @@ def apply_migration(db_name, version):
|
|||||||
def run_migrations():
|
def run_migrations():
|
||||||
"""Apply pending migrations to all tenants."""
|
"""Apply pending migrations to all tenants."""
|
||||||
tenants = get_all_tenants()
|
tenants = get_all_tenants()
|
||||||
def _version_key(v):
|
sorted_versions = sorted(MIGRATIONS.keys())
|
||||||
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"Found {len(tenants)} active tenants")
|
||||||
print(f"Available migrations: {sorted_versions}")
|
print(f"Available migrations: {sorted_versions}")
|
||||||
@@ -136,9 +115,8 @@ def run_migrations():
|
|||||||
for tenant_id, db_name, name, current_version in tenants:
|
for tenant_id, db_name, name, current_version in tenants:
|
||||||
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
||||||
|
|
||||||
current_key = _version_key(current_version)
|
|
||||||
for version in sorted_versions:
|
for version in sorted_versions:
|
||||||
if _version_key(version) <= current_key:
|
if version <= current_version:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f" Applying {version}...", end=" ")
|
print(f" Applying {version}...", end=" ")
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
-- v4.10: add fleet RBAC permissions for existing employees.
|
|
||||||
-- Owner and admin get full fleet access; accountant and workshop get read access.
|
|
||||||
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, p.perm
|
|
||||||
FROM employees e
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
('fleet.view'),
|
|
||||||
('fleet.create'),
|
|
||||||
('fleet.edit'),
|
|
||||||
('fleet.delete')
|
|
||||||
) AS p(perm)
|
|
||||||
WHERE e.role IN ('owner', 'admin')
|
|
||||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, 'fleet.view'
|
|
||||||
FROM employees e
|
|
||||||
WHERE e.role IN ('accountant', 'workshop')
|
|
||||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- v4.11_clean_workshop_mechanic_permissions.sql
|
|
||||||
-- Restringe los permisos de los empleados con rol 'workshop' y 'mechanic'
|
|
||||||
-- para que solo tengan acceso a la seccion de Taller y no vean precios.
|
|
||||||
-- Ejecutar en cada base de datos de tenant.
|
|
||||||
|
|
||||||
-- 1. Eliminar cualquier permiso que no sea de taller para estos roles
|
|
||||||
DELETE FROM employee_permissions
|
|
||||||
WHERE employee_id IN (
|
|
||||||
SELECT id FROM employees WHERE role IN ('workshop', 'mechanic')
|
|
||||||
)
|
|
||||||
AND permission NOT IN ('workshop.view', 'workshop.edit', 'workshop.add_items');
|
|
||||||
|
|
||||||
-- 2. Asegurar que tengan los permisos base de taller
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, p.permission
|
|
||||||
FROM employees e
|
|
||||||
CROSS JOIN (
|
|
||||||
VALUES
|
|
||||||
('workshop.view'),
|
|
||||||
('workshop.edit'),
|
|
||||||
('workshop.add_items')
|
|
||||||
) AS p(permission)
|
|
||||||
WHERE e.role IN ('workshop', 'mechanic')
|
|
||||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- v4.12_service_order_soft_delete.sql
|
|
||||||
-- Soft-delete support for service orders.
|
|
||||||
|
|
||||||
ALTER TABLE service_orders
|
|
||||||
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_orders_is_deleted
|
|
||||||
ON service_orders (is_deleted);
|
|
||||||
|
|
||||||
-- Exclude deleted orders from common queries by default via partial index helpers
|
|
||||||
-- (application filters explicitly).
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- v4.13_fleet_vehicle_customer.sql
|
|
||||||
-- Link fleet vehicles to a customer.
|
|
||||||
|
|
||||||
ALTER TABLE fleet_vehicles
|
|
||||||
ADD COLUMN IF NOT EXISTS customer_id INTEGER;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint
|
|
||||||
WHERE conname = 'fleet_vehicles_customer_id_fkey'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE fleet_vehicles
|
|
||||||
ADD CONSTRAINT fleet_vehicles_customer_id_fkey
|
|
||||||
FOREIGN KEY (customer_id) REFERENCES customers(id);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_customer_id
|
|
||||||
ON fleet_vehicles (customer_id);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
|
|
||||||
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
|
|
||||||
|
|
||||||
UPDATE service_orders
|
|
||||||
SET delivery_method = 'pickup'
|
|
||||||
WHERE delivery_method = 'courier';
|
|
||||||
|
|
||||||
UPDATE service_orders
|
|
||||||
SET courier_id = NULL
|
|
||||||
WHERE delivery_method IS NULL OR delivery_method != 'delivery';
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- v4.15: Counter remission note support
|
|
||||||
-- Ensures employees with role 'counter' have the base permissions needed to
|
|
||||||
-- generate remission notes from the POS.
|
|
||||||
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, p.permission
|
|
||||||
FROM employees e
|
|
||||||
CROSS JOIN (
|
|
||||||
VALUES
|
|
||||||
('pos.remission'),
|
|
||||||
('pos.view'),
|
|
||||||
('catalog.view'),
|
|
||||||
('inventory.view'),
|
|
||||||
('customers.view'),
|
|
||||||
('customers.create')
|
|
||||||
) AS p(permission)
|
|
||||||
WHERE e.role = 'counter'
|
|
||||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- v4.16: Add courier assignment to counter remission notes.
|
|
||||||
|
|
||||||
ALTER TABLE sales
|
|
||||||
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint
|
|
||||||
WHERE conname = 'sales_courier_id_fkey'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE sales
|
|
||||||
ADD CONSTRAINT sales_courier_id_fkey
|
|
||||||
FOREIGN KEY (courier_id) REFERENCES couriers(id);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- Service order invoice requirement flag
|
|
||||||
ALTER TABLE service_orders
|
|
||||||
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
|
|
||||||
ON service_orders(requires_invoice);
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
-- v4.18 Rached workshop fields
|
|
||||||
-- Extends service orders and items to match the Rached legacy workshop flow.
|
|
||||||
-- Applied to all tenants.
|
|
||||||
|
|
||||||
-- ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
|
|
||||||
-- ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
ALTER TABLE service_orders
|
|
||||||
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
|
|
||||||
ADD COLUMN IF NOT EXISTS customer_address TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
|
|
||||||
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
|
|
||||||
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
|
|
||||||
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
|
|
||||||
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
|
|
||||||
|
|
||||||
-- ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
|
|
||||||
-- ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
ALTER TABLE service_order_items
|
|
||||||
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
|
|
||||||
ADD COLUMN IF NOT EXISTS observations TEXT;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
|
|
||||||
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- v4.19 — Grant inventory.create permission to existing counter employees
|
|
||||||
-- so they can create items and record purchase entries.
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, 'inventory.create'
|
|
||||||
FROM employees e
|
|
||||||
WHERE e.role = 'counter'
|
|
||||||
AND e.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM employee_permissions ep
|
|
||||||
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
|
|
||||||
);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
|
|
||||||
-- The cashier default set already includes these permissions, but employees
|
|
||||||
-- created before the fix may be missing them.
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, p.perm
|
|
||||||
FROM employees e
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
('pos.sell'),
|
|
||||||
('pos.discount'),
|
|
||||||
('pos.cancel'),
|
|
||||||
('pos.view')
|
|
||||||
) AS p(perm)
|
|
||||||
WHERE e.role = 'cashier'
|
|
||||||
AND e.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM employee_permissions ep
|
|
||||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
|
||||||
);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- v4.21 — Allow existing cashier employees to create and edit service orders.
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, p.perm
|
|
||||||
FROM employees e
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
('workshop.view'),
|
|
||||||
('workshop.edit'),
|
|
||||||
('workshop.add_items')
|
|
||||||
) AS p(perm)
|
|
||||||
WHERE e.role = 'cashier'
|
|
||||||
AND e.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM employee_permissions ep
|
|
||||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
|
||||||
);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- v4.22 -- Enable invoicing for existing cashier employees.
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, p.perm
|
|
||||||
FROM employees e
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
('invoicing.view'),
|
|
||||||
('invoicing.create'),
|
|
||||||
('invoicing.cancel')
|
|
||||||
) AS p(perm)
|
|
||||||
WHERE e.role = 'cashier'
|
|
||||||
AND e.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM employee_permissions ep
|
|
||||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- v4.23 -- Add free-text mechanic name for service orders.
|
|
||||||
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Tenant DB schema v4.6 — support tables for inventory UI
|
|
||||||
|
|
||||||
-- Product categories (hierarchical, optional)
|
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_categories_active ON categories(is_active);
|
|
||||||
|
|
||||||
-- Tier-based discount configuration for price tiers 2 and 3
|
|
||||||
CREATE TABLE IF NOT EXISTS tier_discounts (
|
|
||||||
tier_id INTEGER PRIMARY KEY,
|
|
||||||
tier_name VARCHAR(50) NOT NULL,
|
|
||||||
discount_pct NUMERIC(5,2) DEFAULT 0
|
|
||||||
);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- v4.6 Workshop business fields
|
|
||||||
-- Adds delivery method, courier assignment and direct-order flag to service orders.
|
|
||||||
|
|
||||||
ALTER TABLE service_orders
|
|
||||||
ADD COLUMN IF NOT EXISTS delivery_method VARCHAR(30),
|
|
||||||
ADD COLUMN IF NOT EXISTS courier_id INTEGER REFERENCES couriers(id),
|
|
||||||
ADD COLUMN IF NOT EXISTS is_direct BOOLEAN DEFAULT FALSE;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_orders_delivery_method ON service_orders(delivery_method);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_orders_courier_id ON service_orders(courier_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_orders_is_direct ON service_orders(is_direct);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- v4.8 Workshop permissions seed
|
|
||||||
-- Grants workshop permissions to existing admin employees so they keep access
|
|
||||||
-- after the new role-based restrictions are enforced.
|
|
||||||
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT id, 'workshop.view'
|
|
||||||
FROM employees
|
|
||||||
WHERE role = 'admin'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT id, 'workshop.edit'
|
|
||||||
FROM employees
|
|
||||||
WHERE role = 'admin'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- v4.9: Grant customers.view permission to existing workshop employees
|
|
||||||
-- so they can load the customer list when creating service orders.
|
|
||||||
INSERT INTO employee_permissions (employee_id, permission)
|
|
||||||
SELECT e.id, 'customers.view'
|
|
||||||
FROM employees e
|
|
||||||
WHERE e.role = 'workshop'
|
|
||||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
|
||||||
@@ -46,22 +46,18 @@ def _get_account_id(cur, code):
|
|||||||
|
|
||||||
def _get_account_ids(cur, codes):
|
def _get_account_ids(cur, codes):
|
||||||
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
||||||
cur.execute(
|
result = {}
|
||||||
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true",
|
for code in codes:
|
||||||
(list(codes),)
|
result[code] = _get_account_id(cur, code)
|
||||||
)
|
return result
|
||||||
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):
|
def get_next_entry_number(conn):
|
||||||
"""Get the next sequential journal entry number.
|
"""Get the next sequential journal entry number.
|
||||||
|
|
||||||
Uses a transaction-level advisory lock to prevent duplicate numbers
|
Uses a simple MAX+1 approach. For high-concurrency environments this
|
||||||
when multiple journal entries are created concurrently.
|
could be replaced with a sequence, but for single-tenant refaccionarias
|
||||||
|
the transaction-level lock from the INSERT is sufficient.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection to tenant DB
|
conn: psycopg2 connection to tenant DB
|
||||||
@@ -70,7 +66,6 @@ def get_next_entry_number(conn):
|
|||||||
int: next entry number (starts at 1)
|
int: next entry number (starts at 1)
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
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")
|
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
||||||
number = cur.fetchone()[0]
|
number = cur.fetchone()[0]
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
@@ -29,13 +29,8 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
|||||||
|
|
||||||
|
|
||||||
def _generate_provisional_folio(conn):
|
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 = conn.cursor()
|
||||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
|
|
||||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||||
seq = cur.fetchone()[0]
|
seq = cur.fetchone()[0]
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -106,7 +101,6 @@ def process_queue(conn, tenant_config, dry_run=False):
|
|||||||
AND retry_count < %s
|
AND retry_count < %s
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
FOR UPDATE SKIP LOCKED
|
|
||||||
""",
|
""",
|
||||||
(MAX_RETRIES,),
|
(MAX_RETRIES,),
|
||||||
)
|
)
|
||||||
@@ -370,7 +364,7 @@ def get_queue_status(conn, filters=None):
|
|||||||
params.append(int(filters["sale_id"]))
|
params.append(int(filters["sale_id"]))
|
||||||
|
|
||||||
if filters.get("type"):
|
if filters.get("type"):
|
||||||
where_clauses.append("LOWER(q.type) = LOWER(%s)")
|
where_clauses.append("q.type = %s")
|
||||||
params.append(filters["type"])
|
params.append(filters["type"])
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
where = " AND ".join(where_clauses)
|
||||||
@@ -382,12 +376,8 @@ def get_queue_status(conn, filters=None):
|
|||||||
f"""
|
f"""
|
||||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||||
q.retry_count, q.provisional_folio, q.error_message,
|
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
|
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}
|
WHERE {where}
|
||||||
ORDER BY q.created_at DESC
|
ORDER BY q.created_at DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
@@ -411,12 +401,6 @@ def get_queue_status(conn, filters=None):
|
|||||||
"created_at": str(r[9]) if r[9] else None,
|
"created_at": str(r[9]) if r[9] else None,
|
||||||
"stamped_at": str(r[10]) if r[10] else None,
|
"stamped_at": str(r[10]) if r[10] else None,
|
||||||
"external_id": r[11],
|
"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:
|
def create_organization(tenant_config: dict) -> dict:
|
||||||
"""Create or reuse a Facturapi organization, configure legal data and return a live key.
|
"""Create a new Facturapi organization for the tenant and return live key.
|
||||||
|
|
||||||
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
||||||
Uses tenant RFC/razon_social if available.
|
Uses tenant RFC/razon_social if available.
|
||||||
@@ -216,18 +216,8 @@ def create_organization(tenant_config: dict) -> dict:
|
|||||||
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||||
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
||||||
|
|
||||||
# 1) Reuse the organization already stored locally, if it still exists in Facturapi.
|
# First try to find existing org by RFC
|
||||||
local_org_id = _get_org_id(tenant_config)
|
existing = find_organization_by_rfc(tenant_config) if rfc else None
|
||||||
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:
|
if existing:
|
||||||
org_id = existing["id"]
|
org_id = existing["id"]
|
||||||
else:
|
else:
|
||||||
@@ -236,20 +226,6 @@ def create_organization(tenant_config: dict) -> dict:
|
|||||||
org_id = org.get("id")
|
org_id = org.get("id")
|
||||||
if not org_id:
|
if not org_id:
|
||||||
raise FacturapiError("Could not create organization: no id returned")
|
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
|
# Generate live secret key
|
||||||
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
|
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
|
||||||
@@ -257,80 +233,7 @@ def create_organization(tenant_config: dict) -> dict:
|
|||||||
if not live_key:
|
if not live_key:
|
||||||
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||||
|
|
||||||
return {"org_id": org_id, "api_key": live_key, "legal_updated": True}
|
return {"org_id": org_id, "api_key": live_key}
|
||||||
|
|
||||||
|
|
||||||
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:
|
def get_org_status(tenant_config: dict) -> dict:
|
||||||
@@ -346,12 +249,12 @@ def get_org_status(tenant_config: dict) -> dict:
|
|||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
has_secret = bool(_get_secret_key(tenant_config))
|
try:
|
||||||
has_user = bool(_get_user_key_for_tenant(tenant_config))
|
api_key = get_api_key(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
|
result["has_key"] = True
|
||||||
|
except FacturapiError as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
return result
|
||||||
|
|
||||||
org_id = _get_org_id(tenant_config)
|
org_id = _get_org_id(tenant_config)
|
||||||
if not org_id:
|
if not org_id:
|
||||||
@@ -361,35 +264,20 @@ def get_org_status(tenant_config: dict) -> dict:
|
|||||||
result["has_org_id"] = True
|
result["has_org_id"] = True
|
||||||
result["org_id"] = org_id
|
result["org_id"] = org_id
|
||||||
|
|
||||||
api_key = _get_status_key(tenant_config)
|
try:
|
||||||
if not api_key:
|
|
||||||
result["error"] = "No Facturapi key available"
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _fetch():
|
|
||||||
org = get_organization(org_id, api_key)
|
org = get_organization(org_id, api_key)
|
||||||
legal = org.get("legal", {})
|
legal = org.get("legal", {})
|
||||||
cert = org.get("certificate", {})
|
cert = org.get("certificate", {})
|
||||||
return {
|
result.update(
|
||||||
|
{
|
||||||
"configured": True,
|
"configured": True,
|
||||||
"has_csd": bool(cert.get("has_certificate")),
|
"has_csd": bool(cert.get("has_certificate")),
|
||||||
"legal_name": legal.get("name") or legal.get("legal_name"),
|
"legal_name": legal.get("name") or legal.get("legal_name"),
|
||||||
"tax_id": legal.get("tax_id"),
|
"tax_id": legal.get("tax_id"),
|
||||||
"pending_steps": org.get("pending_steps", []),
|
"pending_steps": org.get("pending_steps", []),
|
||||||
}
|
}
|
||||||
|
)
|
||||||
try:
|
|
||||||
result.update(_fetch())
|
|
||||||
except FacturapiError as e:
|
except FacturapiError as e:
|
||||||
# If the org reports "not configured for Live" (401) and we have a user key,
|
|
||||||
# try to backfill legal data and retry.
|
|
||||||
if e.status_code == 401 and _get_user_key_for_tenant(tenant_config):
|
|
||||||
try:
|
|
||||||
update_organization_legal(tenant_config, org_id)
|
|
||||||
result.update(_fetch())
|
|
||||||
except FacturapiError as e2:
|
|
||||||
result["error"] = str(e2)
|
|
||||||
else:
|
|
||||||
result["error"] = str(e)
|
result["error"] = str(e)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -58,18 +58,19 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
|||||||
cur.close()
|
cur.close()
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Load sale details with items in two bulk queries (O(1) round-trips)
|
# Load sale details with items
|
||||||
|
sales = []
|
||||||
|
for sale_id in sale_ids:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||||
payment_method, subtotal, discount_total, tax_total, total,
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||||
FROM sales
|
FROM sales WHERE id = %s
|
||||||
WHERE id = ANY(%s)
|
""", (sale_id,))
|
||||||
ORDER BY created_at ASC
|
row = cur.fetchone()
|
||||||
""", (sale_ids,))
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
sales = {}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
sale = {
|
sale = {
|
||||||
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||||
'employee_id': row[3], 'sale_type': row[4],
|
'employee_id': row[3], 'sale_type': row[4],
|
||||||
@@ -84,37 +85,33 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
|||||||
'created_at': str(row[13]),
|
'created_at': str(row[13]),
|
||||||
'items': [],
|
'items': [],
|
||||||
}
|
}
|
||||||
sales[row[0]] = sale
|
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, sale_id, inventory_id, part_number, name, quantity, unit_price,
|
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||||
subtotal, clave_prod_serv, clave_unidad
|
subtotal, clave_prod_serv, clave_unidad
|
||||||
FROM sale_items
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
WHERE sale_id = ANY(%s)
|
""", (sale_id,))
|
||||||
ORDER BY sale_id, id
|
|
||||||
""", (sale_ids,))
|
|
||||||
|
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
sale = sales.get(r[1])
|
|
||||||
if not sale:
|
|
||||||
continue
|
|
||||||
sale['items'].append({
|
sale['items'].append({
|
||||||
'id': r[0], 'inventory_id': r[2], 'part_number': r[3],
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||||
'name': r[4], 'quantity': r[5],
|
'name': r[3], 'quantity': r[4],
|
||||||
'unit_price': float(r[6]) if r[6] else 0,
|
'unit_price': float(r[5]) if r[5] else 0,
|
||||||
'unit_cost': float(r[7]) if r[7] else 0,
|
'unit_cost': float(r[6]) if r[6] else 0,
|
||||||
'discount_pct': float(r[8]) if r[8] else 0,
|
'discount_pct': float(r[7]) if r[7] else 0,
|
||||||
'discount_amount': float(r[9]) if r[9] else 0,
|
'discount_amount': float(r[8]) if r[8] else 0,
|
||||||
'tax_rate': float(r[10]) if r[10] else 0.16,
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||||
'tax_amount': float(r[11]) if r[11] else 0,
|
'tax_amount': float(r[10]) if r[10] else 0,
|
||||||
'subtotal': float(r[12]) if r[12] else 0,
|
'subtotal': float(r[11]) if r[11] else 0,
|
||||||
'clave_prod_serv': r[13] or '25174800',
|
'clave_prod_serv': r[12] or '25174800',
|
||||||
'clave_unidad': r[14] or 'H87',
|
'clave_unidad': r[13] or 'H87',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sales.append(sale)
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return list(sales.values())
|
return sales
|
||||||
|
|
||||||
|
|
||||||
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||||
|
|||||||
@@ -122,14 +122,7 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
|||||||
))
|
))
|
||||||
op_id = cur.fetchone()[0]
|
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("""
|
|
||||||
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("""
|
cur.execute("""
|
||||||
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
||||||
SELECT %s, 'stock_update', 'pending'
|
SELECT %s, 'stock_update', 'pending'
|
||||||
@@ -237,54 +230,6 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
|||||||
return op_id
|
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):
|
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||||
"""Record a customer return (positive quantity)."""
|
"""Record a customer return (positive quantity)."""
|
||||||
result = record_operation(
|
result = record_operation(
|
||||||
@@ -429,7 +374,7 @@ def get_movement_history(conn, inventory_id, limit=50):
|
|||||||
history.append({
|
history.append({
|
||||||
'id': r[0], 'type': r[1], 'quantity': r[2],
|
'id': r[0], 'type': r[1], 'quantity': r[2],
|
||||||
'cost': float(r[3]) if r[3] else None,
|
'cost': float(r[3]) if r[3] else None,
|
||||||
'notes': r[4], 'date': r[5].isoformat() if r[5] else None,
|
'notes': r[4], 'date': str(r[5]),
|
||||||
'employee': r[6], 'branch_id': r[7]
|
'employee': r[6], 'branch_id': r[7]
|
||||||
})
|
})
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ Tax: 16% IVA per item (from item.tax_rate field).
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
import threading
|
|
||||||
from flask import g
|
from flask import g
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
from services.inventory_engine import (
|
from services.inventory_engine import (
|
||||||
record_sale as inventory_record_sale,
|
record_sale as inventory_record_sale,
|
||||||
record_reservation as inventory_record_reservation,
|
|
||||||
release_reservation as inventory_release_reservation,
|
|
||||||
record_operation,
|
record_operation,
|
||||||
get_stock,
|
get_stock,
|
||||||
get_stock_bulk,
|
get_stock_bulk,
|
||||||
@@ -226,9 +223,9 @@ def process_sale(conn, sale_data):
|
|||||||
if not items:
|
if not items:
|
||||||
raise ValueError("No items in sale")
|
raise ValueError("No items in sale")
|
||||||
|
|
||||||
# Validate register is open and lock it to prevent concurrent close/sale races
|
# Validate register is open
|
||||||
if register_id:
|
if register_id:
|
||||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
|
cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
|
||||||
reg = cur.fetchone()
|
reg = cur.fetchone()
|
||||||
if not reg or reg[0] != 'open':
|
if not reg or reg[0] != 'open':
|
||||||
raise ValueError("Cash register is not open")
|
raise ValueError("Cash register is not open")
|
||||||
@@ -252,17 +249,6 @@ def process_sale(conn, sale_data):
|
|||||||
# Batch stock check
|
# Batch stock check
|
||||||
stock_map = get_stock_bulk(conn, branch_id)
|
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
|
# Validate and enrich items
|
||||||
enriched_items = []
|
enriched_items = []
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -315,34 +301,24 @@ def process_sale(conn, sale_data):
|
|||||||
credit_limit = float(cust[0] or 0)
|
credit_limit = float(cust[0] or 0)
|
||||||
credit_balance = float(cust[1] or 0)
|
credit_balance = float(cust[1] or 0)
|
||||||
credit_available = credit_limit - credit_balance
|
credit_available = credit_limit - credit_balance
|
||||||
if totals['total'] > credit_available:
|
if totals['total'] > credit_available and credit_limit > 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
||||||
f"Required: ${totals['total']:.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
|
# Calculate change
|
||||||
change_given = 0.0
|
change_given = 0.0
|
||||||
if sale_type == 'cash' and payment_method == 'efectivo':
|
if sale_type == 'cash' and payment_method == 'efectivo':
|
||||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||||
|
|
||||||
# SAT payment method codes
|
# SAT payment method codes
|
||||||
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
|
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
|
||||||
forma_pago_map = {
|
forma_pago_map = {
|
||||||
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
||||||
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
|
||||||
}
|
}
|
||||||
forma_pago_sat = forma_pago_map.get(payment_method, '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)
|
# Create sale record (with currency)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO sales
|
INSERT INTO sales
|
||||||
@@ -350,14 +326,14 @@ def process_sale(conn, sale_data):
|
|||||||
payment_method, subtotal, discount_total, tax_total, total,
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||||
status, device_id, notes, currency, exchange_rate)
|
status, device_id, notes, currency, exchange_rate)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
""", (
|
""", (
|
||||||
branch_id, customer_id, employee_id, register_id, sale_type,
|
branch_id, customer_id, employee_id, register_id, sale_type,
|
||||||
payment_method, totals['subtotal'], totals['discount_total'],
|
payment_method, totals['subtotal'], totals['discount_total'],
|
||||||
totals['tax_total'], totals['total'], amount_paid, change_given,
|
totals['tax_total'], totals['total'], amount_paid, change_given,
|
||||||
metodo_pago_sat, forma_pago_sat,
|
metodo_pago_sat, forma_pago_sat,
|
||||||
status, _safe_g('device_id'), notes,
|
_safe_g('device_id'), notes,
|
||||||
currency, exchange_rate
|
currency, exchange_rate
|
||||||
))
|
))
|
||||||
sale_id, created_at = cur.fetchone()
|
sale_id, created_at = cur.fetchone()
|
||||||
@@ -416,21 +392,18 @@ def process_sale(conn, sale_data):
|
|||||||
'subtotal': item['subtotal'],
|
'subtotal': item['subtotal'],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Record payment on cash register (skip pending sales and zero-amount rows)
|
# Record payment on cash register (cash movements for efectivo)
|
||||||
if not is_pending:
|
|
||||||
if register_id and payment_details:
|
if register_id and payment_details:
|
||||||
for pd in payment_details:
|
for pd in payment_details:
|
||||||
method = pd.get('method', payment_method)
|
method = pd.get('method', payment_method)
|
||||||
amt = float(pd.get('amount', 0))
|
amt = float(pd.get('amount', 0))
|
||||||
ref = pd.get('reference', '')
|
ref = pd.get('reference', '')
|
||||||
if amt <= 0:
|
|
||||||
continue
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO sale_payments
|
INSERT INTO sale_payments
|
||||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||||
elif register_id and amount_paid > 0:
|
elif register_id:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO sale_payments
|
INSERT INTO sale_payments
|
||||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||||
@@ -445,16 +418,6 @@ def process_sale(conn, sale_data):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (totals['total'], customer_id))
|
""", (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
|
# Audit log
|
||||||
log_action(conn, 'SALE', 'sale', sale_id,
|
log_action(conn, 'SALE', 'sale', sale_id,
|
||||||
new_value={
|
new_value={
|
||||||
@@ -531,8 +494,6 @@ def process_sale(conn, sale_data):
|
|||||||
'id': sale_id,
|
'id': sale_id,
|
||||||
'branch_id': branch_id,
|
'branch_id': branch_id,
|
||||||
'customer_id': customer_id,
|
'customer_id': customer_id,
|
||||||
'customer_name': customer_name,
|
|
||||||
'customer_rfc': customer_rfc,
|
|
||||||
'employee_id': employee_id,
|
'employee_id': employee_id,
|
||||||
'register_id': register_id,
|
'register_id': register_id,
|
||||||
'sale_type': sale_type,
|
'sale_type': sale_type,
|
||||||
@@ -553,342 +514,6 @@ def process_sale(conn, sale_data):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_remission_note(conn, data):
|
|
||||||
"""Create a counter remission note: reserve stock, no payment, pending status.
|
|
||||||
|
|
||||||
data: {
|
|
||||||
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
|
|
||||||
customer_id: int | null,
|
|
||||||
notes: str,
|
|
||||||
branch_id: int,
|
|
||||||
register_id: int | null (optional)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
|
||||||
items = data.get('items', [])
|
|
||||||
customer_id = data.get('customer_id')
|
|
||||||
notes = data.get('notes')
|
|
||||||
branch_id = data.get('branch_id') or _safe_g('branch_id')
|
|
||||||
register_id = data.get('register_id')
|
|
||||||
employee_id = _safe_g('employee_id')
|
|
||||||
currency = data.get('currency', 'MXN')
|
|
||||||
if currency not in ('MXN', 'USD'):
|
|
||||||
raise ValueError("Unsupported currency")
|
|
||||||
exchange_rate = float(data.get('exchange_rate') or 1.0)
|
|
||||||
if currency != 'MXN' and not exchange_rate:
|
|
||||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
|
||||||
|
|
||||||
if not branch_id:
|
|
||||||
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
|
|
||||||
row = cur.fetchone()
|
|
||||||
branch_id = row[0] if row else None
|
|
||||||
if not branch_id:
|
|
||||||
raise ValueError("No hay sucursal activa disponible")
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
raise ValueError("No items in remission note")
|
|
||||||
|
|
||||||
inv_ids = [item.get('inventory_id') for item in items]
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
|
||||||
tax_rate, branch_id, retail_price
|
|
||||||
FROM inventory
|
|
||||||
WHERE id = ANY(%s) AND is_active = true
|
|
||||||
ORDER BY id
|
|
||||||
FOR UPDATE
|
|
||||||
""", (inv_ids,))
|
|
||||||
inv_rows = {r[0]: r for r in cur.fetchall()}
|
|
||||||
|
|
||||||
stock_map = get_stock_bulk(conn, branch_id)
|
|
||||||
if branch_id:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT inventory_id, stock
|
|
||||||
FROM inventory_stock
|
|
||||||
WHERE branch_id = %s AND inventory_id = ANY(%s)
|
|
||||||
FOR UPDATE
|
|
||||||
""", (branch_id, inv_ids))
|
|
||||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
|
||||||
|
|
||||||
enriched_items = []
|
|
||||||
for item in items:
|
|
||||||
inv_id = item.get('inventory_id')
|
|
||||||
qty = int(item.get('quantity', 1))
|
|
||||||
if qty <= 0:
|
|
||||||
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
|
||||||
inv = inv_rows.get(inv_id)
|
|
||||||
if not inv:
|
|
||||||
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
|
||||||
current_stock = stock_map.get(inv_id, 0)
|
|
||||||
unit_price = float(item.get('unit_price', inv[4]))
|
|
||||||
discount_pct = float(item.get('discount_pct', 0))
|
|
||||||
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
|
|
||||||
unit_cost = float(inv[3]) if inv[3] else 0
|
|
||||||
enriched_items.append({
|
|
||||||
'inventory_id': inv_id,
|
|
||||||
'part_number': inv[1],
|
|
||||||
'name': inv[2],
|
|
||||||
'quantity': qty,
|
|
||||||
'unit_price': unit_price,
|
|
||||||
'unit_cost': unit_cost,
|
|
||||||
'discount_pct': discount_pct,
|
|
||||||
'tax_rate': tax_rate,
|
|
||||||
'branch_id': inv[8],
|
|
||||||
'stock_before': current_stock,
|
|
||||||
})
|
|
||||||
|
|
||||||
totals = calculate_totals(enriched_items)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO sales
|
|
||||||
(branch_id, customer_id, employee_id, register_id, sale_type,
|
|
||||||
payment_method, subtotal, discount_total, tax_total, total,
|
|
||||||
amount_paid, change_given, status, device_id, notes, currency, exchange_rate,
|
|
||||||
courier_id)
|
|
||||||
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
|
|
||||||
%s, %s, %s, %s, 0, 0, 'pending_payment',
|
|
||||||
%s, %s, %s, %s, %s)
|
|
||||||
RETURNING id, created_at
|
|
||||||
""", (
|
|
||||||
branch_id, customer_id, employee_id, register_id,
|
|
||||||
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
|
|
||||||
_safe_g('device_id'), notes, currency, exchange_rate,
|
|
||||||
data.get('courier_id')
|
|
||||||
))
|
|
||||||
sale_id, created_at = cur.fetchone()
|
|
||||||
|
|
||||||
sale_items_data = []
|
|
||||||
for item in totals['items']:
|
|
||||||
inv = inv_rows.get(item['inventory_id'])
|
|
||||||
retail_price = inv[9] if inv else None
|
|
||||||
sale_items_data.append((
|
|
||||||
sale_id, item['inventory_id'], item['part_number'], item['name'],
|
|
||||||
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
|
|
||||||
item['discount_pct'], item['discount_amount'],
|
|
||||||
item['tax_rate'], item['tax_amount'], item['subtotal'],
|
|
||||||
retail_price, currency, exchange_rate
|
|
||||||
))
|
|
||||||
|
|
||||||
cur.executemany("""
|
|
||||||
INSERT INTO sale_items
|
|
||||||
(sale_id, inventory_id, part_number, name, quantity,
|
|
||||||
unit_price, unit_cost, discount_pct, discount_amount,
|
|
||||||
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
""", sale_items_data)
|
|
||||||
|
|
||||||
sale_items = []
|
|
||||||
for item in totals['items']:
|
|
||||||
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
|
|
||||||
remaining_after = stock_before - item['quantity']
|
|
||||||
inventory_record_reservation(
|
|
||||||
conn,
|
|
||||||
item['inventory_id'],
|
|
||||||
item.get('branch_id', branch_id),
|
|
||||||
item['quantity'],
|
|
||||||
sale_id=sale_id,
|
|
||||||
cost_at_time=item.get('unit_cost'),
|
|
||||||
remaining_stock=remaining_after
|
|
||||||
)
|
|
||||||
sale_items.append({
|
|
||||||
'inventory_id': item['inventory_id'],
|
|
||||||
'part_number': item['part_number'],
|
|
||||||
'name': item['name'],
|
|
||||||
'quantity': item['quantity'],
|
|
||||||
'unit_price': item['unit_price'],
|
|
||||||
'unit_cost': item.get('unit_cost', 0),
|
|
||||||
'discount_pct': item['discount_pct'],
|
|
||||||
'discount_amount': item['discount_amount'],
|
|
||||||
'tax_rate': item['tax_rate'],
|
|
||||||
'tax_amount': item['tax_amount'],
|
|
||||||
'subtotal': item['subtotal'],
|
|
||||||
})
|
|
||||||
|
|
||||||
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
|
|
||||||
new_value={
|
|
||||||
'total': totals['total'],
|
|
||||||
'items_count': len(sale_items),
|
|
||||||
'customer_id': customer_id,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fetch customer info for ticket/receipt
|
|
||||||
customer_name = None
|
|
||||||
customer_rfc = None
|
|
||||||
if customer_id:
|
|
||||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
|
||||||
cust_row = cur.fetchone()
|
|
||||||
if cust_row:
|
|
||||||
customer_name = cust_row[0]
|
|
||||||
customer_rfc = cust_row[1]
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
return {
|
|
||||||
'id': sale_id,
|
|
||||||
'branch_id': branch_id,
|
|
||||||
'customer_id': customer_id,
|
|
||||||
'customer_name': customer_name,
|
|
||||||
'customer_rfc': customer_rfc,
|
|
||||||
'employee_id': employee_id,
|
|
||||||
'register_id': register_id,
|
|
||||||
'sale_type': 'counter_remission',
|
|
||||||
'payment_method': 'remission',
|
|
||||||
'subtotal': totals['subtotal'],
|
|
||||||
'discount_total': totals['discount_total'],
|
|
||||||
'tax_total': totals['tax_total'],
|
|
||||||
'total': totals['total'],
|
|
||||||
'amount_paid': 0.0,
|
|
||||||
'change_given': 0.0,
|
|
||||||
'status': 'pending_payment',
|
|
||||||
'courier_id': data.get('courier_id'),
|
|
||||||
'items': sale_items,
|
|
||||||
'created_at': str(created_at),
|
|
||||||
'currency': currency,
|
|
||||||
'exchange_rate': exchange_rate,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def pay_pending_sale(conn, sale_id, data):
|
|
||||||
"""Pay a pending counter remission note and convert it into a completed sale.
|
|
||||||
|
|
||||||
data: {
|
|
||||||
payment_method: str,
|
|
||||||
amount_paid: float,
|
|
||||||
payment_details: [{method, amount, reference}],
|
|
||||||
register_id: int,
|
|
||||||
reference: str
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
|
||||||
currency, exchange_rate
|
|
||||||
FROM sales WHERE id = %s
|
|
||||||
FOR UPDATE
|
|
||||||
""", (sale_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise ValueError("Sale not found")
|
|
||||||
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
|
||||||
currency, exchange_rate) = row
|
|
||||||
|
|
||||||
# Fetch customer info for ticket/receipt
|
|
||||||
customer_name = None
|
|
||||||
customer_rfc = None
|
|
||||||
if customer_id:
|
|
||||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
|
||||||
cust_row = cur.fetchone()
|
|
||||||
if cust_row:
|
|
||||||
customer_name = cust_row[0]
|
|
||||||
customer_rfc = cust_row[1]
|
|
||||||
|
|
||||||
if status != 'pending_payment':
|
|
||||||
raise ValueError("La nota no esta pendiente de pago")
|
|
||||||
|
|
||||||
payment_method = data.get('payment_method', 'efectivo')
|
|
||||||
sale_type = 'cash'
|
|
||||||
amount_paid = float(data.get('amount_paid', 0))
|
|
||||||
payment_details = data.get('payment_details', [])
|
|
||||||
register_id = data.get('register_id')
|
|
||||||
reference = data.get('reference', '')
|
|
||||||
|
|
||||||
if register_id:
|
|
||||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
|
|
||||||
reg = cur.fetchone()
|
|
||||||
if not reg or reg[0] != 'open':
|
|
||||||
raise ValueError("Cash register is not open")
|
|
||||||
|
|
||||||
totals = {'total': float(total)}
|
|
||||||
change_given = 0.0
|
|
||||||
if payment_method == 'efectivo':
|
|
||||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
|
||||||
|
|
||||||
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
|
|
||||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE sales
|
|
||||||
SET status = 'completed',
|
|
||||||
sale_type = %s,
|
|
||||||
payment_method = %s,
|
|
||||||
amount_paid = %s,
|
|
||||||
change_given = %s,
|
|
||||||
register_id = COALESCE(%s, register_id),
|
|
||||||
metodo_pago_sat = 'PUE',
|
|
||||||
forma_pago_sat = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
|
|
||||||
|
|
||||||
if payment_details:
|
|
||||||
for pd in payment_details:
|
|
||||||
method = pd.get('method', payment_method)
|
|
||||||
amt = float(pd.get('amount', 0))
|
|
||||||
ref = pd.get('reference', '')
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO sale_payments
|
|
||||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
||||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
|
||||||
else:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO sale_payments
|
|
||||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
||||||
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
|
|
||||||
|
|
||||||
# Release reservation and record actual sale movement
|
|
||||||
cur.execute("""
|
|
||||||
SELECT inventory_id, quantity, unit_cost
|
|
||||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
|
||||||
""", (sale_id,))
|
|
||||||
for inv_id, qty, cost in cur.fetchall():
|
|
||||||
inventory_release_reservation(
|
|
||||||
conn, inv_id, branch_id, qty, sale_id=sale_id,
|
|
||||||
notes='Pago de nota de remision'
|
|
||||||
)
|
|
||||||
inventory_record_sale(
|
|
||||||
conn, inv_id, branch_id, qty,
|
|
||||||
sale_id=sale_id, cost_at_time=float(cost) if cost else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Accounting (non-blocking)
|
|
||||||
try:
|
|
||||||
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
|
|
||||||
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
|
|
||||||
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COALESCE(SUM(unit_cost * quantity), 0)
|
|
||||||
FROM sale_items WHERE sale_id = %s
|
|
||||||
""", (sale_id,))
|
|
||||||
cost_total = float(cur.fetchone()[0] or 0)
|
|
||||||
record_sale_entry(conn, {
|
|
||||||
'id': sale_id,
|
|
||||||
'sale_type': sale_type,
|
|
||||||
'total': total_mxn,
|
|
||||||
'tax_total': tax_mxn,
|
|
||||||
'subtotal': sub_mxn,
|
|
||||||
'cost_total': cost_total,
|
|
||||||
'payment_method': payment_method,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
|
|
||||||
old_value={'status': 'pending_payment', 'total': totals['total']},
|
|
||||||
new_value={'status': 'completed', 'payment_method': payment_method})
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
return {
|
|
||||||
'id': sale_id,
|
|
||||||
'status': 'completed',
|
|
||||||
'payment_method': payment_method,
|
|
||||||
'amount_paid': amount_paid,
|
|
||||||
'change_given': change_given,
|
|
||||||
'total': totals['total'],
|
|
||||||
'customer_id': customer_id,
|
|
||||||
'customer_name': customer_name,
|
|
||||||
'customer_rfc': customer_rfc,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_sale(conn, sale_id, reason):
|
def cancel_sale(conn, sale_id, reason):
|
||||||
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
||||||
|
|
||||||
@@ -930,15 +555,15 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
if s_status == 'cancelled':
|
if s_status == 'cancelled':
|
||||||
raise ValueError("Sale is already cancelled")
|
raise ValueError("Sale is already cancelled")
|
||||||
|
|
||||||
# Permission check: non-admin employees can only cancel their own docs within 30 min
|
# Permission check: cashiers can only cancel own sales within 30 min
|
||||||
role = _safe_g('employee_role', 'cashier')
|
role = _safe_g('employee_role', 'cashier')
|
||||||
emp_id = _safe_g('employee_id')
|
emp_id = _safe_g('employee_id')
|
||||||
|
|
||||||
if role not in ('owner', 'admin'):
|
if role == 'cashier':
|
||||||
if s_emp_id != emp_id:
|
if s_emp_id != emp_id:
|
||||||
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
|
raise ValueError("Cashiers can only cancel their own sales")
|
||||||
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
||||||
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
|
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
|
||||||
|
|
||||||
# Get sale items for inventory reversal
|
# Get sale items for inventory reversal
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -947,16 +572,7 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
""", (sale_id,))
|
""", (sale_id,))
|
||||||
sale_items = cur.fetchall()
|
sale_items = cur.fetchall()
|
||||||
|
|
||||||
if s_status == 'pending_payment':
|
# Reverse inventory: create RETURN operations (positive quantity)
|
||||||
# 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
|
from services.inventory_engine import record_return
|
||||||
for inv_id, qty, cost in sale_items:
|
for inv_id, qty, cost in sale_items:
|
||||||
record_return(
|
record_return(
|
||||||
@@ -999,7 +615,7 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
log_action(conn, 'CANCEL', 'sale', sale_id,
|
log_action(conn, 'CANCEL', 'sale', sale_id,
|
||||||
old_value={'status': s_status, 'total': float(s_total)},
|
old_value={'status': 'completed', 'total': float(s_total)},
|
||||||
new_value={'status': 'cancelled', 'reason': reason})
|
new_value={'status': 'cancelled', 'reason': reason})
|
||||||
|
|
||||||
# Push notification to owner/admin (best-effort, non-blocking)
|
# Push notification to owner/admin (best-effort, non-blocking)
|
||||||
|
|||||||
@@ -8,66 +8,23 @@ from datetime import datetime
|
|||||||
|
|
||||||
from services import inventory_engine
|
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 = {
|
VALID_TRANSITIONS = {
|
||||||
status: [s for s in ORDER_STATUSES if s != status]
|
'received': ['diagnosis', 'cancelled'],
|
||||||
for status in ORDER_STATUSES
|
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
||||||
|
'waiting_parts': ['repair', 'cancelled'],
|
||||||
|
'repair': ['quality_check', 'cancelled'],
|
||||||
|
'quality_check': ['ready', 'repair', 'cancelled'],
|
||||||
|
'ready': ['delivered', 'cancelled'],
|
||||||
|
'delivered': [],
|
||||||
|
'cancelled': [],
|
||||||
}
|
}
|
||||||
for terminal in TERMINAL_STATUSES:
|
|
||||||
VALID_TRANSITIONS[terminal] = []
|
|
||||||
|
|
||||||
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
|
|
||||||
_LEGACY_STATUSES = {
|
|
||||||
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
|
|
||||||
}
|
|
||||||
for legacy in _LEGACY_STATUSES:
|
|
||||||
VALID_TRANSITIONS.setdefault(legacy, [])
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_order_number(conn):
|
def _generate_order_number(conn):
|
||||||
"""Generate DDMMYYYY-N order number (daily sequential).
|
"""Generate SO-YYYY-NNNN order number."""
|
||||||
|
|
||||||
Uses a per-day advisory transaction lock to avoid duplicate order
|
|
||||||
numbers when multiple workers create orders concurrently.
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
today = datetime.utcnow().strftime('%d%m%Y')
|
year = datetime.utcnow().year
|
||||||
prefix = f"{today}-"
|
prefix = f"SO-{year}-"
|
||||||
# Serialize order creation per day within the current transaction.
|
|
||||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (prefix,))
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT order_number FROM service_orders
|
SELECT order_number FROM service_orders
|
||||||
WHERE order_number LIKE %s
|
WHERE order_number LIKE %s
|
||||||
@@ -80,19 +37,7 @@ def _generate_order_number(conn):
|
|||||||
last_num = int(row[0].split('-')[-1])
|
last_num = int(row[0].split('-')[-1])
|
||||||
new_num = last_num + 1
|
new_num = last_num + 1
|
||||||
cur.close()
|
cur.close()
|
||||||
return f"{prefix}{new_num}"
|
return f"{prefix}{new_num:04d}"
|
||||||
|
|
||||||
|
|
||||||
_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):
|
def create_service_order(conn, data):
|
||||||
@@ -101,11 +46,9 @@ def create_service_order(conn, data):
|
|||||||
data: {
|
data: {
|
||||||
customer_id, vehicle_id, branch_id, priority,
|
customer_id, vehicle_id, branch_id, priority,
|
||||||
reception_notes, estimated_cost, estimated_completion,
|
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()
|
cur = conn.cursor()
|
||||||
order_number = _generate_order_number(conn)
|
order_number = _generate_order_number(conn)
|
||||||
|
|
||||||
@@ -113,23 +56,16 @@ def create_service_order(conn, data):
|
|||||||
INSERT INTO service_orders
|
INSERT INTO service_orders
|
||||||
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
||||||
priority, reception_notes, estimated_cost, estimated_completion,
|
priority, reception_notes, estimated_cost, estimated_completion,
|
||||||
employee_id, mechanic_name, mileage_in, fuel_level, created_by,
|
employee_id, mileage_in, fuel_level, created_by)
|
||||||
delivery_method, courier_id, is_direct, requires_invoice,
|
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
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
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
||||||
data.get('vehicle_id'), order_number,
|
data.get('vehicle_id'), order_number,
|
||||||
data.get('priority', 'normal'), data.get('reception_notes'),
|
data.get('priority', 'normal'), data.get('reception_notes'),
|
||||||
data.get('estimated_cost'), data.get('estimated_completion'),
|
data.get('estimated_cost'), data.get('estimated_completion'),
|
||||||
data.get('employee_id'), data.get('mechanic_name'),
|
data.get('employee_id'), data.get('mileage_in'),
|
||||||
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'),
|
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]
|
so_id = cur.fetchone()[0]
|
||||||
|
|
||||||
@@ -137,7 +73,7 @@ def create_service_order(conn, data):
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO service_order_status_history
|
INSERT INTO service_order_status_history
|
||||||
(service_order_id, new_status, changed_by, notes)
|
(service_order_id, new_status, changed_by, notes)
|
||||||
VALUES (%s, 'por_revisar', %s, 'Orden creada')
|
VALUES (%s, 'received', %s, 'Orden creada')
|
||||||
""", (so_id, data.get('created_by')))
|
""", (so_id, data.get('created_by')))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -150,28 +86,18 @@ def get_service_order(conn, so_id):
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT so.id, so.order_number, so.status, so.priority,
|
SELECT so.id, so.order_number, so.status, so.priority,
|
||||||
so.customer_id, c.name as customer_name, c.phone as customer_phone,
|
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.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
||||||
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone,
|
so.branch_id, so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
||||||
so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
|
||||||
so.delivery_notes, so.estimated_cost, so.final_cost,
|
so.delivery_notes, so.estimated_cost, so.final_cost,
|
||||||
so.estimated_completion, so.actual_completion, so.delivered_at,
|
so.estimated_completion, so.actual_completion, so.delivered_at,
|
||||||
so.mileage_in, so.mileage_out, so.fuel_level,
|
so.mileage_in, so.mileage_out, so.fuel_level,
|
||||||
so.employee_id, e.name as employee_name,
|
so.employee_id, e.name as employee_name,
|
||||||
so.mechanic_name,
|
so.created_by, so.created_at, so.updated_at
|
||||||
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
|
FROM service_orders so
|
||||||
LEFT JOIN customers c ON so.customer_id = c.id
|
LEFT JOIN customers c ON so.customer_id = c.id
|
||||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||||
LEFT JOIN employees e ON so.employee_id = e.id
|
LEFT JOIN employees e ON so.employee_id = e.id
|
||||||
LEFT JOIN employees creator ON so.created_by = creator.id
|
WHERE so.id = %s
|
||||||
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,))
|
""", (so_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -181,51 +107,35 @@ def get_service_order(conn, so_id):
|
|||||||
so = {
|
so = {
|
||||||
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
|
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
|
||||||
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
|
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
|
||||||
'customer_address': row[7], 'customer_price_tier': row[8],
|
'vehicle_id': row[7], 'vehicle_plate': row[8], 'vehicle_make': row[9], 'vehicle_model': row[10],
|
||||||
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12],
|
'branch_id': row[11], 'reception_notes': row[12], 'diagnosis_notes': row[13],
|
||||||
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16],
|
'repair_notes': row[14], 'delivery_notes': row[15],
|
||||||
'reception_notes': row[17], 'diagnosis_notes': row[18],
|
'estimated_cost': float(row[16]) if row[16] else None,
|
||||||
'repair_notes': row[19], 'delivery_notes': row[20],
|
'final_cost': float(row[17]) if row[17] else None,
|
||||||
'estimated_cost': float(row[21]) if row[21] else None,
|
'estimated_completion': str(row[18]) if row[18] else None,
|
||||||
'final_cost': float(row[22]) if row[22] else None,
|
'actual_completion': str(row[19]) if row[19] else None,
|
||||||
'estimated_completion': str(row[23]) if row[23] else None,
|
'delivered_at': str(row[20]) if row[20] else None,
|
||||||
'actual_completion': str(row[24]) if row[24] else None,
|
'mileage_in': row[21], 'mileage_out': row[22], 'fuel_level': row[23],
|
||||||
'delivered_at': str(row[25]) if row[25] else None,
|
'employee_id': row[24], 'employee_name': row[25],
|
||||||
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28],
|
'created_by': row[26], 'created_at': str(row[27]), 'updated_at': str(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
|
# Items
|
||||||
cur.execute("""
|
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
|
FROM service_order_items
|
||||||
WHERE service_order_id = %s
|
WHERE service_order_id = %s
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
""", (so_id,))
|
""", (so_id,))
|
||||||
so['items'] = []
|
so['items'] = []
|
||||||
total_parts = 0.0
|
|
||||||
for r in cur.fetchall():
|
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({
|
so['items'].append({
|
||||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
||||||
'quantity': qty,
|
'quantity': float(r[4]) if r[4] else 0,
|
||||||
'unit_cost': float(r[5]) if r[5] else None,
|
'unit_cost': float(r[5]) if r[5] else None,
|
||||||
'unit_price': price,
|
'unit_price': float(r[6]) if r[6] else None,
|
||||||
'status': r[7], 'notes': r[8],
|
'status': r[7], 'notes': r[8],
|
||||||
'mechanic_id': r[9], 'observations': r[10],
|
|
||||||
})
|
})
|
||||||
total_parts += qty * price
|
|
||||||
|
|
||||||
# Labor
|
# Labor
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -235,37 +145,27 @@ def get_service_order(conn, so_id):
|
|||||||
ORDER BY id
|
ORDER BY id
|
||||||
""", (so_id,))
|
""", (so_id,))
|
||||||
so['labor'] = []
|
so['labor'] = []
|
||||||
total_labor = 0.0
|
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
total = float(r[4]) if r[4] else 0
|
|
||||||
so['labor'].append({
|
so['labor'].append({
|
||||||
'id': r[0], 'description': r[1],
|
'id': r[0], 'description': r[1],
|
||||||
'hours': float(r[2]) if r[2] else 0,
|
'hours': float(r[2]) if r[2] else 0,
|
||||||
'hourly_rate': float(r[3]) if r[3] else 0,
|
'hourly_rate': float(r[3]) if r[3] else 0,
|
||||||
'total_cost': total,
|
'total_cost': float(r[4]) if r[4] else 0,
|
||||||
'employee_id': r[5], 'status': r[6],
|
'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
|
# Status history
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT h.id, h.old_status, h.new_status, h.changed_by, e.name as changed_by_name,
|
SELECT id, old_status, new_status, changed_by, notes, created_at
|
||||||
h.notes, h.created_at
|
FROM service_order_status_history
|
||||||
FROM service_order_status_history h
|
WHERE service_order_id = %s
|
||||||
LEFT JOIN employees e ON h.changed_by = e.id
|
ORDER BY created_at
|
||||||
WHERE h.service_order_id = %s
|
|
||||||
ORDER BY h.created_at
|
|
||||||
""", (so_id,))
|
""", (so_id,))
|
||||||
so['status_history'] = []
|
so['status_history'] = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
so['status_history'].append({
|
so['status_history'].append({
|
||||||
'id': r[0], 'old_status': r[1], 'new_status': r[2],
|
'id': r[0], 'old_status': r[1], 'new_status': r[2],
|
||||||
'changed_by': r[3], 'changed_by_name': r[4],
|
'changed_by': r[3], 'notes': r[4], 'created_at': str(r[5]),
|
||||||
'notes': r[5], 'created_at': str(r[6]),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -273,8 +173,7 @@ def get_service_order(conn, so_id):
|
|||||||
|
|
||||||
|
|
||||||
def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||||
priority=None, employee_id=None, delivery_method=None,
|
priority=None, employee_id=None, page=1, per_page=50):
|
||||||
is_direct=None, q=None, page=1, per_page=50):
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
where_clauses = []
|
where_clauses = []
|
||||||
params = []
|
params = []
|
||||||
@@ -294,49 +193,22 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
if employee_id:
|
if employee_id:
|
||||||
where_clauses.append("so.employee_id = %s")
|
where_clauses.append("so.employee_id = %s")
|
||||||
params.append(employee_id)
|
params.append(employee_id)
|
||||||
if delivery_method:
|
|
||||||
where_clauses.append("so.delivery_method = %s")
|
|
||||||
params.append(delivery_method)
|
|
||||||
if is_direct is not None:
|
|
||||||
where_clauses.append("so.is_direct = %s")
|
|
||||||
params.append(is_direct)
|
|
||||||
if q:
|
|
||||||
where_clauses.append(
|
|
||||||
"(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s OR "
|
|
||||||
"so.workshop_name ILIKE %s OR so.vehicle_description ILIKE %s)"
|
|
||||||
)
|
|
||||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%'])
|
|
||||||
|
|
||||||
where_clauses.append("so.is_deleted = false")
|
where = " AND ".join(where_clauses) if where_clauses else "true"
|
||||||
where = " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT count(*) FROM service_orders so
|
SELECT count(*) FROM service_orders so WHERE {where}
|
||||||
LEFT JOIN customers c ON so.customer_id = c.id
|
|
||||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
|
||||||
WHERE {where}
|
|
||||||
""", params)
|
""", params)
|
||||||
total = cur.fetchone()[0]
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT so.id, so.order_number, so.status, so.priority,
|
SELECT so.id, so.order_number, so.status, so.priority,
|
||||||
so.customer_id, c.name as customer_name,
|
so.customer_id, c.name as customer_name,
|
||||||
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
so.vehicle_id, fv.plate as vehicle_plate,
|
||||||
so.branch_id, b.name as branch_name,
|
so.estimated_cost, so.estimated_completion, so.created_at
|
||||||
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
|
FROM service_orders so
|
||||||
LEFT JOIN customers c ON so.customer_id = c.id
|
LEFT JOIN customers c ON so.customer_id = c.id
|
||||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.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}
|
WHERE {where}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE so.priority
|
CASE so.priority
|
||||||
@@ -351,25 +223,13 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
|
|
||||||
orders = []
|
orders = []
|
||||||
for r in cur.fetchall():
|
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({
|
orders.append({
|
||||||
'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3],
|
'id': r[0], 'order_number': r[1], 'status': r[2], 'priority': r[3],
|
||||||
'customer_id': r[4], 'customer_name': r[5],
|
'customer_id': r[4], 'customer_name': r[5],
|
||||||
'vehicle_id': r[6], 'vehicle_plate': r[7], 'vehicle_make': r[8], 'vehicle_model': r[9],
|
'vehicle_id': r[6], 'vehicle_plate': r[7],
|
||||||
'branch_id': r[10], 'branch_name': r[11],
|
'estimated_cost': float(r[8]) if r[8] else None,
|
||||||
'estimated_cost': estimated,
|
'estimated_completion': str(r[9]) if r[9] else None,
|
||||||
'final_cost': final,
|
'created_at': str(r[10]),
|
||||||
'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()
|
cur.close()
|
||||||
@@ -383,7 +243,7 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
|||||||
def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
||||||
"""Update service order status with validation."""
|
"""Update service order status with validation."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT status FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
|
cur.execute("SELECT status FROM service_orders WHERE id = %s", (so_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -397,9 +257,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
|||||||
# Update status
|
# Update status
|
||||||
extra_sets = []
|
extra_sets = []
|
||||||
extra_vals = []
|
extra_vals = []
|
||||||
if new_status == 'reparada':
|
if new_status == 'ready':
|
||||||
extra_sets.append("actual_completion = NOW()")
|
extra_sets.append("actual_completion = NOW()")
|
||||||
if new_status == 'entregado':
|
if new_status == 'delivered':
|
||||||
extra_sets.append("delivered_at = NOW()")
|
extra_sets.append("delivered_at = NOW()")
|
||||||
extra_sets.append("delivered_by = %s")
|
extra_sets.append("delivered_by = %s")
|
||||||
extra_vals.append(changed_by)
|
extra_vals.append(changed_by)
|
||||||
@@ -428,16 +288,14 @@ def add_item(conn, so_id, item_data):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO service_order_items
|
INSERT INTO service_order_items
|
||||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
(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)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
||||||
item_data.get('name'), item_data.get('quantity', 1),
|
item_data.get('name'), item_data.get('quantity', 1),
|
||||||
item_data.get('unit_cost'), item_data.get('unit_price'),
|
item_data.get('unit_cost'), item_data.get('unit_price'),
|
||||||
item_data.get('status', 'pending'), item_data.get('notes'),
|
item_data.get('status', 'pending'), item_data.get('notes'),
|
||||||
item_data.get('mechanic_id'), item_data.get('observations'),
|
|
||||||
))
|
))
|
||||||
item_id = cur.fetchone()[0]
|
item_id = cur.fetchone()[0]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -447,7 +305,7 @@ def add_item(conn, so_id, item_data):
|
|||||||
|
|
||||||
def update_item(conn, item_id, data):
|
def update_item(conn, item_id, data):
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations']
|
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
|
||||||
sets = []
|
sets = []
|
||||||
vals = []
|
vals = []
|
||||||
for field in allowed:
|
for field in allowed:
|
||||||
@@ -529,14 +387,10 @@ def remove_labor(conn, labor_id):
|
|||||||
|
|
||||||
def update_service_order(conn, so_id, data):
|
def update_service_order(conn, so_id, data):
|
||||||
"""Update general service order fields."""
|
"""Update general service order fields."""
|
||||||
_normalize_delivery(data)
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
|
allowed = ['priority', 'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||||
'reception_notes', 'diagnosis_notes', 'repair_notes',
|
|
||||||
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
||||||
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
'employee_id', 'mileage_out', 'fuel_level', 'final_cost']
|
||||||
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
|
|
||||||
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
|
|
||||||
sets = []
|
sets = []
|
||||||
vals = []
|
vals = []
|
||||||
for field in allowed:
|
for field in allowed:
|
||||||
@@ -565,20 +419,19 @@ def get_kanban_summary(conn, branch_id=None):
|
|||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT status, COUNT(*) as cnt
|
SELECT status, COUNT(*) as cnt
|
||||||
FROM service_orders
|
FROM service_orders
|
||||||
WHERE status != 'cancelled' AND is_deleted = false {branch_filter}
|
WHERE status != 'cancelled' {branch_filter}
|
||||||
GROUP BY status
|
GROUP BY status
|
||||||
""", params)
|
""", params)
|
||||||
|
|
||||||
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
|
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
summary[r[0]] = r[1]
|
summary[r[0]] = r[1]
|
||||||
|
|
||||||
# Overdue orders (estimated_completion passed and not delivered/invoiced)
|
# Overdue orders (estimated_completion passed and not ready/delivered)
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT count(*) FROM service_orders
|
SELECT count(*) FROM service_orders
|
||||||
WHERE estimated_completion < NOW()
|
WHERE estimated_completion < NOW()
|
||||||
AND status NOT IN ('entregado', 'facturada', 'cancelada')
|
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
||||||
AND is_deleted = false
|
|
||||||
{branch_filter}
|
{branch_filter}
|
||||||
""", params)
|
""", params)
|
||||||
overdue = cur.fetchone()[0]
|
overdue = cur.fetchone()[0]
|
||||||
@@ -601,7 +454,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
|
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
|
||||||
so.order_number, so.branch_id
|
so.order_number
|
||||||
FROM service_order_items soi
|
FROM service_order_items soi
|
||||||
JOIN service_orders so ON so.id = soi.service_order_id
|
JOIN service_orders so ON so.id = soi.service_order_id
|
||||||
WHERE soi.id = %s
|
WHERE soi.id = %s
|
||||||
@@ -613,7 +466,7 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
|
|||||||
cur.close()
|
cur.close()
|
||||||
raise ValueError("Service order item not found")
|
raise ValueError("Service order item not found")
|
||||||
|
|
||||||
so_id, inventory_id, quantity, status, order_number, branch_id = row
|
so_id, inventory_id, quantity, status, order_number = row
|
||||||
if status == "cancelled":
|
if status == "cancelled":
|
||||||
cur.close()
|
cur.close()
|
||||||
raise ValueError("Cannot reserve a cancelled item")
|
raise ValueError("Cannot reserve a cancelled item")
|
||||||
@@ -622,11 +475,10 @@ def reserve_item(conn, so_item_id, branch_id, employee_id=None):
|
|||||||
raise ValueError("Item has no inventory linked")
|
raise ValueError("Item has no inventory linked")
|
||||||
|
|
||||||
qty = int(quantity)
|
qty = int(quantity)
|
||||||
if not _tenant_allows_negative_stock(conn):
|
|
||||||
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
|
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
|
||||||
if available < qty:
|
if available < qty:
|
||||||
cur.close()
|
cur.close()
|
||||||
raise ValueError(f"Sin stock suficiente. Disponible: {available}, solicitado: {qty}")
|
raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
|
||||||
|
|
||||||
inventory_engine.record_operation(
|
inventory_engine.record_operation(
|
||||||
conn,
|
conn,
|
||||||
@@ -891,156 +743,10 @@ def convert_to_sale(conn, so_id, sale_data, employee_id=None):
|
|||||||
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
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):
|
def assign_mechanic(conn, so_id, employee_id):
|
||||||
"""Assign a mechanic/technician to a service order."""
|
"""Assign a mechanic/technician to a service order."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT id FROM service_orders WHERE id = %s AND is_deleted = false", (so_id,))
|
cur.execute("SELECT id FROM service_orders WHERE id = %s", (so_id,))
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
cur.close()
|
cur.close()
|
||||||
raise ValueError("Service order not found")
|
raise ValueError("Service order not found")
|
||||||
@@ -1054,21 +760,6 @@ def assign_mechanic(conn, so_id, employee_id):
|
|||||||
return {"employee_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) ───────────────────────────────
|
# ─── Service catalog (reusable labor concepts) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -247,8 +247,7 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
|
|||||||
'accounting.view', 'accounting.create', 'accounting.close',
|
'accounting.view', 'accounting.create', 'accounting.close',
|
||||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||||
'reports.view', 'reports.financial',
|
'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(
|
tenant_cur.executemany(
|
||||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",
|
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ ALIGN_CENTER = ESC + b'a' + b'\x01'
|
|||||||
ALIGN_RIGHT = ESC + b'a' + b'\x02'
|
ALIGN_RIGHT = ESC + b'a' + b'\x02'
|
||||||
BOLD_ON = ESC + b'E' + b'\x01'
|
BOLD_ON = ESC + b'E' + b'\x01'
|
||||||
BOLD_OFF = ESC + b'E' + b'\x00'
|
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'
|
DOUBLE_HEIGHT = ESC + b'!' + b'\x10'
|
||||||
NORMAL_SIZE = ESC + b'!' + b'\x00'
|
NORMAL_SIZE = ESC + b'!' + b'\x00'
|
||||||
LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height
|
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):
|
def generate_ticket(sale_data, business_info, width=80):
|
||||||
@@ -38,8 +34,6 @@ def generate_ticket(sale_data, business_info, width=80):
|
|||||||
chars = 32 if width == 58 else 48 # characters per line
|
chars = 32 if width == 58 else 48 # characters per line
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
buf += INIT
|
buf += INIT
|
||||||
buf += EMPH_ON
|
|
||||||
buf += LINE_SPACING_140
|
|
||||||
|
|
||||||
# Header: business name (centered, bold, large)
|
# Header: business name (centered, bold, large)
|
||||||
buf += ALIGN_CENTER
|
buf += ALIGN_CENTER
|
||||||
@@ -106,10 +100,7 @@ def generate_ticket(sale_data, business_info, width=80):
|
|||||||
buf += 'Gracias por su compra!\n'.encode('cp437', errors='replace')
|
buf += 'Gracias por su compra!\n'.encode('cp437', errors='replace')
|
||||||
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
|
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
|
||||||
buf += b'\n\n\n'
|
buf += b'\n\n\n'
|
||||||
buf += LINE_SPACING_DEFAULT
|
buf += PARTIAL_CUT
|
||||||
buf += EMPH_OFF
|
|
||||||
buf += FEED + b'\x05' # Feed 5 lines before cutting
|
|
||||||
buf += CUT # Full cut
|
|
||||||
|
|
||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|
||||||
@@ -128,8 +119,6 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
|
|||||||
chars = 32 if width == 58 else 48
|
chars = 32 if width == 58 else 48
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
buf += INIT
|
buf += INIT
|
||||||
buf += EMPH_ON
|
|
||||||
buf += LINE_SPACING_140
|
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
buf += ALIGN_CENTER
|
buf += ALIGN_CENTER
|
||||||
@@ -198,10 +187,7 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
|
|||||||
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
|
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
|
||||||
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
|
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
|
||||||
buf += b'\n\n\n'
|
buf += b'\n\n\n'
|
||||||
buf += LINE_SPACING_DEFAULT
|
buf += PARTIAL_CUT
|
||||||
buf += EMPH_OFF
|
|
||||||
buf += FEED + b'\x05'
|
|
||||||
buf += CUT
|
|
||||||
|
|
||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|
||||||
@@ -238,8 +224,6 @@ def generate_service_order_ticket(so_data, business_info, width=80):
|
|||||||
chars = 32 if width == 58 else 48
|
chars = 32 if width == 58 else 48
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
buf += INIT
|
buf += INIT
|
||||||
buf += EMPH_ON
|
|
||||||
buf += LINE_SPACING_140
|
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
buf += ALIGN_CENTER
|
buf += ALIGN_CENTER
|
||||||
@@ -354,9 +338,6 @@ def generate_service_order_ticket(so_data, business_info, width=80):
|
|||||||
buf += "No es comprobante fiscal\n".encode("cp437", errors="replace")
|
buf += "No es comprobante fiscal\n".encode("cp437", errors="replace")
|
||||||
buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace")
|
buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace")
|
||||||
buf += b"\n\n\n"
|
buf += b"\n\n\n"
|
||||||
buf += LINE_SPACING_DEFAULT
|
buf += PARTIAL_CUT
|
||||||
buf += EMPH_OFF
|
|
||||||
buf += FEED + b'\x05'
|
|
||||||
buf += CUT
|
|
||||||
|
|
||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|||||||
@@ -1243,45 +1243,3 @@
|
|||||||
.summary-strip { grid-template-columns: 1fr; }
|
.summary-strip { grid-template-columns: 1fr; }
|
||||||
.finance-grid { 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,8 +1186,6 @@
|
|||||||
.badge--green { background: rgba(34,197,94,0.15); color: var(--color-success); }
|
.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--yellow { background: rgba(234,179,8,0.15); color: #eab308; }
|
||||||
.badge--purple { background: rgba(168,85,247,0.15); color: #a855f7; }
|
.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 */
|
/* Toast notification */
|
||||||
.cfg-toast {
|
.cfg-toast {
|
||||||
|
|||||||
@@ -1209,16 +1209,6 @@
|
|||||||
color: #000;
|
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 {
|
.action-btn__icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -117,16 +117,6 @@
|
|||||||
.empty-state__action {
|
.empty-state__action {
|
||||||
margin-top: var(--space-2, 0.5rem);
|
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
|
4. UNIFIED INPUT FOCUS RING
|
||||||
|
|||||||
@@ -713,23 +713,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pago-tabs {
|
.pago-tabs {
|
||||||
display: grid;
|
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
|
||||||
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 {
|
.pago-tab {
|
||||||
padding: var(--space-3) var(--space-2); font-family: var(--font-body);
|
padding: var(--space-3) var(--space-5); font-family: var(--font-body);
|
||||||
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
|
font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
|
||||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
background: transparent; border: none; color: var(--color-text-muted);
|
||||||
border-radius: var(--radius-md); color: var(--color-text-muted);
|
cursor: pointer; border-bottom: 2px solid transparent;
|
||||||
cursor: pointer; transition: var(--transition-fast);
|
margin-bottom: -2px; transition: var(--transition-fast);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; gap: var(--space-2);
|
||||||
text-align: center; min-height: 44px;
|
|
||||||
}
|
}
|
||||||
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
|
.pago-tab:hover { color: var(--color-text-primary); }
|
||||||
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
|
||||||
|
|
||||||
.tab-content { padding: var(--space-6); display: none; }
|
.tab-content { padding: var(--space-6); display: none; }
|
||||||
.tab-content.active { display: block; }
|
.tab-content.active { display: block; }
|
||||||
@@ -906,10 +901,8 @@
|
|||||||
font-family: 'Courier New', 'Consolas', monospace;
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
font-size: 11px; line-height: 1.4; padding: 12px; text-align: left;
|
font-size: 11px; line-height: 1.4; padding: 12px; text-align: left;
|
||||||
border: 1px dashed #ccc;
|
border: 1px dashed #ccc;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
.ticket-80 { width: 80mm; max-width: 80mm; }
|
.ticket-80 { width: 302px; }
|
||||||
.ticket-58 { width: 48mm; max-width: 48mm; }
|
|
||||||
.ticket .store-name { font-size: 14px; font-weight: bold; text-align: center; margin-bottom: 2px; }
|
.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-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; }
|
.ticket .store-info { font-size: 9px; text-align: center; color: #333; margin-bottom: 6px; line-height: 1.3; }
|
||||||
@@ -928,26 +921,17 @@
|
|||||||
gap: 8px; align-items: baseline; font-size: 10px; margin-bottom: 3px;
|
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 .qty { font-weight: bold; min-width: 24px; text-align: right; }
|
||||||
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: break-word; }
|
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ticket-80 .item-line-wide .price { text-align: right; min-width: 55px; }
|
.ticket-80 .item-line-wide .price { text-align: right; min-width: 55px; }
|
||||||
.ticket-80 .item-line-wide .subtotal { text-align: right; font-weight: bold; min-width: 60px; }
|
.ticket-80 .item-line-wide .subtotal { text-align: right; font-weight: bold; min-width: 60px; }
|
||||||
.ticket-line { page-break-inside: avoid; }
|
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@page { margin: 0; size: 72mm auto; }
|
body * { display: none !important; }
|
||||||
html, body { height: auto !important; overflow: visible !important; }
|
.ticket-print-area, .ticket-print-area * { display: block !important; }
|
||||||
body > * { display: none !important; }
|
.ticket-print-area { position: fixed; top: 0; left: 0; }
|
||||||
.ticket-print-area,
|
.ticket { border: none; box-shadow: none; padding: 4px; }
|
||||||
.ticket-print-area * { display: block !important; }
|
.ticket .item-line-wide { display: grid !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 .ticket-row, .ticket .folio-line, .ticket .total-line { display: flex !important; }
|
||||||
.ticket .name { white-space: normal !important; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
/* Remission notes page — matches the Nexus POS design system */
|
|
||||||
|
|
||||||
/* Scrollable page content area */
|
|
||||||
.page-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--space-5) var(--space-6);
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
|
||||||
}
|
|
||||||
.page-content::-webkit-scrollbar { width: 6px; }
|
|
||||||
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
|
||||||
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
|
|
||||||
|
|
||||||
/* Filters card */
|
|
||||||
.filters-card {
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-4);
|
|
||||||
margin-bottom: var(--space-5);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
[data-theme="modern"] .filters-card {
|
|
||||||
background: var(--color-bg-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date input reuse select-filter styling */
|
|
||||||
.select-filter[type="date"] {
|
|
||||||
padding: 0 var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badges for remission notes */
|
|
||||||
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
|
|
||||||
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
|
|
||||||
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
/* Action buttons in table */
|
|
||||||
.action-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: 0 var(--space-2);
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-caption);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.action-btn svg {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
stroke: currentColor;
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
.action-btn--ghost {
|
|
||||||
background: var(--btn-ghost-bg);
|
|
||||||
color: var(--btn-ghost-text);
|
|
||||||
border-color: var(--btn-ghost-border);
|
|
||||||
}
|
|
||||||
.action-btn--ghost:hover {
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border-color: var(--color-border-strong);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
.action-btn--primary {
|
|
||||||
background: var(--btn-primary-bg);
|
|
||||||
color: var(--btn-primary-text);
|
|
||||||
border-color: var(--btn-primary-border);
|
|
||||||
}
|
|
||||||
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.empty-state {
|
|
||||||
padding: var(--space-10) var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: var(--z-modal, 1050);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
.modal-overlay.is-open { display: flex; }
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
[data-theme="modern"] .modal {
|
|
||||||
background: var(--color-bg-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--space-4) var(--space-5);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.modal__title {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: var(--text-h6);
|
|
||||||
font-weight: var(--heading-weight-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.modal__close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
padding: var(--space-1);
|
|
||||||
}
|
|
||||||
.modal__close:hover { color: var(--color-text-primary); }
|
|
||||||
|
|
||||||
.modal__body {
|
|
||||||
padding: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4) var(--space-5);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ticket preview (monospace) */
|
|
||||||
.ticket-preview {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
.ticket-preview__center { text-align: center; }
|
|
||||||
.ticket-preview__bold { font-weight: 700; }
|
|
||||||
.ticket-preview__line {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.ticket-preview__divider {
|
|
||||||
border-top: 1px dashed var(--color-border-strong);
|
|
||||||
margin: var(--space-3) 0;
|
|
||||||
}
|
|
||||||
.ticket-preview__items { margin: var(--space-3) 0; }
|
|
||||||
.ticket-preview__item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.ticket-preview__footer {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.page-content {
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.search-box, .select-filter {
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.toolbar__spacer { display: none; }
|
|
||||||
}
|
|
||||||
@@ -767,257 +767,3 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
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,9 +1,9 @@
|
|||||||
|
// /home/Autopartes/pos/static/js/accounting.js
|
||||||
// Accounting module — wired to design-system HTML IDs
|
// Accounting module — wired to design-system HTML IDs
|
||||||
// Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre
|
// Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre
|
||||||
|
|
||||||
const Accounting = (() => {
|
const Accounting = (() => {
|
||||||
const API = '/pos/api/accounting';
|
const API = '/pos/api/accounting';
|
||||||
let accountsList = [];
|
|
||||||
|
|
||||||
function token() {
|
function token() {
|
||||||
return localStorage.getItem('pos_token') || '';
|
return localStorage.getItem('pos_token') || '';
|
||||||
@@ -14,8 +14,7 @@ const Accounting = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const url = path.startsWith('/pos/api/') ? path : `${API}${path}`;
|
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
||||||
const res = await fetch(url, { headers: headers(), ...opts });
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
throw new Error(err.error || 'Request failed');
|
throw new Error(err.error || 'Request failed');
|
||||||
@@ -26,12 +25,6 @@ const Accounting = (() => {
|
|||||||
function fmt(n) {
|
function fmt(n) {
|
||||||
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
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 ----
|
// ---- Auth check ----
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
@@ -42,30 +35,6 @@ const Accounting = (() => {
|
|||||||
return true;
|
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')") ----
|
// ---- Tab switching (matches design system onclick="switchTab('xxx')") ----
|
||||||
function switchTab(name) {
|
function switchTab(name) {
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
@@ -121,36 +90,25 @@ const Accounting = (() => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/aging');
|
const res = await api('/aging');
|
||||||
let rows = res.data || [];
|
const 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) {
|
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>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = rows.map(r => {
|
tbody.innerHTML = rows.map(r => {
|
||||||
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
|
const 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 label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
|
||||||
const actionLabel = r.balance > 0 ? 'Cobrar' : 'Ver';
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
||||||
<td class="td--primary">${r.customer_name || r.name || '-'}</td>
|
<td class="td--primary">${r.name || r.customer_name || '-'}</td>
|
||||||
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
|
<td>${r.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>${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.total)}</td>
|
||||||
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
||||||
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
||||||
<td>${statusBadge(status, label)}</td>
|
<td>${statusBadge(status, label)}</td>
|
||||||
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.showReceivableDetail(${r.sale_id})">${esc(actionLabel)}</button></td>
|
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -165,90 +123,6 @@ const Accounting = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Receivable detail / cancel ticket ----
|
|
||||||
async function showReceivableDetail(saleId) {
|
|
||||||
try {
|
|
||||||
const sale = await api('/pos/api/sales/' + saleId);
|
|
||||||
if (!sale || sale.error) {
|
|
||||||
alert('No se pudo cargar el detalle de la venta');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const itemsHtml = (sale.items || []).map(function (item) {
|
|
||||||
return '<tr>' +
|
|
||||||
'<td>' + esc(item.part_number || '-') + '</td>' +
|
|
||||||
'<td>' + esc(item.name) + '</td>' +
|
|
||||||
'<td style="text-align:right">' + item.quantity + '</td>' +
|
|
||||||
'<td style="text-align:right">$' + fmt(item.unit_price) + '</td>' +
|
|
||||||
'<td style="text-align:right">$' + fmt(item.subtotal) + '</td>' +
|
|
||||||
'</tr>';
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const paid = (sale.payments || []).reduce(function (sum, p) { return sum + (p.amount || 0); }, 0) + (sale.amount_paid || 0);
|
|
||||||
const balance = (sale.total || 0) - paid;
|
|
||||||
const canCancel = sale.status !== 'cancelled' && balance > 0;
|
|
||||||
|
|
||||||
const html = '<div class="modal-overlay open" id="receivableDetailOverlay" style="z-index:2000;">' +
|
|
||||||
'<div class="modal-pago" style="max-width:600px;width:90%;max-height:90vh;overflow:auto;">' +
|
|
||||||
'<div class="modal-header"><h3>Detalle de Venta a Crédito</h3>' +
|
|
||||||
'<button class="modal-close" onclick="Accounting.closeReceivableDetail()">✕</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 ----
|
// ---- Tab 2: Cuentas por Pagar ----
|
||||||
async function loadAccountsPayable() {
|
async function loadAccountsPayable() {
|
||||||
const panel = document.getElementById('panel-cxp');
|
const panel = document.getElementById('panel-cxp');
|
||||||
@@ -259,35 +133,25 @@ const Accounting = (() => {
|
|||||||
try {
|
try {
|
||||||
// Use accounts endpoint filtered for payables or a dedicated endpoint
|
// Use accounts endpoint filtered for payables or a dedicated endpoint
|
||||||
const res = await api('/aging?type=payable');
|
const res = await api('/aging?type=payable');
|
||||||
let rows = res.data || [];
|
const 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) {
|
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>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = rows.map(r => {
|
tbody.innerHTML = rows.map(r => {
|
||||||
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
|
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
|
||||||
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Pendiente');
|
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
||||||
<td class="td--primary">${r.vendor_name || r.name || '-'}</td>
|
<td class="td--primary">${r.name || r.vendor_name || '-'}</td>
|
||||||
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
|
<td>${r.receipt_date ? new Date(r.receipt_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||||
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
|
<td>${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.total)}</td>
|
||||||
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
||||||
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
||||||
<td>${statusBadge(status, label)}</td>
|
<td>${statusBadge(status, label)}</td>
|
||||||
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.registerPayablePayment(${r.id || 0})">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
|
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -520,8 +384,6 @@ const Accounting = (() => {
|
|||||||
loadSummaryCards();
|
loadSummaryCards();
|
||||||
// Load initial tab data (cxc is active by default)
|
// Load initial tab data (cxc is active by default)
|
||||||
loadAging();
|
loadAging();
|
||||||
// Preload chart of accounts for the manual entry modal
|
|
||||||
loadChartOfAccounts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
@@ -561,27 +423,6 @@ const Accounting = (() => {
|
|||||||
URL.revokeObjectURL(url);
|
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 ----
|
// ---- Nueva Poliza modal ----
|
||||||
function showNewEntryModal() {
|
function showNewEntryModal() {
|
||||||
const overlay = document.getElementById('newEntryModalOverlay');
|
const overlay = document.getElementById('newEntryModalOverlay');
|
||||||
@@ -607,20 +448,13 @@ const Accounting = (() => {
|
|||||||
line.className = 'entry-line';
|
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.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 =
|
line.innerHTML =
|
||||||
accountSelectHtml() +
|
'<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
|
||||||
'<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
|
'<input type="number" placeholder="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);" />' +
|
'<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>';
|
'<button class="btn btn--ghost btn--sm" onclick="this.closest(\'.entry-line\').remove()">×</button>';
|
||||||
container.appendChild(line);
|
container.appendChild(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateEntryAccountSelects() {
|
|
||||||
document.querySelectorAll('#entryLines .entry-account').forEach(sel => {
|
|
||||||
const selected = sel.value;
|
|
||||||
sel.outerHTML = accountSelectHtml(selected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitNewEntry() {
|
async function submitNewEntry() {
|
||||||
const date = document.getElementById('entryDate').value;
|
const date = document.getElementById('entryDate').value;
|
||||||
const type = document.getElementById('entryType').value;
|
const type = document.getElementById('entryType').value;
|
||||||
@@ -634,16 +468,16 @@ const Accounting = (() => {
|
|||||||
|
|
||||||
const lines = [];
|
const lines = [];
|
||||||
document.querySelectorAll('#entryLines .entry-line').forEach(row => {
|
document.querySelectorAll('#entryLines .entry-line').forEach(row => {
|
||||||
const accountId = row.querySelector('.entry-account').value;
|
const account = row.querySelector('.entry-account').value.trim();
|
||||||
const debit = parseFloat(row.querySelector('.entry-debit').value) || 0;
|
const debit = parseFloat(row.querySelector('.entry-debit').value) || 0;
|
||||||
const credit = parseFloat(row.querySelector('.entry-credit').value) || 0;
|
const credit = parseFloat(row.querySelector('.entry-credit').value) || 0;
|
||||||
if (accountId && (debit || credit)) {
|
if (account && (debit || credit)) {
|
||||||
lines.push({ account_id: parseInt(accountId, 10), debit, credit });
|
lines.push({ account, debit, credit });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (lines.length < 2) {
|
if (!lines.length) {
|
||||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos dos partidas.</span>';
|
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos una partida.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,23 +495,17 @@ const Accounting = (() => {
|
|||||||
|
|
||||||
// Expose switchTab globally for onclick handlers in HTML
|
// Expose switchTab globally for onclick handlers in HTML
|
||||||
window.switchTab = switchTab;
|
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.exportarContabilidad = exportarContabilidad;
|
||||||
window.exportarCuentasPorCobrar = exportarCuentasPorCobrar;
|
|
||||||
window.showNewEntryModal = showNewEntryModal;
|
window.showNewEntryModal = showNewEntryModal;
|
||||||
window.closeNewEntryModal = closeNewEntryModal;
|
window.closeNewEntryModal = closeNewEntryModal;
|
||||||
window.addEntryLine = addEntryLine;
|
window.addEntryLine = addEntryLine;
|
||||||
window.submitNewEntry = submitNewEntry;
|
window.submitNewEntry = submitNewEntry;
|
||||||
|
|
||||||
|
return {
|
||||||
|
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
||||||
|
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
||||||
|
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
|
||||||
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||||
@@ -686,13 +514,4 @@ const Accounting = (() => {
|
|||||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Accounting = {
|
|
||||||
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
|
||||||
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
|
||||||
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
|
|
||||||
showReceivableDetail, closeReceivableDetail, cancelReceivable,
|
|
||||||
registerPayablePayment, runPeriodClose,
|
|
||||||
};
|
|
||||||
|
|
||||||
return window.Accounting;
|
|
||||||
})();
|
})();
|
||||||
1
pos/static/js/accounting.min.js
vendored
Normal file
1
pos/static/js/accounting.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
|||||||
// /home/Autopartes/pos/static/js/api.js
|
|
||||||
// Minimal shared API helper used by standalone pages (e.g. historical_sales).
|
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
|
||||||
const token = localStorage.getItem('pos_token') || '';
|
|
||||||
const url = path.startsWith('http') ? path : path;
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Authorization': token ? 'Bearer ' + token : '',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers || {})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
window.location.href = '/pos/login';
|
|
||||||
throw new Error('Sesión expirada');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error || data.message || 'Error ' + res.status);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
@@ -43,8 +43,7 @@
|
|||||||
var _t = typeof window.t === 'function' ? window.t : function(k) { return k; };
|
var _t = typeof window.t === 'function' ? window.t : function(k) { return k; };
|
||||||
var roleLabels = {
|
var roleLabels = {
|
||||||
'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'),
|
'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 roleLabel = roleLabels[role] || role;
|
||||||
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
|
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
|
||||||
@@ -118,7 +117,6 @@
|
|||||||
localStorage.removeItem('pos_employee');
|
localStorage.removeItem('pos_employee');
|
||||||
localStorage.removeItem('pos_tenant_id');
|
localStorage.removeItem('pos_tenant_id');
|
||||||
localStorage.removeItem('pos_cart');
|
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';
|
window.location.href = '/pos/login';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,147 +180,6 @@
|
|||||||
permissions: payload.permissions || []
|
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 ───
|
// ─── Preload enabled modules for sidebar filtering ───
|
||||||
try {
|
try {
|
||||||
fetch('/pos/api/config/modules', {
|
fetch('/pos/api/config/modules', {
|
||||||
@@ -340,16 +197,6 @@
|
|||||||
}).catch(function() {});
|
}).catch(function() {});
|
||||||
} catch(e) {}
|
} 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 ───
|
// ─── Service Worker update handler ───
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.addEventListener('message', function (event) {
|
navigator.serviceWorker.addEventListener('message', function (event) {
|
||||||
@@ -360,21 +207,4 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Global toast utility ───
|
|
||||||
window.toast = function(msg, type) {
|
|
||||||
type = type || 'success';
|
|
||||||
var bg = type === 'error' ? '#d32f2f' : '#388e3c';
|
|
||||||
var el = document.createElement('div');
|
|
||||||
el.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:99999;padding:12px 20px;border-radius:8px;background:' + bg + ';color:#fff;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.2);opacity:0;transition:opacity .3s;';
|
|
||||||
el.textContent = msg;
|
|
||||||
document.body.appendChild(el);
|
|
||||||
// trigger reflow
|
|
||||||
el.offsetHeight;
|
|
||||||
el.style.opacity = '1';
|
|
||||||
setTimeout(function() {
|
|
||||||
el.style.opacity = '0';
|
|
||||||
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
const Config = (() => {
|
const Config = (() => {
|
||||||
const API = '/pos/api/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)
|
// Cache for branches (used by employee modal selector)
|
||||||
let _branches = [];
|
let _branches = [];
|
||||||
|
|
||||||
@@ -58,7 +55,7 @@ const Config = (() => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Theme switcher
|
// Theme switcher
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function setTheme(theme) {
|
/*function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
|
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
|
||||||
|
|
||||||
@@ -73,7 +70,22 @@ const Config = (() => {
|
|||||||
var opts = document.querySelectorAll('.theme-option');
|
var opts = document.querySelectorAll('.theme-option');
|
||||||
if (opts[idx]) opts[idx].classList.add('is-selected');
|
if (opts[idx]) opts[idx].classList.add('is-selected');
|
||||||
}
|
}
|
||||||
window.setTheme = setTheme;
|
window.setTheme = setTheme;*/
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
if (window.posSetTheme) window.posSetTheme(theme);
|
||||||
|
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(function(btn) {
|
||||||
|
btn.classList.toggle('is-active', btn.dataset.themeTarget === theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.theme-option').forEach(function(opt) {
|
||||||
|
opt.classList.remove('is-selected');
|
||||||
|
});
|
||||||
|
var idx = theme === 'industrial' ? 0 : 1;
|
||||||
|
var opts = document.querySelectorAll('.theme-option');
|
||||||
|
if (opts[idx]) opts[idx].classList.add('is-selected');
|
||||||
|
}
|
||||||
|
|
||||||
function selectThemeOption(theme) {
|
function selectThemeOption(theme) {
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
@@ -108,22 +120,16 @@ const Config = (() => {
|
|||||||
owner: 'Dueno',
|
owner: 'Dueno',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
cashier: 'Cajero',
|
cashier: 'Cajero',
|
||||||
counter: 'Mostrador',
|
|
||||||
warehouse: 'Almacenista',
|
warehouse: 'Almacenista',
|
||||||
accountant: 'Contador',
|
accountant: 'Contador'
|
||||||
workshop: 'Taller',
|
|
||||||
mechanic: 'Mecanico'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var ROLE_BADGE = {
|
var ROLE_BADGE = {
|
||||||
owner: 'badge--owner',
|
owner: 'badge--owner',
|
||||||
admin: 'badge--blue',
|
admin: 'badge--blue',
|
||||||
cashier: 'badge--green',
|
cashier: 'badge--green',
|
||||||
counter: 'badge--gray',
|
|
||||||
warehouse: 'badge--yellow',
|
warehouse: 'badge--yellow',
|
||||||
accountant: 'badge--purple',
|
accountant: 'badge--purple'
|
||||||
workshop: 'badge--orange',
|
|
||||||
mechanic: 'badge--teal'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function roleBadge(role) {
|
function roleBadge(role) {
|
||||||
@@ -188,7 +194,6 @@ const Config = (() => {
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="device-card__actions">'
|
+ '<div class="device-card__actions">'
|
||||||
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editBranch(' + b.id + ')">Editar</button>'
|
+ '<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>';
|
+ '</div></div>';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,22 +249,6 @@ const Config = (() => {
|
|||||||
openBranchModal(b);
|
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) {
|
async function saveBranch(data) {
|
||||||
var branchId = document.getElementById('branch-id').value;
|
var branchId = document.getElementById('branch-id').value;
|
||||||
var url = API + '/branches' + (branchId ? '/' + branchId : '');
|
var url = API + '/branches' + (branchId ? '/' + branchId : '');
|
||||||
@@ -320,10 +309,7 @@ const Config = (() => {
|
|||||||
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
|
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
|
||||||
+ '<td>' + statusBadge + '</td>'
|
+ '<td>' + statusBadge + '</td>'
|
||||||
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
|
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
|
||||||
+ '<td>'
|
+ '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></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>';
|
+ '</tr>';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,7 +319,7 @@ const Config = (() => {
|
|||||||
|
|
||||||
async function saveEmployee(data) {
|
async function saveEmployee(data) {
|
||||||
// Check if we're editing (modal has editId) or creating
|
// Check if we're editing (modal has editId) or creating
|
||||||
var modal = document.getElementById('modal-employee');
|
var modal = document.getElementById('employee-modal');
|
||||||
var editId = modal ? modal.dataset.editId : null;
|
var editId = modal ? modal.dataset.editId : null;
|
||||||
var url = API + '/employees';
|
var url = API + '/employees';
|
||||||
var method = 'POST';
|
var method = 'POST';
|
||||||
@@ -387,21 +373,6 @@ const Config = (() => {
|
|||||||
return el ? el.value.trim() : '';
|
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) {
|
async function editEmployee(empId) {
|
||||||
if (!checkAuth()) return;
|
if (!checkAuth()) return;
|
||||||
// Find the employee in the loaded data by re-fetching
|
// Find the employee in the loaded data by re-fetching
|
||||||
@@ -412,25 +383,24 @@ const Config = (() => {
|
|||||||
var emp = (json.data || []).find(function(e) { return e.id === empId; });
|
var emp = (json.data || []).find(function(e) { return e.id === empId; });
|
||||||
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
|
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
|
||||||
|
|
||||||
// Pre-fill the employee modal with existing data for editing
|
// Pre-fill the "new employee" modal with existing data for editing
|
||||||
setVal('emp-name', emp.name);
|
setVal('new-emp-name', emp.name);
|
||||||
setVal('emp-email', emp.email || '');
|
setVal('new-emp-email', emp.email || '');
|
||||||
setVal('emp-phone', emp.phone || '');
|
var roleSelect = document.getElementById('new-emp-role');
|
||||||
var roleSelect = document.getElementById('emp-role');
|
|
||||||
if (roleSelect) roleSelect.value = emp.role || 'cashier';
|
if (roleSelect) roleSelect.value = emp.role || 'cashier';
|
||||||
var branchSelect = document.getElementById('emp-branch');
|
var branchSelect = document.getElementById('new-emp-branch');
|
||||||
if (branchSelect) branchSelect.value = emp.branch_id || '';
|
if (branchSelect) branchSelect.value = emp.branch_id || '';
|
||||||
setVal('emp-discount', emp.max_discount_pct || '');
|
setVal('new-emp-discount', emp.max_discount_pct || '');
|
||||||
setVal('emp-pin', ''); // Don't pre-fill PIN for security
|
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
|
||||||
|
|
||||||
// Store the ID so saveEmployee knows it's an update
|
// Store the ID so saveEmployee knows it's an update
|
||||||
var modal = document.getElementById('modal-employee');
|
var modal = document.getElementById('employee-modal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.dataset.editId = empId;
|
modal.dataset.editId = empId;
|
||||||
var title = modal.querySelector('.modal-title, h3');
|
var title = modal.querySelector('.modal-title, h3');
|
||||||
if (title) title.textContent = 'Editar Empleado';
|
if (title) title.textContent = 'Editar Empleado';
|
||||||
}
|
}
|
||||||
openModal('modal-employee');
|
openModal('employee-modal');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast('Error: ' + e.message, 'error');
|
toast('Error: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -487,156 +457,6 @@ const Config = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Receipt / ticket customization
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
let _receiptLogo = '';
|
|
||||||
|
|
||||||
async function loadReceiptConfig() {
|
|
||||||
try {
|
|
||||||
var res = await fetch(API + '/receipt', { headers: headers() });
|
|
||||||
if (!res.ok) return;
|
|
||||||
var d = await res.json();
|
|
||||||
_receiptLogo = d.logo || '';
|
|
||||||
setVal('receipt-store-name', d.store_name);
|
|
||||||
setVal('receipt-tagline', d.tagline);
|
|
||||||
setVal('receipt-rfc', d.rfc);
|
|
||||||
setVal('receipt-address', d.address);
|
|
||||||
setVal('receipt-phone', d.phone);
|
|
||||||
setVal('receipt-paper-width', d.paper_width || '80');
|
|
||||||
setVal('receipt-thanks', d.thanks_message);
|
|
||||||
setVal('receipt-footer', d.footer);
|
|
||||||
setChecked('receipt-show-logo', d.show_logo);
|
|
||||||
setChecked('receipt-show-rfc', d.show_rfc);
|
|
||||||
setChecked('receipt-show-address', d.show_address);
|
|
||||||
setChecked('receipt-show-phone', d.show_phone);
|
|
||||||
setChecked('receipt-show-iva', d.show_iva_breakdown);
|
|
||||||
setChecked('receipt-show-payment', d.show_payment_details);
|
|
||||||
setChecked('receipt-show-employee', d.show_employee);
|
|
||||||
renderReceiptLogoThumb();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Config.loadReceiptConfig:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setChecked(id, v) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (el) el.checked = !!v;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChecked(id) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
return el ? el.checked : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReceiptLogoThumb() {
|
|
||||||
var thumb = document.getElementById('receipt-logo-thumb');
|
|
||||||
var removeBtn = document.getElementById('receipt-logo-remove');
|
|
||||||
if (!thumb) return;
|
|
||||||
if (_receiptLogo) {
|
|
||||||
thumb.innerHTML = '<img src="' + escapeHtml(_receiptLogo) + '" style="max-width:100%;max-height:100%;object-fit:contain;" alt="Logo ticket">';
|
|
||||||
if (removeBtn) removeBtn.style.display = '';
|
|
||||||
} else {
|
|
||||||
thumb.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>';
|
|
||||||
if (removeBtn) removeBtn.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReceiptLogo(input) {
|
|
||||||
var file = input && input.files ? input.files[0] : null;
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.match(/^image\/(png|jpeg|jpg|webp)$/)) {
|
|
||||||
toast('Solo se permiten imágenes PNG, JPG o WebP', 'error');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function(e) {
|
|
||||||
var img = new Image();
|
|
||||||
img.onload = function() {
|
|
||||||
var maxWidth = 300;
|
|
||||||
var scale = Math.min(1, maxWidth / img.width);
|
|
||||||
var w = Math.round(img.width * scale);
|
|
||||||
var h = Math.round(img.height * scale);
|
|
||||||
var canvas = document.createElement('canvas');
|
|
||||||
canvas.width = w;
|
|
||||||
canvas.height = h;
|
|
||||||
var ctx = canvas.getContext('2d');
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
|
||||||
ctx.drawImage(img, 0, 0, w, h);
|
|
||||||
_receiptLogo = canvas.toDataURL('image/jpeg', 0.85);
|
|
||||||
renderReceiptLogoThumb();
|
|
||||||
toast('Logo cargado. Guarda los cambios para aplicarlo.', 'ok');
|
|
||||||
};
|
|
||||||
img.src = e.target.result;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeReceiptLogo() {
|
|
||||||
_receiptLogo = '';
|
|
||||||
renderReceiptLogoThumb();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveReceiptConfig() {
|
|
||||||
if (!checkAuth()) return;
|
|
||||||
var data = {
|
|
||||||
logo: _receiptLogo,
|
|
||||||
store_name: getVal('receipt-store-name'),
|
|
||||||
tagline: getVal('receipt-tagline'),
|
|
||||||
rfc: getVal('receipt-rfc'),
|
|
||||||
address: getVal('receipt-address'),
|
|
||||||
phone: getVal('receipt-phone'),
|
|
||||||
paper_width: getVal('receipt-paper-width') || '80',
|
|
||||||
thanks_message: getVal('receipt-thanks'),
|
|
||||||
footer: getVal('receipt-footer'),
|
|
||||||
show_logo: getChecked('receipt-show-logo'),
|
|
||||||
show_rfc: getChecked('receipt-show-rfc'),
|
|
||||||
show_address: getChecked('receipt-show-address'),
|
|
||||||
show_phone: getChecked('receipt-show-phone'),
|
|
||||||
show_iva_breakdown: getChecked('receipt-show-iva'),
|
|
||||||
show_payment_details: getChecked('receipt-show-payment'),
|
|
||||||
show_employee: getChecked('receipt-show-employee'),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
var res = await fetch(API + '/receipt', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: headers(),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
|
||||||
throw new Error(err.error || 'Error al guardar');
|
|
||||||
}
|
|
||||||
toast('Configuración de ticket guardada', 'ok');
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAll() {
|
|
||||||
if (!checkAuth()) return;
|
|
||||||
var btn = document.getElementById('btn-save-all');
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
|
||||||
try {
|
|
||||||
await saveBusiness();
|
|
||||||
await saveTaxParams();
|
|
||||||
await saveCurrency();
|
|
||||||
await saveVehicleCompatSource();
|
|
||||||
await saveAllowedBrands();
|
|
||||||
await saveModules();
|
|
||||||
await saveSalesSettings();
|
|
||||||
await saveReceiptConfig();
|
|
||||||
toast('Configuración guardada', 'ok');
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message, 'error');
|
|
||||||
} finally {
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Cambios'; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Event bindings
|
// Event bindings
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -701,12 +521,6 @@ const Config = (() => {
|
|||||||
var btnNewEmp = document.getElementById('btn-new-employee');
|
var btnNewEmp = document.getElementById('btn-new-employee');
|
||||||
if (btnNewEmp) {
|
if (btnNewEmp) {
|
||||||
btnNewEmp.addEventListener('click', function() {
|
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');
|
openModal('modal-employee');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -730,7 +544,6 @@ const Config = (() => {
|
|||||||
|
|
||||||
btnSaveEmp.disabled = true;
|
btnSaveEmp.disabled = true;
|
||||||
btnSaveEmp.textContent = 'Guardando...';
|
btnSaveEmp.textContent = 'Guardando...';
|
||||||
var isEdit = !!document.getElementById('modal-employee').dataset.editId;
|
|
||||||
try {
|
try {
|
||||||
await saveEmployee({
|
await saveEmployee({
|
||||||
name: name,
|
name: name,
|
||||||
@@ -741,7 +554,7 @@ const Config = (() => {
|
|||||||
branch_id: branchId ? parseInt(branchId, 10) : null,
|
branch_id: branchId ? parseInt(branchId, 10) : null,
|
||||||
max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0
|
max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0
|
||||||
});
|
});
|
||||||
toast(isEdit ? 'Empleado actualizado' : 'Empleado creado');
|
toast('Empleado creado');
|
||||||
closeModal('modal-employee');
|
closeModal('modal-employee');
|
||||||
// Reset form
|
// Reset form
|
||||||
document.getElementById('emp-name').value = '';
|
document.getElementById('emp-name').value = '';
|
||||||
@@ -958,42 +771,25 @@ const Config = (() => {
|
|||||||
var cbMp = document.getElementById('cfg-module-marketplace');
|
var cbMp = document.getElementById('cfg-module-marketplace');
|
||||||
var cbMeli = document.getElementById('cfg-module-meli');
|
var cbMeli = document.getElementById('cfg-module-meli');
|
||||||
var cbCat = document.getElementById('cfg-module-catalog');
|
var cbCat = document.getElementById('cfg-module-catalog');
|
||||||
var cbWork = document.getElementById('cfg-module-workshop');
|
|
||||||
if (cbWa) cbWa.checked = data.whatsapp !== false;
|
if (cbWa) cbWa.checked = data.whatsapp !== false;
|
||||||
if (cbMp) cbMp.checked = data.marketplace !== false;
|
if (cbMp) cbMp.checked = data.marketplace !== false;
|
||||||
if (cbMeli) cbMeli.checked = data.meli !== false;
|
if (cbMeli) cbMeli.checked = data.meli !== false;
|
||||||
if (cbCat) cbCat.checked = data.catalog !== false;
|
if (cbCat) cbCat.checked = data.catalog !== false;
|
||||||
if (cbWork) cbWork.checked = data.workshop !== false;
|
|
||||||
localStorage.setItem('pos_modules', JSON.stringify(data));
|
localStorage.setItem('pos_modules', JSON.stringify(data));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Config.loadModules:', 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() {
|
async function saveModules() {
|
||||||
var cbWa = document.getElementById('cfg-module-whatsapp');
|
var btn = event.target;
|
||||||
var cbMp = document.getElementById('cfg-module-marketplace');
|
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||||
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 {
|
try {
|
||||||
var data = {
|
var data = {
|
||||||
whatsapp: cbWa ? cbWa.checked : true,
|
whatsapp: document.getElementById('cfg-module-whatsapp').checked,
|
||||||
marketplace: cbMp ? cbMp.checked : true,
|
marketplace: document.getElementById('cfg-module-marketplace').checked,
|
||||||
meli: cbMeli ? cbMeli.checked : true,
|
meli: document.getElementById('cfg-module-meli').checked,
|
||||||
catalog: cbCat ? cbCat.checked : true,
|
catalog: document.getElementById('cfg-module-catalog').checked,
|
||||||
workshop: cbWork ? cbWork.checked : true,
|
|
||||||
};
|
};
|
||||||
var res = await fetch(API + '/modules', {
|
var res = await fetch(API + '/modules', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -1005,257 +801,14 @@ const Config = (() => {
|
|||||||
throw new Error(err.error || 'Save failed');
|
throw new Error(err.error || 'Save failed');
|
||||||
}
|
}
|
||||||
localStorage.setItem('pos_modules', JSON.stringify(data));
|
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');
|
toast('Módulos actualizados');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
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
|
// Init
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -1277,10 +830,6 @@ const Config = (() => {
|
|||||||
// Bind UI events
|
// Bind UI events
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
|
||||||
// Global save button
|
|
||||||
var btnSaveAll = document.getElementById('btn-save-all');
|
|
||||||
if (btnSaveAll) btnSaveAll.addEventListener('click', saveAll);
|
|
||||||
|
|
||||||
// Vehicle compat source save button
|
// Vehicle compat source save button
|
||||||
var btnCompat = document.getElementById('btn-save-compat-source');
|
var btnCompat = document.getElementById('btn-save-compat-source');
|
||||||
if (btnCompat) {
|
if (btnCompat) {
|
||||||
@@ -1308,11 +857,6 @@ const Config = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show permissions tab only for owner/admin
|
|
||||||
var isAdmin = user.role === 'owner' || user.role === 'admin';
|
|
||||||
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
|
|
||||||
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
|
|
||||||
|
|
||||||
// Load real data in parallel
|
// Load real data in parallel
|
||||||
loadBranches();
|
loadBranches();
|
||||||
loadEmployees();
|
loadEmployees();
|
||||||
@@ -1321,24 +865,18 @@ const Config = (() => {
|
|||||||
loadVehicleCompatSource();
|
loadVehicleCompatSource();
|
||||||
loadAllowedBrands();
|
loadAllowedBrands();
|
||||||
loadModules();
|
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);
|
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
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||||
@@ -1347,18 +885,4 @@ const Config = (() => {
|
|||||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
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,20 +5,12 @@
|
|||||||
* Wired to the design-system HTML (customers.html).
|
* Wired to the design-system HTML (customers.html).
|
||||||
*/
|
*/
|
||||||
const Customers = (() => {
|
const Customers = (() => {
|
||||||
|
let token = localStorage.getItem('pos_token') || '';
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let currentCustomer = null;
|
let currentCustomer = null;
|
||||||
let searchTimeout = 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', {
|
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
|
||||||
minimumFractionDigits: 2, maximumFractionDigits: 2
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||||
});
|
});
|
||||||
@@ -31,7 +23,7 @@ const Customers = (() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(url, options = {}) {
|
async function api(url, options = {}) {
|
||||||
@@ -51,9 +43,6 @@ const Customers = (() => {
|
|||||||
const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' };
|
const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' };
|
||||||
|
|
||||||
function statusBadge(c) {
|
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
|
// 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) {
|
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>';
|
return '<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>';
|
||||||
@@ -93,27 +82,14 @@ const Customers = (() => {
|
|||||||
const searchEl = document.getElementById('searchInput');
|
const searchEl = document.getElementById('searchInput');
|
||||||
q = q !== undefined ? q : (searchEl ? searchEl.value || '' : '');
|
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 {
|
try {
|
||||||
const params = new URLSearchParams({ page, per_page: 50 });
|
const params = new URLSearchParams({ page, per_page: 50 });
|
||||||
if (q) params.append('q', q);
|
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}`);
|
const data = await api(`/pos/api/customers?${params}`);
|
||||||
renderTable(data.data || []);
|
renderTable(data.data || []);
|
||||||
renderPagination(data.pagination || {});
|
renderPagination(data.pagination || {});
|
||||||
} catch (e) {
|
} 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);
|
console.error('Load customers failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +136,7 @@ const Customers = (() => {
|
|||||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
|
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',
|
title: 'Sin clientes',
|
||||||
subtitle: 'No se encontraron clientes registrados.',
|
subtitle: 'No se encontraron clientes registrados.',
|
||||||
action: '<button class="btn btn--primary btn--sm" onclick="Customers.showCreateModal()">Nuevo cliente</button>'
|
action: '<button class="btn btn--primary btn--sm" onclick="Customers.openCreateModal()">Nuevo cliente</button>'
|
||||||
}) + '</td></tr>';
|
}) + '</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,28 +279,6 @@ const Customers = (() => {
|
|||||||
const discountEl = document.getElementById('detailMaxDiscount');
|
const discountEl = document.getElementById('detailMaxDiscount');
|
||||||
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
|
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
|
// Re-wire action buttons after detail panel is visible
|
||||||
wireActionButtons();
|
wireActionButtons();
|
||||||
|
|
||||||
@@ -426,106 +380,15 @@ const Customers = (() => {
|
|||||||
// Wire action buttons in detail panel
|
// Wire action buttons in detail panel
|
||||||
function wireActionButtons() {
|
function wireActionButtons() {
|
||||||
const btns = document.querySelectorAll('.quick-actions .action-btn');
|
const btns = document.querySelectorAll('.quick-actions .action-btn');
|
||||||
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial, Eliminar
|
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial
|
||||||
if (btns.length >= 1) btns[0].onclick = () => {
|
if (btns.length >= 1) btns[0].onclick = () => {
|
||||||
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
|
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
|
||||||
};
|
};
|
||||||
if (btns.length >= 2) btns[1].onclick = () => editCurrent();
|
if (btns.length >= 2) btns[1].onclick = () => editCurrent();
|
||||||
if (btns.length >= 3) btns[2].onclick = () => showStatement();
|
if (btns.length >= 3) btns[2].onclick = () => showStatement();
|
||||||
if (btns.length >= 4) btns[3].onclick = () => {
|
if (btns.length >= 4) btns[3].onclick = () => {
|
||||||
if (currentCustomer) showCustomerHistory(currentCustomer.id);
|
if (currentCustomer) selectCustomer(currentCustomer.id);
|
||||||
};
|
};
|
||||||
const btnDelete = document.getElementById('btnDeleteCustomer');
|
|
||||||
if (btnDelete) btnDelete.onclick = () => deleteCustomer();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCustomer() {
|
|
||||||
if (!currentCustomer) return;
|
|
||||||
if (!canDeleteCustomer) {
|
|
||||||
alert('No tienes permiso para eliminar clientes');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`¿Eliminar al cliente "${currentCustomer.name}"? Se borrará completamente. Las ventas, órdenes y vehículos conservarán sus datos pero quedarán sin cliente asignado.`)) return;
|
|
||||||
try {
|
|
||||||
await api(`/pos/api/customers/${currentCustomer.id}`, { method: 'DELETE' });
|
|
||||||
alert('Cliente eliminado');
|
|
||||||
currentCustomer = null;
|
|
||||||
closeDetail();
|
|
||||||
loadCustomers(currentPage);
|
|
||||||
} catch (e) {
|
|
||||||
alert('Error: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.deleteCustomer = deleteCustomer;
|
|
||||||
|
|
||||||
async function showCustomerHistory(customerId) {
|
|
||||||
try {
|
|
||||||
const res = await api(`/pos/api/customers/${customerId}/purchases`);
|
|
||||||
const purchases = res.data || [];
|
|
||||||
let modal = document.getElementById('customerHistoryModal');
|
|
||||||
if (!modal) {
|
|
||||||
modal = document.createElement('div');
|
|
||||||
modal.id = 'customerHistoryModal';
|
|
||||||
modal.className = 'modal-overlay';
|
|
||||||
modal.style.display = 'none';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal-content" style="max-width:700px;">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Historial de Compras — <span id="customerHistoryName"></span></h3>
|
|
||||||
<button class="modal-close" onclick="Customers.closeCustomerHistoryModal()">×</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 ───────────────
|
// ─── Create/Edit Modal ───────────────
|
||||||
@@ -717,7 +580,7 @@ const Customers = (() => {
|
|||||||
try {
|
try {
|
||||||
await api(`/pos/api/customers/${currentCustomer.id}/payment`, {
|
await api(`/pos/api/customers/${currentCustomer.id}/payment`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ amount, payment_method: method, reference }),
|
body: JSON.stringify({ amount, method, reference }),
|
||||||
});
|
});
|
||||||
closePayment();
|
closePayment();
|
||||||
selectCustomer(currentCustomer.id);
|
selectCustomer(currentCustomer.id);
|
||||||
@@ -736,7 +599,7 @@ const Customers = (() => {
|
|||||||
// ─── Init ────────────────────────────
|
// ─── Init ────────────────────────────
|
||||||
function init() {
|
function init() {
|
||||||
// Auth check
|
// Auth check
|
||||||
if (!getToken()) {
|
if (!token) {
|
||||||
window.location.href = '/pos/login';
|
window.location.href = '/pos/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -956,18 +819,8 @@ const Customers = (() => {
|
|||||||
showCreateModal, editCurrent, editCustomer, closeModal, save,
|
showCreateModal, editCurrent, editCustomer, closeModal, save,
|
||||||
showStatement, closeStatement,
|
showStatement, closeStatement,
|
||||||
showPaymentModal, closePayment, recordPayment,
|
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
|
// Bulk selection
|
||||||
publicApi.toggleCustomerSelection = function(id) {
|
publicApi.toggleCustomerSelection = function(id) {
|
||||||
if (selectedCustomers.has(id)) selectedCustomers.delete(id);
|
if (selectedCustomers.has(id)) selectedCustomers.delete(id);
|
||||||
@@ -1003,15 +856,15 @@ const Customers = (() => {
|
|||||||
updateBulkToolbar();
|
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
|
// Expose globally for inline HTML onclick handlers
|
||||||
window.Customers = publicApi;
|
window.Customers = publicApi;
|
||||||
return 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: "📊" });
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const Dashboard = (() => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Theme switcher
|
// Theme switcher
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function setTheme(theme) {
|
/*function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
|
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
|
||||||
const btnInd = document.getElementById('btn-industrial');
|
const btnInd = document.getElementById('btn-industrial');
|
||||||
@@ -54,7 +54,15 @@ const Dashboard = (() => {
|
|||||||
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
|
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
|
||||||
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
|
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
|
||||||
}
|
}
|
||||||
window.setTheme = setTheme;
|
window.setTheme = setTheme;*/
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
if (window.posSetTheme) window.posSetTheme(theme);
|
||||||
|
const btnInd = document.getElementById('btn-industrial');
|
||||||
|
const btnMod = document.getElementById('btn-modern');
|
||||||
|
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
|
||||||
|
if (btnMod) btnMod.classList.toggle('active', theme === 'modern');
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Sidebar toggle (mobile)
|
// Sidebar toggle (mobile)
|
||||||
@@ -367,51 +375,16 @@ const Dashboard = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// 4. Credit alerts
|
// 4. Top Products (from today's sales detail)
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
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() {
|
async function loadTopProducts() {
|
||||||
// Single optimized endpoint: returns today's top products already aggregated
|
const today = todayStr();
|
||||||
const data = await apiFetch('/pos/api/dashboard/stats');
|
// Fetch all today's sales with pagination
|
||||||
|
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&status=completed&per_page=200`);
|
||||||
const container = document.getElementById('top-products-list');
|
const container = document.getElementById('top-products-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const top = data && data.top_products ? data.top_products : [];
|
if (!data || !data.data || data.data.length === 0) {
|
||||||
|
|
||||||
if (!top.length) {
|
|
||||||
container.innerHTML = renderEmptyState({
|
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>',
|
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',
|
title: 'Sin ventas hoy',
|
||||||
@@ -421,7 +394,37 @@ const Dashboard = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = top.slice(0, 5);
|
// Fetch detail for each sale to get items (up to 20 sales for performance)
|
||||||
|
const salesToFetch = data.data.slice(0, 20);
|
||||||
|
const details = await Promise.all(
|
||||||
|
salesToFetch.map(s => apiFetch(`/pos/api/sales/${s.id}`))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregate items
|
||||||
|
const productMap = {};
|
||||||
|
for (const sale of details) {
|
||||||
|
if (!sale || !sale.items) continue;
|
||||||
|
for (const item of sale.items) {
|
||||||
|
const key = item.part_number || item.name;
|
||||||
|
if (!productMap[key]) {
|
||||||
|
productMap[key] = { name: item.name, part_number: item.part_number || '', qty: 0, revenue: 0 };
|
||||||
|
}
|
||||||
|
productMap[key].qty += item.quantity || 0;
|
||||||
|
productMap[key].revenue += item.subtotal || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
container.innerHTML = renderEmptyState({
|
||||||
|
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
|
||||||
|
title: 'Sin productos vendidos',
|
||||||
|
subtitle: 'No hay suficiente información para mostrar el ranking.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maxRev = sorted[0].revenue || 1;
|
const maxRev = sorted[0].revenue || 1;
|
||||||
container.innerHTML = sorted.map((p, i) => {
|
container.innerHTML = sorted.map((p, i) => {
|
||||||
const pct = Math.round((p.revenue / maxRev) * 100);
|
const pct = Math.round((p.revenue / maxRev) * 100);
|
||||||
@@ -430,7 +433,7 @@ const Dashboard = (() => {
|
|||||||
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
|
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
|
||||||
<div class="rank-item__info">
|
<div class="rank-item__info">
|
||||||
<div class="rank-item__name">${escHtml(p.name)}</div>
|
<div class="rank-item__name">${escHtml(p.name)}</div>
|
||||||
<div class="rank-item__sub">${p.quantity} pzas vendidas</div>
|
<div class="rank-item__sub">${escHtml(p.part_number)} · ${p.qty} pzas vendidas</div>
|
||||||
<div class="rank-item__bar-bg">
|
<div class="rank-item__bar-bg">
|
||||||
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
|
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -624,12 +627,11 @@ const Dashboard = (() => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
async function loadRecentSales() {
|
async function loadRecentSales() {
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
const data = await apiFetch(`/pos/api/sales/recent?date_from=${today}&date_to=${today}&limit=10`);
|
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`);
|
||||||
const tbody = document.getElementById('recent-sales-tbody');
|
const tbody = document.getElementById('recent-sales-tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
const sales = data && data.data ? data.data : [];
|
if (!data || !data.data || data.data.length === 0) {
|
||||||
if (!sales.length) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({
|
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>',
|
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',
|
title: 'Sin ventas hoy',
|
||||||
@@ -639,22 +641,26 @@ const Dashboard = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const salesToShow = sales.slice(0, 5);
|
// Fetch items for first 5 sales
|
||||||
|
const salesToShow = data.data.slice(0, 5);
|
||||||
|
const details = await Promise.all(
|
||||||
|
salesToShow.map(s => apiFetch(`/pos/api/sales/${s.id}`))
|
||||||
|
);
|
||||||
|
|
||||||
tbody.innerHTML = salesToShow.map((sale) => {
|
tbody.innerHTML = salesToShow.map((sale, idx) => {
|
||||||
|
const detail = details[idx];
|
||||||
const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--';
|
const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--';
|
||||||
const client = sale.customer_name || 'Publico General';
|
const client = sale.customer_name || 'Publico General';
|
||||||
const total = sale.total || 0;
|
const total = sale.total || 0;
|
||||||
const method = sale.payment_method || 'efectivo';
|
const method = sale.payment_method || 'efectivo';
|
||||||
|
|
||||||
// Build products summary from items already included in the response
|
// Build products summary from detail items
|
||||||
let productsSummary = '';
|
let productsSummary = '';
|
||||||
const items = sale.items || [];
|
if (detail && detail.items && detail.items.length > 0) {
|
||||||
if (items.length > 0) {
|
productsSummary = detail.items.slice(0, 3).map(it =>
|
||||||
productsSummary = items.slice(0, 3).map(it =>
|
|
||||||
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
|
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
|
||||||
).join(', ');
|
).join(', ');
|
||||||
if (items.length > 3) productsSummary += '...';
|
if (detail.items.length > 3) productsSummary += '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodClass = getPaymentBadgeClass(method);
|
const methodClass = getPaymentBadgeClass(method);
|
||||||
@@ -703,7 +709,6 @@ const Dashboard = (() => {
|
|||||||
loadDailySummary();
|
loadDailySummary();
|
||||||
loadHistoricalSummary();
|
loadHistoricalSummary();
|
||||||
loadAlerts();
|
loadAlerts();
|
||||||
loadCreditAlerts();
|
|
||||||
loadTopProducts();
|
loadTopProducts();
|
||||||
loadChart('semana');
|
loadChart('semana');
|
||||||
loadRecentSales();
|
loadRecentSales();
|
||||||
@@ -712,7 +717,6 @@ const Dashboard = (() => {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
loadDailySummary();
|
loadDailySummary();
|
||||||
loadRecentSales();
|
loadRecentSales();
|
||||||
loadCreditAlerts();
|
|
||||||
}, 120000);
|
}, 120000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,6 @@ var Fleet = (function() {
|
|||||||
var currentPage = 1;
|
var currentPage = 1;
|
||||||
var searchTimeout = null;
|
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 ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
function headers() {
|
function headers() {
|
||||||
@@ -85,16 +75,9 @@ var Fleet = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New vehicle button
|
// New vehicle button
|
||||||
var btnNewVehicle = document.getElementById('btnNewVehicle');
|
document.getElementById('btnNewVehicle').addEventListener('click', function() {
|
||||||
if (btnNewVehicle) {
|
|
||||||
if (!canCreate) {
|
|
||||||
btnNewVehicle.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
btnNewVehicle.addEventListener('click', function() {
|
|
||||||
openVehicleModal();
|
openVehicleModal();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
loadStats();
|
loadStats();
|
||||||
@@ -122,8 +105,6 @@ var Fleet = (function() {
|
|||||||
var url = API + '/vehicles?page=' + currentPage + '&per_page=50';
|
var url = API + '/vehicles?page=' + currentPage + '&per_page=50';
|
||||||
if (q) url += '&q=' + encodeURIComponent(q);
|
if (q) url += '&q=' + encodeURIComponent(q);
|
||||||
|
|
||||||
document.getElementById('vehicleGrid').innerHTML = renderLoadingState({ message: 'Cargando vehiculos...' });
|
|
||||||
|
|
||||||
fetch(url, {headers: headers()})
|
fetch(url, {headers: headers()})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
@@ -134,7 +115,7 @@ var Fleet = (function() {
|
|||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
document.getElementById('vehicleGrid').innerHTML =
|
document.getElementById('vehicleGrid').innerHTML =
|
||||||
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los vehiculos.' });
|
'<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +125,7 @@ var Fleet = (function() {
|
|||||||
grid.innerHTML = '<div class="empty-state">' +
|
grid.innerHTML = '<div class="empty-state">' +
|
||||||
'<div class="empty-state__icon">🚚</div>' +
|
'<div class="empty-state__icon">🚚</div>' +
|
||||||
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
|
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
|
||||||
(canCreate ? '<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' : '') +
|
'<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -154,7 +135,7 @@ var Fleet = (function() {
|
|||||||
var label = (v.make || '') + ' ' + (v.model || '');
|
var label = (v.make || '') + ' ' + (v.model || '');
|
||||||
if (v.year) label += ' ' + v.year;
|
if (v.year) label += ' ' + v.year;
|
||||||
|
|
||||||
html += '<div class="vehicle-card" ' + (canEdit ? 'onclick="Fleet.viewVehicle(' + v.id + ')" style="cursor:pointer;"' : '') + '>' +
|
html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
|
||||||
'<div class="vehicle-card__header">' +
|
'<div class="vehicle-card__header">' +
|
||||||
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
|
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
|
||||||
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
|
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
|
||||||
@@ -219,11 +200,9 @@ var Fleet = (function() {
|
|||||||
function openVehicleModal(data) {
|
function openVehicleModal(data) {
|
||||||
var modal = document.getElementById('vehicleModal');
|
var modal = document.getElementById('vehicleModal');
|
||||||
var title = document.getElementById('vehicleModalTitle');
|
var title = document.getElementById('vehicleModalTitle');
|
||||||
var saveBtn = document.getElementById('btnSaveVehicle');
|
|
||||||
|
|
||||||
if (data && data.id) {
|
if (data && data.id) {
|
||||||
title.textContent = 'Editar Vehiculo';
|
title.textContent = 'Editar Vehiculo';
|
||||||
if (saveBtn) saveBtn.style.display = canEdit ? '' : 'none';
|
|
||||||
document.getElementById('vehEditId').value = data.id;
|
document.getElementById('vehEditId').value = data.id;
|
||||||
document.getElementById('vehPlate').value = data.plate || '';
|
document.getElementById('vehPlate').value = data.plate || '';
|
||||||
document.getElementById('vehVin').value = data.vin || '';
|
document.getElementById('vehVin').value = data.vin || '';
|
||||||
@@ -237,7 +216,6 @@ var Fleet = (function() {
|
|||||||
document.getElementById('vehNotes').value = data.notes || '';
|
document.getElementById('vehNotes').value = data.notes || '';
|
||||||
} else {
|
} else {
|
||||||
title.textContent = 'Nuevo Vehiculo';
|
title.textContent = 'Nuevo Vehiculo';
|
||||||
if (saveBtn) saveBtn.style.display = canCreate ? '' : 'none';
|
|
||||||
document.getElementById('vehEditId').value = '';
|
document.getElementById('vehEditId').value = '';
|
||||||
['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes']
|
['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes']
|
||||||
.forEach(function(id) { document.getElementById(id).value = ''; });
|
.forEach(function(id) { document.getElementById(id).value = ''; });
|
||||||
@@ -254,8 +232,6 @@ var Fleet = (function() {
|
|||||||
|
|
||||||
function saveVehicle() {
|
function saveVehicle() {
|
||||||
var editId = document.getElementById('vehEditId').value;
|
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 = {
|
var payload = {
|
||||||
plate: document.getElementById('vehPlate').value.trim(),
|
plate: document.getElementById('vehPlate').value.trim(),
|
||||||
vin: document.getElementById('vehVin').value.trim(),
|
vin: document.getElementById('vehVin').value.trim(),
|
||||||
@@ -299,36 +275,26 @@ var Fleet = (function() {
|
|||||||
// ─── Maintenance Tab ───
|
// ─── Maintenance Tab ───
|
||||||
|
|
||||||
function loadMaintenance() {
|
function loadMaintenance() {
|
||||||
// Single bulk endpoint replaces N+1 per-vehicle schedule requests
|
// Load all vehicles with their schedules
|
||||||
document.getElementById('maintBody').innerHTML =
|
fetch(API + '/vehicles?per_page=200', {headers: headers()})
|
||||||
'<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(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var schedules = d.data || [];
|
var allVehicles = d.data || [];
|
||||||
var results = schedules.map(function(s) {
|
var promises = allVehicles.map(function(v) {
|
||||||
return {
|
return fetch(API + '/vehicles/' + v.id + '/schedules', {headers: headers()})
|
||||||
vehicle: s.vehicle || {},
|
.then(function(r) { return r.json(); })
|
||||||
schedules: [{
|
.then(function(s) {
|
||||||
id: s.id,
|
return {vehicle: v, schedules: s.data || []};
|
||||||
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);
|
renderMaintenance(results);
|
||||||
})
|
})
|
||||||
.catch(function(e) {
|
.catch(function() {
|
||||||
console.error('loadMaintenance error:', e);
|
|
||||||
document.getElementById('maintBody').innerHTML =
|
document.getElementById('maintBody').innerHTML =
|
||||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los programas.' }) + '</td></tr>';
|
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +326,7 @@ var Fleet = (function() {
|
|||||||
'<td>' + (next || '—') + '</td>' +
|
'<td>' + (next || '—') + '</td>' +
|
||||||
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
|
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
|
||||||
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
|
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
|
||||||
'<td>' + (canCreate ? '<button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button>' : '') + '</td>' +
|
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,9 +344,7 @@ var Fleet = (function() {
|
|||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
|
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>' +
|
'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>';
|
||||||
(canCreate ? '<button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button>' : '') +
|
|
||||||
'</td></tr>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,25 +363,25 @@ var Fleet = (function() {
|
|||||||
// ─── History Tab ───
|
// ─── History Tab ───
|
||||||
|
|
||||||
function loadHistory() {
|
function loadHistory() {
|
||||||
// Single bulk endpoint replaces N+1 per-vehicle detail requests
|
fetch(API + '/vehicles?per_page=200', {headers: headers()})
|
||||||
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(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var logs = d.data || [];
|
var allVehicles = d.data || [];
|
||||||
var results = logs.map(function(l) {
|
var promises = allVehicles.map(function(v) {
|
||||||
l._plate = (l.vehicle && l.vehicle.plate) || 'S/P';
|
return fetch(API + '/vehicles/' + v.id, {headers: headers()})
|
||||||
l._make = ((l.vehicle && l.vehicle.make) || '') + ' ' + ((l.vehicle && l.vehicle.model) || '');
|
.then(function(r) { return r.json(); })
|
||||||
return {vehicle: l.vehicle || {}, logs: [l]};
|
.then(function(detail) {
|
||||||
|
return {vehicle: v, logs: detail.recent_logs || []};
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
return Promise.all(promises);
|
||||||
|
})
|
||||||
|
.then(function(results) {
|
||||||
renderHistory(results);
|
renderHistory(results);
|
||||||
})
|
})
|
||||||
.catch(function(e) {
|
.catch(function() {
|
||||||
console.error('loadHistory error:', e);
|
|
||||||
document.getElementById('historyBody').innerHTML =
|
document.getElementById('historyBody').innerHTML =
|
||||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudo cargar el historial.' }) + '</td></tr>';
|
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,8 +437,6 @@ var Fleet = (function() {
|
|||||||
// ─── Alerts Tab ───
|
// ─── Alerts Tab ───
|
||||||
|
|
||||||
function loadAlerts() {
|
function loadAlerts() {
|
||||||
document.getElementById('alertsList').innerHTML = renderLoadingState({ message: 'Cargando alertas...' });
|
|
||||||
|
|
||||||
fetch(API + '/alerts', {headers: headers()})
|
fetch(API + '/alerts', {headers: headers()})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
@@ -482,7 +444,7 @@ var Fleet = (function() {
|
|||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
document.getElementById('alertsList').innerHTML =
|
document.getElementById('alertsList').innerHTML =
|
||||||
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar las alertas.' });
|
'<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +475,7 @@ var Fleet = (function() {
|
|||||||
' ' + detail +
|
' ' + detail +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(canCreate ? '<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' : '') +
|
'<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
@@ -542,7 +504,6 @@ var Fleet = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveSchedule() {
|
function saveSchedule() {
|
||||||
if (!canCreate) { alert('No tienes permiso para crear programas'); return; }
|
|
||||||
var vehicleId = document.getElementById('schedVehicleSelect').value;
|
var vehicleId = document.getElementById('schedVehicleSelect').value;
|
||||||
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
|
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
|
||||||
|
|
||||||
@@ -613,7 +574,6 @@ var Fleet = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveLog() {
|
function saveLog() {
|
||||||
if (!canCreate) { alert('No tienes permiso para registrar mantenimiento'); return; }
|
|
||||||
var vehicleId = document.getElementById('logVehicleSelect').value;
|
var vehicleId = document.getElementById('logVehicleSelect').value;
|
||||||
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
|
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ var I18N = {
|
|||||||
'catalog': 'Catalogo',
|
'catalog': 'Catalogo',
|
||||||
'inventory': 'Inventario',
|
'inventory': 'Inventario',
|
||||||
'diagrams': 'Diagramas',
|
'diagrams': 'Diagramas',
|
||||||
'remission_notes': 'Notas de Remisión',
|
|
||||||
'customers': 'Clientes',
|
'customers': 'Clientes',
|
||||||
'invoicing': 'Facturacion',
|
'invoicing': 'Facturacion',
|
||||||
'accounting': 'Contabilidad',
|
'accounting': 'Contabilidad',
|
||||||
@@ -167,7 +166,6 @@ var I18N = {
|
|||||||
'catalog': 'Catalog',
|
'catalog': 'Catalog',
|
||||||
'inventory': 'Inventory',
|
'inventory': 'Inventory',
|
||||||
'diagrams': 'Diagrams',
|
'diagrams': 'Diagrams',
|
||||||
'remission_notes': 'Remission Notes',
|
|
||||||
'customers': 'Customers',
|
'customers': 'Customers',
|
||||||
'invoicing': 'Invoicing',
|
'invoicing': 'Invoicing',
|
||||||
'accounting': 'Accounting',
|
'accounting': 'Accounting',
|
||||||
@@ -319,9 +317,7 @@ var I18N = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spanish is mandatory for all tenants; ignore any previously stored language.
|
var currentLang = localStorage.getItem('pos_lang') || 'es';
|
||||||
var currentLang = 'es';
|
|
||||||
localStorage.setItem('pos_lang', 'es');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate a key to the current language.
|
* Translate a key to the current language.
|
||||||
@@ -332,11 +328,12 @@ window.t = function(key) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Language switcher is disabled; the system stays in Spanish.
|
* Switch the UI language and reload.
|
||||||
*/
|
*/
|
||||||
window.setLang = function(lang) {
|
window.setLang = function(lang) {
|
||||||
currentLang = 'es';
|
currentLang = lang;
|
||||||
localStorage.setItem('pos_lang', 'es');
|
localStorage.setItem('pos_lang', lang);
|
||||||
|
location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,38 +6,19 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var API = '/pos/api/inventory';
|
var API = '/pos/api/inventory';
|
||||||
function getToken() { return localStorage.getItem('pos_token') || ''; }
|
var token = localStorage.getItem('pos_token');
|
||||||
if (!getToken()) { window.location.href = '/pos/login'; return; }
|
if (!token) { window.location.href = '/pos/login'; return; }
|
||||||
|
|
||||||
function authHeaders() { return { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }; }
|
var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||||
var currentPage = 1;
|
var currentPage = 1;
|
||||||
var currentSearch = '';
|
var currentSearch = '';
|
||||||
var draftCountId = null;
|
var draftCountId = null;
|
||||||
var inventoryVS = null;
|
var inventoryVS = null;
|
||||||
var compatSource = 'both'; // default, loaded from config
|
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
|
// Load compatibility source setting
|
||||||
(function loadCompatSource() {
|
(function loadCompatSource() {
|
||||||
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.source) compatSource = d.source;
|
if (d.source) compatSource = d.source;
|
||||||
@@ -46,7 +27,7 @@
|
|||||||
|
|
||||||
// --- API helper ---
|
// --- API helper ---
|
||||||
function apiFetch(url, opts) {
|
function apiFetch(url, opts) {
|
||||||
return fetch(url, Object.assign({ headers: authHeaders() }, opts || {}))
|
return fetch(url, Object.assign({ headers: headers }, opts || {}))
|
||||||
.then(function (resp) {
|
.then(function (resp) {
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
localStorage.removeItem('pos_token');
|
localStorage.removeItem('pos_token');
|
||||||
@@ -65,15 +46,6 @@
|
|||||||
d.textContent = s;
|
d.textContent = s;
|
||||||
return d.innerHTML;
|
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 ---
|
// --- Dashboard summary badges ---
|
||||||
function loadSummary() {
|
function loadSummary() {
|
||||||
@@ -128,7 +100,7 @@
|
|||||||
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
|
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
|
||||||
fetch(API + '/tier-discounts', {
|
fetch(API + '/tier-discounts', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
|
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function(res) {
|
.then(function(res) {
|
||||||
@@ -199,11 +171,10 @@
|
|||||||
'<td>' + esc(it.location) + '</td>' +
|
'<td>' + esc(it.location) + '</td>' +
|
||||||
'<td>' +
|
'<td>' +
|
||||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
|
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
|
||||||
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') +
|
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
|
||||||
(canCreateItem ? '<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> ' +
|
||||||
(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" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
|
||||||
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
|
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
|
||||||
'</td></tr>';
|
'</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,14 +225,7 @@
|
|||||||
var tbody = document.getElementById('productTableBody');
|
var tbody = document.getElementById('productTableBody');
|
||||||
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
|
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
|
||||||
|
|
||||||
if (inventorySearchController) {
|
apiFetch(API + '/items?' + params.toString()).then(function (data) {
|
||||||
inventorySearchController.abort();
|
|
||||||
}
|
|
||||||
inventorySearchController = new AbortController();
|
|
||||||
|
|
||||||
apiFetch(API + '/items?' + params.toString(), { signal: inventorySearchController.signal })
|
|
||||||
.then(function (data) {
|
|
||||||
inventorySearchController = null;
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
var items = data.data || [];
|
var items = data.data || [];
|
||||||
@@ -304,9 +268,6 @@
|
|||||||
} else {
|
} else {
|
||||||
pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>';
|
pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>';
|
||||||
}
|
}
|
||||||
}).catch(function (err) {
|
|
||||||
if (err.name === 'AbortError') return;
|
|
||||||
console.error('Inventory load error:', err);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,49 +287,39 @@
|
|||||||
// CREATE ITEM (createModal)
|
// CREATE ITEM (createModal)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
function loadCategories(selectedId) {
|
function loadCategories() {
|
||||||
var sel = document.getElementById('newCategory');
|
var sel = document.getElementById('newCategory');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
apiFetch(API + '/categories/all').then(function(data) {
|
apiFetch(API + '/categories').then(function(data) {
|
||||||
if (!data || !data.categories) return;
|
if (!data || !data.categories) return;
|
||||||
var cats = data.categories;
|
sel.innerHTML = '<option value="">Selecciona categoría</option>';
|
||||||
var top = cats.filter(function(c) { return !c.parent_id; });
|
data.categories.forEach(function(c) {
|
||||||
var subs = cats.filter(function(c) { return c.parent_id; });
|
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
|
||||||
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;
|
window.loadCategories = loadCategories;
|
||||||
|
|
||||||
function onCategoryChange(categoryId) {
|
function onCategoryChange(categoryId) {
|
||||||
// Kept for backwards compatibility; selector is now flat.
|
var subSel = document.getElementById('newSubcategory');
|
||||||
|
if (!subSel) return;
|
||||||
|
if (!categoryId) {
|
||||||
|
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
|
||||||
|
subSel.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
|
||||||
|
if (!data || !data.subcategories) return;
|
||||||
|
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
|
||||||
|
data.subcategories.forEach(function(s) {
|
||||||
|
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
|
||||||
|
});
|
||||||
|
subSel.disabled = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
window.onCategoryChange = onCategoryChange;
|
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() {
|
function showCreateModal() {
|
||||||
resetCreateModal();
|
|
||||||
document.getElementById('createModal').classList.add('is-open');
|
document.getElementById('createModal').classList.add('is-open');
|
||||||
loadCategories();
|
loadCategories();
|
||||||
// Attach AI classification on part number blur
|
// Attach AI classification on part number blur
|
||||||
@@ -386,41 +337,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEditItemModal(itemId) {
|
|
||||||
resetCreateModal();
|
|
||||||
document.getElementById('editItemId').value = itemId;
|
|
||||||
document.getElementById('createModalTitle').textContent = 'Editar Producto';
|
|
||||||
document.getElementById('createModalBtn').textContent = 'Guardar Cambios';
|
|
||||||
document.getElementById('initialStockField').style.display = 'none';
|
|
||||||
document.getElementById('createModal').classList.add('is-open');
|
|
||||||
apiFetch(API + '/items/' + itemId).then(function(it) {
|
|
||||||
if (!it) return;
|
|
||||||
document.getElementById('newPartNumber').value = it.part_number || '';
|
|
||||||
document.getElementById('newName').value = it.name || '';
|
|
||||||
document.getElementById('newBrand').value = it.brand || '';
|
|
||||||
document.getElementById('newBarcode').value = it.barcode || '';
|
|
||||||
document.getElementById('newUnit').value = it.unit || '';
|
|
||||||
document.getElementById('newCost').value = it.cost != null ? it.cost : '';
|
|
||||||
document.getElementById('newPrice1').value = it.price_1 != null ? it.price_1 : '';
|
|
||||||
document.getElementById('newMinStock').value = it.min_stock != null ? it.min_stock : '';
|
|
||||||
document.getElementById('newMaxStock').value = it.max_stock != null ? it.max_stock : '';
|
|
||||||
document.getElementById('newTaxRate').value = it.tax_rate != null ? it.tax_rate : '';
|
|
||||||
document.getElementById('newLocation').value = it.location || '';
|
|
||||||
document.getElementById('newDescription').value = it.description || '';
|
|
||||||
document.getElementById('newIsActive').value = (it.is_active === false ? 'false' : 'true');
|
|
||||||
document.getElementById('newSku2').value = (it.sku_aliases && it.sku_aliases[0]) ? it.sku_aliases[0].sku : '';
|
|
||||||
document.getElementById('newSku3').value = (it.sku_aliases && it.sku_aliases[1]) ? it.sku_aliases[1].sku : '';
|
|
||||||
if (!canEditPrices) {
|
|
||||||
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = true; });
|
|
||||||
}
|
|
||||||
loadCategories(it.category_id);
|
|
||||||
}).catch(function(e) {
|
|
||||||
alert('Error al cargar producto: ' + e.message);
|
|
||||||
closeCreateModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
window.showEditItemModal = showEditItemModal;
|
|
||||||
|
|
||||||
function classifyPartNumber(partNumber) {
|
function classifyPartNumber(partNumber) {
|
||||||
var resultEl = document.getElementById('createResult');
|
var resultEl = document.getElementById('createResult');
|
||||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
|
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
|
||||||
@@ -451,57 +367,58 @@
|
|||||||
function closeCreateModal() {
|
function closeCreateModal() {
|
||||||
document.getElementById('createModal').classList.remove('is-open');
|
document.getElementById('createModal').classList.remove('is-open');
|
||||||
document.getElementById('createResult').innerHTML = '';
|
document.getElementById('createResult').innerHTML = '';
|
||||||
resetCreateModal();
|
var catSel = document.getElementById('newCategory');
|
||||||
|
var subSel = document.getElementById('newSubcategory');
|
||||||
|
if (catSel) catSel.innerHTML = '<option value="">Selecciona categoría</option>';
|
||||||
|
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; subSel.disabled = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createItem() {
|
function createItem() {
|
||||||
var editId = document.getElementById('editItemId').value;
|
var elPrice2 = document.getElementById('newPrice2');
|
||||||
|
var elPrice3 = document.getElementById('newPrice3');
|
||||||
var data = {
|
var data = {
|
||||||
part_number: document.getElementById('newPartNumber').value.trim(),
|
part_number: document.getElementById('newPartNumber').value.trim(),
|
||||||
name: document.getElementById('newName').value.trim(),
|
name: document.getElementById('newName').value.trim(),
|
||||||
brand: document.getElementById('newBrand').value.trim(),
|
brand: document.getElementById('newBrand').value.trim(),
|
||||||
barcode: document.getElementById('newBarcode').value.trim() || undefined,
|
barcode: document.getElementById('newBarcode').value.trim() || undefined,
|
||||||
unit: document.getElementById('newUnit').value.trim() || undefined,
|
|
||||||
cost: parseFloat(document.getElementById('newCost').value) || 0,
|
cost: parseFloat(document.getElementById('newCost').value) || 0,
|
||||||
price_1: parseFloat(document.getElementById('newPrice1').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,
|
min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
|
||||||
max_stock: parseInt(document.getElementById('newMaxStock').value) || 0,
|
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
|
||||||
tax_rate: parseFloat(document.getElementById('newTaxRate').value) || 0,
|
|
||||||
location: document.getElementById('newLocation').value.trim(),
|
location: document.getElementById('newLocation').value.trim(),
|
||||||
description: document.getElementById('newDescription').value.trim(),
|
|
||||||
is_active: document.getElementById('newIsActive').value === 'true',
|
|
||||||
sku_aliases: []
|
sku_aliases: []
|
||||||
};
|
};
|
||||||
var sku2 = document.getElementById('newSku2').value.trim();
|
var sku2 = document.getElementById('newSku2').value.trim();
|
||||||
var sku3 = document.getElementById('newSku3').value.trim();
|
var sku3 = document.getElementById('newSku3').value.trim();
|
||||||
var categoryId = document.getElementById('newCategory').value;
|
var categoryId = document.getElementById('newCategory').value;
|
||||||
if (categoryId) data.category_id = parseInt(categoryId);
|
var subcategoryId = document.getElementById('newSubcategory').value;
|
||||||
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
|
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
|
||||||
if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'});
|
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) {
|
if (!data.part_number || !data.name) {
|
||||||
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
|
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
|
||||||
var url = API + '/items';
|
if (result && result.id) {
|
||||||
var method = 'POST';
|
var msg = 'Creado ID ' + result.id + ' | Barcode: ' + result.barcode;
|
||||||
if (editId) {
|
if (result.vehicle_compatibilities_added > 0) {
|
||||||
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';
|
msg += ' | ' + result.vehicle_compatibilities_added + ' vehiculo(s) asignado(s) por IA';
|
||||||
}
|
}
|
||||||
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">' + msg + '</span>';
|
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">' + msg + '</span>';
|
||||||
loadItems(currentPage);
|
loadItems(currentPage);
|
||||||
|
// Close modal, clear form, refresh badges
|
||||||
closeCreateModal();
|
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();
|
if (window.loadInventoryStats) window.loadInventoryStats();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||||
@@ -527,7 +444,7 @@
|
|||||||
fetch(API + '/items/bulk-import', {
|
fetch(API + '/items/bulk-import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + getToken(),
|
'Authorization': 'Bearer ' + token,
|
||||||
'X-Import-Mode': mode,
|
'X-Import-Mode': mode,
|
||||||
'X-Import-Strategy': strategy
|
'X-Import-Strategy': strategy
|
||||||
},
|
},
|
||||||
@@ -557,28 +474,6 @@
|
|||||||
}
|
}
|
||||||
window.submitBulkImport = submitBulkImport;
|
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)
|
// PURCHASE / ENTRADA (purchaseModal)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -1109,7 +1004,7 @@
|
|||||||
history.forEach(function (h) {
|
history.forEach(function (h) {
|
||||||
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
|
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' +
|
'<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
|
||||||
'<td>' + esc(h.type) + '</td>' +
|
'<td>' + esc(h.type) + '</td>' +
|
||||||
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
|
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
|
||||||
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '—') + '</td>' +
|
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '—') + '</td>' +
|
||||||
@@ -1134,9 +1029,10 @@
|
|||||||
|
|
||||||
function deleteItem(itemId) {
|
function deleteItem(itemId) {
|
||||||
if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return;
|
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, {
|
fetch(API + '/items/' + itemId, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getToken() ? { 'Authorization': 'Bearer ' + getToken() } : {}
|
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; }
|
if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; }
|
||||||
@@ -1189,7 +1085,7 @@
|
|||||||
var ids = Array.from(selectedItems);
|
var ids = Array.from(selectedItems);
|
||||||
fetch('/pos/api/marketplace-ext/inventory-check', {
|
fetch('/pos/api/marketplace-ext/inventory-check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ inventory_ids: ids })
|
body: JSON.stringify({ inventory_ids: ids })
|
||||||
}).then(function(r){ return r.json(); })
|
}).then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -1244,7 +1140,7 @@
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/image', {
|
fetch('/pos/api/inventory/items/' + itemId + '/image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
body: formData
|
body: formData
|
||||||
}).then(function(r){ return r.json(); })
|
}).then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -1269,7 +1165,7 @@
|
|||||||
clearTimeout(meliCategorySearchTimeout);
|
clearTimeout(meliCategorySearchTimeout);
|
||||||
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
|
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
|
||||||
meliCategorySearchTimeout = setTimeout(function() {
|
meliCategorySearchTimeout = setTimeout(function() {
|
||||||
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' } })
|
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
var cats = data.categories || [];
|
var cats = data.categories || [];
|
||||||
@@ -1316,7 +1212,7 @@
|
|||||||
var section = document.getElementById('meliAttrsSection');
|
var section = document.getElementById('meliAttrsSection');
|
||||||
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
|
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
|
||||||
section.style.display = 'block';
|
section.style.display = 'block';
|
||||||
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r){ return r.json(); })
|
.then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
meliCategoryAttrs = data.attributes || [];
|
meliCategoryAttrs = data.attributes || [];
|
||||||
@@ -1445,7 +1341,7 @@
|
|||||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
|
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
|
||||||
fetch('/pos/api/marketplace-ext/listings/validate', {
|
fetch('/pos/api/marketplace-ext/listings/validate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
inventory_ids: ids,
|
inventory_ids: ids,
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
@@ -1535,7 +1431,7 @@
|
|||||||
var maxAttempts = 60; // 2 min
|
var maxAttempts = 60; // 2 min
|
||||||
var interval = setInterval(function() {
|
var interval = setInterval(function() {
|
||||||
attempts++;
|
attempts++;
|
||||||
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r){ return r.json(); })
|
.then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.status === 'done') {
|
if (data.status === 'done') {
|
||||||
@@ -1572,7 +1468,7 @@
|
|||||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
|
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
|
||||||
fetch(endpoint, {
|
fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
inventory_ids: ids,
|
inventory_ids: ids,
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
@@ -1631,7 +1527,7 @@
|
|||||||
if (statusEl) statusEl.textContent = 'Subiendo...';
|
if (statusEl) statusEl.textContent = 'Subiendo...';
|
||||||
fetch(API + '/items/' + itemId + '/image', {
|
fetch(API + '/items/' + itemId + '/image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
body: fd
|
body: fd
|
||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
@@ -1654,7 +1550,7 @@
|
|||||||
if (!confirm('Eliminar imagen de este producto?')) return;
|
if (!confirm('Eliminar imagen de este producto?')) return;
|
||||||
fetch(API + '/items/' + itemId + '/image', {
|
fetch(API + '/items/' + itemId + '/image', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (result) {
|
.then(function (result) {
|
||||||
@@ -1764,7 +1660,7 @@
|
|||||||
? '/pos/api/catalog/part/' + catalogPartId
|
? '/pos/api/catalog/part/' + catalogPartId
|
||||||
: '/pos/api/catalog/search?q=' + encodeURIComponent(partNumber);
|
: '/pos/api/catalog/search?q=' + encodeURIComponent(partNumber);
|
||||||
|
|
||||||
fetch(url, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var el = document.getElementById('crossRefContent');
|
var el = document.getElementById('crossRefContent');
|
||||||
@@ -1776,7 +1672,7 @@
|
|||||||
// If it was a search, get alternatives from first result
|
// If it was a search, get alternatives from first result
|
||||||
if (!catalogPartId && d.data && d.data.length > 0) {
|
if (!catalogPartId && d.data && d.data.length > 0) {
|
||||||
// Fetch detail for first match
|
// Fetch detail for first match
|
||||||
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r2) { return r2.json(); })
|
.then(function(r2) { return r2.json(); })
|
||||||
.then(function(d2) {
|
.then(function(d2) {
|
||||||
renderCrossRefs(el, d2.alternatives || [], d2.bodegas || []);
|
renderCrossRefs(el, d2.alternatives || [], d2.bodegas || []);
|
||||||
@@ -1849,7 +1745,7 @@
|
|||||||
|
|
||||||
// Load SKU aliases
|
// Load SKU aliases
|
||||||
(function loadSkuAliases() {
|
(function loadSkuAliases() {
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var el = document.getElementById('skuAliasContent');
|
var el = document.getElementById('skuAliasContent');
|
||||||
@@ -1881,7 +1777,7 @@
|
|||||||
|
|
||||||
// Load vehicle compatibilities and makes
|
// Load vehicle compatibilities and makes
|
||||||
(function loadCompatPanel() {
|
(function loadCompatPanel() {
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var el = document.getElementById('compatContent');
|
var el = document.getElementById('compatContent');
|
||||||
@@ -1907,7 +1803,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load makes
|
// Load makes
|
||||||
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var sel = document.getElementById('manualMake');
|
var sel = document.getElementById('manualMake');
|
||||||
@@ -1932,7 +1828,7 @@
|
|||||||
history.forEach(function (h) {
|
history.forEach(function (h) {
|
||||||
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
|
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' +
|
'<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
|
||||||
'<td>' + esc(h.type) + '</td>' +
|
'<td>' + esc(h.type) + '</td>' +
|
||||||
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</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>' +
|
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '\u2014') + '</td>' +
|
||||||
@@ -1951,7 +1847,7 @@
|
|||||||
function autoMatchCompat(itemId) {
|
function autoMatchCompat(itemId) {
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', {
|
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var msg = '';
|
var msg = '';
|
||||||
@@ -1974,7 +1870,7 @@
|
|||||||
if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
|
if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, {
|
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function() {
|
.then(function() {
|
||||||
viewProductDetail(itemId);
|
viewProductDetail(itemId);
|
||||||
@@ -2007,7 +1903,7 @@
|
|||||||
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
|
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var opts = '<option value="">Selecciona modelo</option>';
|
var opts = '<option value="">Selecciona modelo</option>';
|
||||||
@@ -2033,7 +1929,7 @@
|
|||||||
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
|
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var opts = '<option value="">Selecciona ano</option>';
|
var opts = '<option value="">Selecciona ano</option>';
|
||||||
@@ -2059,7 +1955,7 @@
|
|||||||
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
|
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var opts = '<option value="">Selecciona motor</option>';
|
var opts = '<option value="">Selecciona motor</option>';
|
||||||
@@ -2087,7 +1983,7 @@
|
|||||||
}
|
}
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
|
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode })
|
body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode })
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
@@ -2105,7 +2001,7 @@
|
|||||||
if (!sku) { alert('Ingresa un SKU'); return; }
|
if (!sku) { alert('Ingresa un SKU'); return; }
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/skus', {
|
fetch('/pos/api/inventory/items/' + itemId + '/skus', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sku: sku, label: label })
|
body: JSON.stringify({ sku: sku, label: label })
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
@@ -2118,7 +2014,7 @@
|
|||||||
if (!confirm('Eliminar este SKU alternativo?')) return;
|
if (!confirm('Eliminar este SKU alternativo?')) return;
|
||||||
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
|
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': 'Bearer ' + getToken() }
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function() {
|
.then(function() {
|
||||||
viewProductDetail(itemId);
|
viewProductDetail(itemId);
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ const Invoicing = (() => {
|
|||||||
if (name === 'notas') loadNotas();
|
if (name === 'notas') loadNotas();
|
||||||
if (name === 'complementos') loadComplementos();
|
if (name === 'complementos') loadComplementos();
|
||||||
if (name === 'cancelaciones') loadCancelaciones();
|
if (name === 'cancelaciones') loadCancelaciones();
|
||||||
if (name === 'config') {
|
if (name === 'config') loadFacturapiStatus();
|
||||||
loadFacturapiStatus();
|
|
||||||
loadEmisorData();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Badge helpers ----
|
// ---- Badge helpers ----
|
||||||
@@ -89,34 +86,17 @@ const Invoicing = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ----
|
// ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ----
|
||||||
let facturasCache = [];
|
|
||||||
|
|
||||||
async function loadFacturas() {
|
async function loadFacturas() {
|
||||||
const panel = document.getElementById('panel-facturas');
|
const panel = document.getElementById('panel-facturas');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const tbody = panel.querySelector('.data-table tbody');
|
const tbody = panel.querySelector('.data-table tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
const statusFilter = document.getElementById('facturas-status-filter');
|
|
||||||
const status = statusFilter ? statusFilter.value : '';
|
|
||||||
const url = status ? `/queue?per_page=50&type=ingreso&status=${status}` : '/queue?per_page=50&type=ingreso';
|
|
||||||
|
|
||||||
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando facturas...' }) + '</td></tr>';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api(url);
|
const res = await api('/queue?per_page=50&type=Ingreso');
|
||||||
facturasCache = res.data || [];
|
const items = res.data || [];
|
||||||
renderFacturas(facturasCache, res.pagination?.total || facturasCache.length);
|
|
||||||
} catch (e) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFacturas(items, total) {
|
|
||||||
const panel = document.getElementById('panel-facturas');
|
|
||||||
const tbody = panel.querySelector('.data-table tbody');
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin facturas', subtitle: 'No hay facturas en este periodo.' }) + '</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay facturas en este periodo.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = items.map(item => `<tr>
|
tbody.innerHTML = items.map(item => `<tr>
|
||||||
@@ -125,9 +105,9 @@ const Invoicing = (() => {
|
|||||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||||
<td class="td--mono">${item.rfc || '-'}</td>
|
<td class="td--mono">${item.rfc || '-'}</td>
|
||||||
<td class="td--amount">$${fmt(item.subtotal)}</td>
|
<td class="td--amount">$${fmt(item.subtotal)}</td>
|
||||||
<td class="td--amount">$${fmt(item.tax_total)}</td>
|
<td class="td--amount">$${fmt(item.tax)}</td>
|
||||||
<td class="td--amount">$${fmt(item.total)}</td>
|
<td class="td--amount">$${fmt(item.total)}</td>
|
||||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
<td style="font-size:var(--text-caption);">${item.uso_cfdi || '-'}</td>
|
||||||
<td>${statusBadge(item.status)}</td>
|
<td>${statusBadge(item.status)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;gap:4px;">
|
<div style="display:flex;gap:4px;">
|
||||||
@@ -138,42 +118,12 @@ const Invoicing = (() => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
|
|
||||||
|
// Update footer count
|
||||||
const footer = panel.querySelector('.table-footer span');
|
const footer = panel.querySelector('.table-footer span');
|
||||||
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${total} facturas`;
|
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${res.pagination?.total || items.length} facturas`;
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterFacturas() {
|
|
||||||
const q = (document.getElementById('facturas-search')?.value || '').toLowerCase();
|
|
||||||
const filtered = facturasCache.filter(item =>
|
|
||||||
(item.provisional_folio || '').toLowerCase().includes(q) ||
|
|
||||||
(item.customer_name || '').toLowerCase().includes(q) ||
|
|
||||||
(item.rfc || '').toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
renderFacturas(filtered, facturasCache.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportFacturasCSV() {
|
|
||||||
if (typeof window.exportVisibleTableCSV === 'function') {
|
|
||||||
window.exportVisibleTableCSV('facturas');
|
|
||||||
} else {
|
|
||||||
alert('Exportador no disponible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportNotasCSV() {
|
|
||||||
if (typeof window.exportVisibleTableCSV === 'function') {
|
|
||||||
window.exportVisibleTableCSV('notas_de_credito');
|
|
||||||
} else {
|
|
||||||
alert('Exportador no disponible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function newCreditNote() {
|
|
||||||
alert('Nueva nota de crédito — próximamente');
|
|
||||||
}
|
|
||||||
|
|
||||||
function newPaymentComplement() {
|
|
||||||
alert('Nuevo complemento de pago — próximamente');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ----
|
// ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ----
|
||||||
@@ -183,20 +133,18 @@ const Invoicing = (() => {
|
|||||||
const tbody = panel.querySelector('.data-table tbody');
|
const tbody = panel.querySelector('.data-table tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando notas de credito...' }) + '</td></tr>';
|
|
||||||
|
|
||||||
try {
|
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 || [];
|
const items = res.data || [];
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin notas de credito', subtitle: 'No hay notas de credito registradas.' }) + '</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay notas de credito.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = items.map(item => `<tr>
|
tbody.innerHTML = items.map(item => `<tr>
|
||||||
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
<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--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
|
||||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||||
<td>${item.cancel_motive || '-'}</td>
|
<td>${item.description || '-'}</td>
|
||||||
<td class="td--amount">$${fmt(item.total)}</td>
|
<td class="td--amount">$${fmt(item.total)}</td>
|
||||||
<td>${statusBadge(item.status)}</td>
|
<td>${statusBadge(item.status)}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -207,7 +155,7 @@ const Invoicing = (() => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
|
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,31 +166,14 @@ const Invoicing = (() => {
|
|||||||
const tbody = panel.querySelector('.data-table tbody');
|
const tbody = panel.querySelector('.data-table tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando complementos...' }) + '</td></tr>';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api('/queue?per_page=50&type=pago');
|
const res = await api('/queue?per_page=50&type=Pago');
|
||||||
complementosCache = res.data || [];
|
const items = res.data || [];
|
||||||
renderComplementos(complementosCache);
|
if (!items.length) {
|
||||||
} catch (e) {
|
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>';
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = filtered.map(item => `<tr>
|
tbody.innerHTML = items.map(item => `<tr>
|
||||||
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
||||||
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_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--primary">${item.customer_name || '-'}</td>
|
||||||
@@ -257,6 +188,9 @@ const Invoicing = (() => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
|
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
|
||||||
@@ -354,18 +288,10 @@ const Invoicing = (() => {
|
|||||||
let pendingHtml = '';
|
let pendingHtml = '';
|
||||||
if (status.pending_steps && status.pending_steps.length) {
|
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);">' +
|
pendingHtml = '<ul style="margin:var(--space-2) 0 0 0;padding-left:var(--space-5);color:var(--color-warning);">' +
|
||||||
status.pending_steps.map(s => `<li>${s.description || s.type || s}</li>`).join('') +
|
status.pending_steps.map(s => `<li>${s.description || s.type}</li>`).join('') +
|
||||||
'</ul>';
|
'</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 = `
|
container.innerHTML = `
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
|
||||||
<div>
|
<div>
|
||||||
@@ -375,128 +301,29 @@ const Invoicing = (() => {
|
|||||||
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${status.org_id || ''}</div>
|
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${status.org_id || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Configurada</div>
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">CSD</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-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>
|
<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>'}
|
${pendingHtml || '<span style="color:var(--color-success);">Ninguno</span>'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${status.error ? `<p style="color:var(--color-error);margin-top:var(--space-3);">Error: ${escapeHtml(status.error)}</p>` : ''}
|
${status.error ? `<p style="color:var(--color-error);margin-top:var(--space-3);">Error: ${escapeHtml(status.error)}</p>` : ''}
|
||||||
${retryButton}
|
|
||||||
`;
|
`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
|
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) {
|
async function setupFacturapi(btn) {
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Configurando...';
|
btn.textContent = 'Configurando...';
|
||||||
try {
|
try {
|
||||||
const res = await api('/facturapi/setup', { method: 'POST' });
|
const res = await api('/facturapi/setup', { method: 'POST' });
|
||||||
if (res.error) {
|
alert('Organización vinculada: ' + res.org_id);
|
||||||
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();
|
loadFacturapiStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error: ' + e.message);
|
alert('Error: ' + e.message);
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Crear / Vincular Organización';
|
btn.textContent = 'Crear / Vincular Organización';
|
||||||
}
|
}
|
||||||
@@ -508,13 +335,6 @@ const Invoicing = (() => {
|
|||||||
document.getElementById('csd-key-label').textContent = 'Subir llave privada .key';
|
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() {
|
function updateFileLabels() {
|
||||||
const cer = document.getElementById('csd-cer');
|
const cer = document.getElementById('csd-cer');
|
||||||
const key = document.getElementById('csd-key');
|
const key = document.getElementById('csd-key');
|
||||||
@@ -854,6 +674,13 @@ const Invoicing = (() => {
|
|||||||
window.submitNewInvoice = submitNewInvoice;
|
window.submitNewInvoice = submitNewInvoice;
|
||||||
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
|
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
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||||
@@ -862,15 +689,4 @@ const Invoicing = (() => {
|
|||||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
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,13 +82,7 @@
|
|||||||
localStorage.setItem('pos_token', result.data.token);
|
localStorage.setItem('pos_token', result.data.token);
|
||||||
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
||||||
localStorage.setItem('pos_tenant_id', tenantId);
|
localStorage.setItem('pos_tenant_id', tenantId);
|
||||||
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
|
|
||||||
var role = (result.data.employee.role || '').toLowerCase();
|
|
||||||
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
|
||||||
window.location.href = '/pos/workshop';
|
|
||||||
} else {
|
|
||||||
window.location.href = '/pos/catalog';
|
window.location.href = '/pos/catalog';
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
errorEl.textContent = 'Error de conexion';
|
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),document.cookie="pos_role="+(t.data.employee.role||"")+"; path=/pos; SameSite=Lax",window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}();
|
!function(){"use strict";var t="",e=document.querySelectorAll("#pinDots .pin-dot"),n=document.getElementById("loginError"),o=new URLSearchParams(window.location.search).get("tenant")||localStorage.getItem("pos_tenant_id"),a=localStorage.getItem("pos_device_id");function i(){e.forEach((function(e,n){e.classList.toggle("filled",n<t.length)}))}a||(a="dev-"+Date.now()+"-"+Math.random().toString(36).substr(2,9),localStorage.setItem("pos_device_id",a)),window.addDigit=function(e){t.length>=4||(t+=e,i(),n.textContent="",4===t.length&&submitPin())},window.clearPin=function(){t="",i(),n.textContent=""},window.submitPin=function(){4===t.length&&(n.textContent="",fetch("/pos/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tenant_id:parseInt(o),pin:t,device_id:a})}).then((function(t){return t.json().then((function(e){return{ok:t.ok,data:e}}))})).then((function(t){if(!t.ok)return n.textContent=t.data.error||"Error de autenticacion",void clearPin();localStorage.setItem("pos_token",t.data.token),localStorage.setItem("pos_employee",JSON.stringify(t.data.employee)),localStorage.setItem("pos_tenant_id",o),window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}();
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.startOAuth = async function() {
|
window.startOAuth = function() {
|
||||||
var clientId = document.getElementById('cfgClientId').value.trim();
|
var clientId = document.getElementById('cfgClientId').value.trim();
|
||||||
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
|
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
|
||||||
var category = document.getElementById('cfgCategory').value.trim();
|
var category = document.getElementById('cfgCategory').value.trim();
|
||||||
@@ -75,34 +75,15 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purge any previously stored secret from older versions
|
// Save config locally for the callback
|
||||||
localStorage.removeItem('meli_client_secret');
|
localStorage.setItem('meli_client_id', clientId);
|
||||||
localStorage.removeItem('meli_client_id');
|
localStorage.setItem('meli_client_secret', clientSecret);
|
||||||
localStorage.setItem('meli_category', category);
|
localStorage.setItem('meli_category', category);
|
||||||
localStorage.setItem('meli_shipping', shipping);
|
localStorage.setItem('meli_shipping', shipping);
|
||||||
|
|
||||||
try {
|
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||||
var res = await fetch(API + '/connect/init', {
|
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access';
|
||||||
method: 'POST',
|
window.location.href = authUrl;
|
||||||
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() {
|
window.disconnectMeli = async function() {
|
||||||
@@ -382,6 +363,8 @@
|
|||||||
var authCode = urlParams.get('code');
|
var authCode = urlParams.get('code');
|
||||||
if (authCode && window.location.pathname.includes('marketplace-external')) {
|
if (authCode && window.location.pathname.includes('marketplace-external')) {
|
||||||
(async function() {
|
(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';
|
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||||
try {
|
try {
|
||||||
var res = await fetch(API + '/connect', {
|
var res = await fetch(API + '/connect', {
|
||||||
@@ -389,6 +372,8 @@
|
|||||||
headers: headers(),
|
headers: headers(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
code: authCode,
|
code: authCode,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,15 +160,7 @@
|
|||||||
delete el.dataset.originalContent;
|
delete el.dataset.originalContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Loading / Empty state helpers ────────────────────────────────────────
|
// ── Empty state helper ────────────────────────────────────────
|
||||||
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) {
|
window.renderEmptyState = function(opts) {
|
||||||
opts = 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>';
|
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,24 +21,10 @@ const POS = (() => {
|
|||||||
let paymentMethod = 'efectivo';
|
let paymentMethod = 'efectivo';
|
||||||
let canViewCost = false;
|
let canViewCost = false;
|
||||||
let employeeMaxDiscount = 100;
|
let employeeMaxDiscount = 100;
|
||||||
let lastSaleId = sessionStorage.getItem('pos_last_sale_id') || null;
|
let lastSaleId = null;
|
||||||
let lastSaleData = null;
|
let lastSaleData = null;
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
let customerSearchTimeout = 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
|
// Currency-aware formatter: reads pos_currency from localStorage
|
||||||
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
||||||
@@ -46,16 +32,6 @@ const POS = (() => {
|
|||||||
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
||||||
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
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() {
|
function headers() {
|
||||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
}
|
}
|
||||||
@@ -78,48 +54,6 @@ const POS = (() => {
|
|||||||
setTimeout(() => el.remove(), 2100);
|
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 ────────────────────────────
|
// ─── Init ────────────────────────────
|
||||||
async function init() {
|
async function init() {
|
||||||
// Parse JWT to get employee info
|
// Parse JWT to get employee info
|
||||||
@@ -127,44 +61,9 @@ const POS = (() => {
|
|||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
||||||
document.getElementById('branchName').textContent = payload.branch_name || '';
|
document.getElementById('branchName').textContent = payload.branch_name || '';
|
||||||
const perms = payload.permissions || [];
|
canViewCost = (payload.permissions || []).includes('pos.view_cost');
|
||||||
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;
|
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
|
// Show cost/margin columns and toggle button if permission
|
||||||
if (canViewCost) {
|
if (canViewCost) {
|
||||||
document.getElementById('thCost').style.display = '';
|
document.getElementById('thCost').style.display = '';
|
||||||
@@ -179,8 +78,6 @@ const POS = (() => {
|
|||||||
const parts = payload.name.split(' ');
|
const parts = payload.name.split(' ');
|
||||||
avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase();
|
avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPermissionsUI();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not parse token:', e);
|
console.warn('Could not parse token:', e);
|
||||||
}
|
}
|
||||||
@@ -205,10 +102,8 @@ const POS = (() => {
|
|||||||
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
|
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load current register and receipt config
|
// Load current register
|
||||||
await loadRegister();
|
await loadRegister();
|
||||||
await loadReceiptConfig();
|
|
||||||
await loadCouriers();
|
|
||||||
|
|
||||||
// Setup event listeners
|
// Setup event listeners
|
||||||
setupKeyboard();
|
setupKeyboard();
|
||||||
@@ -228,48 +123,14 @@ const POS = (() => {
|
|||||||
currentRegister = null;
|
currentRegister = null;
|
||||||
document.getElementById('registerInfo').innerHTML =
|
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>';
|
'<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 only for users who can sell
|
// Force open register modal on first load
|
||||||
if (currentPerms.includes('pos.sell')) {
|
|
||||||
showOpenRegisterModal();
|
showOpenRegisterModal();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Register check failed:', 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() {
|
function showOpenRegisterModal() {
|
||||||
document.getElementById('openRegisterModal').classList.add('open');
|
document.getElementById('openRegisterModal').classList.add('open');
|
||||||
document.getElementById('registerOpenResult').innerHTML = '';
|
document.getElementById('registerOpenResult').innerHTML = '';
|
||||||
@@ -417,7 +278,6 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCancelModal() {
|
function openCancelModal() {
|
||||||
if (!canCancel) { showToast('No tienes permiso para cancelar'); return; }
|
|
||||||
const overlay = document.getElementById('overlay-cancelar-venta');
|
const overlay = document.getElementById('overlay-cancelar-venta');
|
||||||
const dialog = document.getElementById('modal-cancelar-venta');
|
const dialog = document.getElementById('modal-cancelar-venta');
|
||||||
if (overlay) overlay.classList.add('active');
|
if (overlay) overlay.classList.add('active');
|
||||||
@@ -447,7 +307,6 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyDiscount() {
|
function applyDiscount() {
|
||||||
if (!canDiscount) { showToast('No tienes permiso para aplicar descuentos'); return; }
|
|
||||||
if (selectedRow < 0 || selectedRow >= cart.length) {
|
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||||
showToast('Selecciona un articulo primero', 'warn');
|
showToast('Selecciona un articulo primero', 'warn');
|
||||||
return;
|
return;
|
||||||
@@ -462,20 +321,7 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isZeroPriceItem(item) {
|
|
||||||
const unitPrice = parseFloat(item.unit_price || 0);
|
|
||||||
const qty = parseFloat(item.quantity || 1);
|
|
||||||
const discount = parseFloat(item.discount_pct || 0);
|
|
||||||
return (unitPrice * qty * (1 - discount / 100)) <= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findZeroPriceItem() {
|
|
||||||
if (allowZeroPriceSales) return null;
|
|
||||||
return cart.find(isZeroPriceItem) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function modifyPrice() {
|
function modifyPrice() {
|
||||||
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
|
|
||||||
if (selectedRow < 0 || selectedRow >= cart.length) {
|
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||||
showToast('Selecciona un articulo primero', 'warn');
|
showToast('Selecciona un articulo primero', 'warn');
|
||||||
return;
|
return;
|
||||||
@@ -483,15 +329,12 @@ const POS = (() => {
|
|||||||
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
|
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
|
||||||
if (p !== null) {
|
if (p !== null) {
|
||||||
const n = parseFloat(p);
|
const n = parseFloat(p);
|
||||||
if (n < 0) { showToast('Precio no válido'); return; }
|
if (n >= 0) {
|
||||||
if (!allowZeroPriceSales && n === 0) {
|
|
||||||
showToast('No está permitido dejar el precio en $0');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cart[selectedRow].unit_price = n;
|
cart[selectedRow].unit_price = n;
|
||||||
renderCart();
|
renderCart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wire confirm-cancel button
|
// Wire confirm-cancel button
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -676,11 +519,26 @@ const POS = (() => {
|
|||||||
if (data.data.length === 0) {
|
if (data.data.length === 0) {
|
||||||
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
||||||
if (window.BarcodeFeedback) BarcodeFeedback.error();
|
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 {
|
} else {
|
||||||
let html = '';
|
let html = '';
|
||||||
data.data.forEach(item => {
|
data.data.forEach(item => {
|
||||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
let price = item.price_1;
|
||||||
const price = priceForTier(item.price_1, tier);
|
if (currentCustomer) {
|
||||||
|
const tier = currentCustomer.price_tier || 1;
|
||||||
|
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||||
|
}
|
||||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
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>
|
||||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||||
@@ -775,7 +633,7 @@ const POS = (() => {
|
|||||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||||
cart.forEach(item => {
|
cart.forEach(item => {
|
||||||
if (item.price_1 > 0) {
|
if (item.price_1 > 0) {
|
||||||
item.unit_price = priceForTier(item.price_1, tier);
|
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -971,12 +829,9 @@ const POS = (() => {
|
|||||||
transferencia: 'refPayment',
|
transferencia: 'refPayment',
|
||||||
tarjeta: 'refPayment',
|
tarjeta: 'refPayment',
|
||||||
mixto: 'mixedPayment',
|
mixto: 'mixedPayment',
|
||||||
credito: 'creditPayment',
|
|
||||||
cheque: 'chequePayment',
|
|
||||||
pendiente: 'pendingPayment',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
|
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
const isActive = el.id === tabs[method];
|
const isActive = el.id === tabs[method];
|
||||||
@@ -990,12 +845,6 @@ const POS = (() => {
|
|||||||
const ref = document.getElementById('paymentRef');
|
const ref = document.getElementById('paymentRef');
|
||||||
if (ref) ref.focus();
|
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() {
|
function updateChange() {
|
||||||
@@ -1033,7 +882,6 @@ const POS = (() => {
|
|||||||
let amountPaid = 0;
|
let amountPaid = 0;
|
||||||
let paymentDetails = [];
|
let paymentDetails = [];
|
||||||
let reference = '';
|
let reference = '';
|
||||||
let saleType = 'cash';
|
|
||||||
|
|
||||||
if (paymentMethod === 'efectivo') {
|
if (paymentMethod === 'efectivo') {
|
||||||
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||||
@@ -1053,20 +901,6 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
|
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 = {
|
const saleData = {
|
||||||
@@ -1079,7 +913,7 @@ const POS = (() => {
|
|||||||
})),
|
})),
|
||||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
sale_type: saleType,
|
sale_type: 'cash',
|
||||||
register_id: currentRegister ? currentRegister.id : null,
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
amount_paid: amountPaid,
|
amount_paid: amountPaid,
|
||||||
payment_details: paymentDetails,
|
payment_details: paymentDetails,
|
||||||
@@ -1087,12 +921,6 @@ const POS = (() => {
|
|||||||
generate_cfdi: document.getElementById('cfdiCheck').checked,
|
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');
|
const confirmBtn = document.getElementById('btnConfirmPayment');
|
||||||
confirmBtn.disabled = true;
|
confirmBtn.disabled = true;
|
||||||
confirmBtn.textContent = 'Procesando...';
|
confirmBtn.textContent = 'Procesando...';
|
||||||
@@ -1104,10 +932,9 @@ const POS = (() => {
|
|||||||
const convertData = {
|
const convertData = {
|
||||||
register_id: currentRegister ? currentRegister.id : null,
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
sale_type: saleType,
|
sale_type: 'cash',
|
||||||
amount_paid: amountPaid,
|
amount_paid: amountPaid,
|
||||||
payment_details: paymentDetails,
|
payment_details: paymentDetails,
|
||||||
reference: reference,
|
|
||||||
};
|
};
|
||||||
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
|
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1125,7 +952,6 @@ const POS = (() => {
|
|||||||
|
|
||||||
lastSaleId = sale.id;
|
lastSaleId = sale.id;
|
||||||
lastSaleData = sale;
|
lastSaleData = sale;
|
||||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
|
||||||
closePaymentModal();
|
closePaymentModal();
|
||||||
showTicket(sale);
|
showTicket(sale);
|
||||||
|
|
||||||
@@ -1142,18 +968,22 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Counter Remission Note ────────────
|
// ─── Credit Sale ─────────────────────
|
||||||
async function createRemissionNote() {
|
async function creditSale() {
|
||||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||||
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
|
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
|
||||||
const zeroItem = findZeroPriceItem();
|
if (!currentRegister) { alert('No hay caja abierta.'); return; }
|
||||||
if (zeroItem) {
|
|
||||||
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
const noteData = {
|
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
||||||
|
|
||||||
|
if (currentCustomer.credit_limit > 0 && total > available) {
|
||||||
|
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saleData = {
|
||||||
items: cart.map(item => ({
|
items: cart.map(item => ({
|
||||||
inventory_id: item.inventory_id,
|
inventory_id: item.inventory_id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -1161,158 +991,34 @@ const POS = (() => {
|
|||||||
discount_pct: item.discount_pct,
|
discount_pct: item.discount_pct,
|
||||||
tax_rate: item.tax_rate,
|
tax_rate: item.tax_rate,
|
||||||
})),
|
})),
|
||||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
customer_id: currentCustomer.id,
|
||||||
notes: 'Nota de remision generada desde mostrador',
|
payment_method: 'credito',
|
||||||
|
sale_type: 'credit',
|
||||||
register_id: currentRegister ? currentRegister.id : null,
|
register_id: currentRegister ? currentRegister.id : null,
|
||||||
courier_id: selectedCourierId,
|
amount_paid: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sale = await api('/pos/api/sales/remission', {
|
const sale = await api('/pos/api/sales', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(noteData),
|
body: JSON.stringify(saleData),
|
||||||
});
|
});
|
||||||
if (selectedCourierId) {
|
|
||||||
const courier = couriers.find(c => c.id === selectedCourierId);
|
|
||||||
sale.courier_name = courier ? courier.name : '';
|
|
||||||
}
|
|
||||||
lastSaleId = sale.id;
|
lastSaleId = sale.id;
|
||||||
lastSaleData = sale;
|
lastSaleData = sale;
|
||||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
|
||||||
showTicket(sale);
|
showTicket(sale);
|
||||||
cart = [];
|
cart = [];
|
||||||
selectedRow = -1;
|
selectedRow = -1;
|
||||||
clearCustomer();
|
clearCustomer();
|
||||||
selectedCourierId = null;
|
|
||||||
const sel = document.getElementById('remissionCourier');
|
|
||||||
if (sel) sel.value = '';
|
|
||||||
renderCart();
|
renderCart();
|
||||||
showToast(`Nota de remision NR-${sale.id} generada`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error al generar nota de remision: ' + e.message);
|
alert('Error: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPayRemissionModal() {
|
|
||||||
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
|
|
||||||
document.getElementById('payRemissionFolio').value = '';
|
|
||||||
document.getElementById('payRemissionDetail').innerHTML = '';
|
|
||||||
document.getElementById('payRemissionResult').innerHTML = '';
|
|
||||||
document.getElementById('payRemissionActions').style.display = 'none';
|
|
||||||
document.getElementById('payRemissionModal').classList.add('open');
|
|
||||||
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePayRemissionModal() {
|
|
||||||
document.getElementById('payRemissionModal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
let pendingRemissionToPay = null;
|
|
||||||
|
|
||||||
async function searchRemissionToPay() {
|
|
||||||
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
|
|
||||||
if (!folio) { showToast('Ingresa un folio valido'); return; }
|
|
||||||
pendingRemissionToPay = null;
|
|
||||||
try {
|
|
||||||
const sale = await api('/pos/api/sales/' + folio);
|
|
||||||
if (sale.status !== 'pending_payment') {
|
|
||||||
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
|
|
||||||
document.getElementById('payRemissionActions').style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingRemissionToPay = sale;
|
|
||||||
let itemsHtml = (sale.items || []).map(it => `
|
|
||||||
<div class="ticket-line">
|
|
||||||
<span class="qty">${it.quantity}</span>
|
|
||||||
<span class="name">${it.name || ''}</span>
|
|
||||||
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
document.getElementById('payRemissionDetail').innerHTML = `
|
|
||||||
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
|
|
||||||
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
|
|
||||||
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
|
|
||||||
<hr class="divider">
|
|
||||||
${itemsHtml}
|
|
||||||
`;
|
|
||||||
document.getElementById('payRemissionActions').style.display = '';
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
|
||||||
document.getElementById('payRemissionActions').style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmPayRemission() {
|
|
||||||
if (!pendingRemissionToPay) return;
|
|
||||||
const sale = pendingRemissionToPay;
|
|
||||||
const paymentMethod = document.getElementById('payRemissionMethod').value;
|
|
||||||
let amountPaid = parseFloat(sale.total);
|
|
||||||
let paymentDetails = [];
|
|
||||||
let reference = '';
|
|
||||||
|
|
||||||
if (paymentMethod === 'efectivo') {
|
|
||||||
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
|
|
||||||
if (received < sale.total) { alert('Monto insuficiente'); return; }
|
|
||||||
amountPaid = received;
|
|
||||||
} else if (paymentMethod === 'mixto') {
|
|
||||||
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
|
|
||||||
let sum = 0;
|
|
||||||
rows.forEach(row => {
|
|
||||||
const method = row.querySelector('select').value;
|
|
||||||
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
|
|
||||||
const ref = row.querySelectorAll('input')[1]?.value || '';
|
|
||||||
if (amount > 0) {
|
|
||||||
paymentDetails.push({ method, amount, reference: ref });
|
|
||||||
sum += amount;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
|
|
||||||
amountPaid = sum;
|
|
||||||
} else {
|
|
||||||
reference = document.getElementById('payRemissionReference').value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
payment_method: paymentMethod,
|
|
||||||
amount_paid: amountPaid,
|
|
||||||
payment_details: paymentDetails,
|
|
||||||
register_id: currentRegister ? currentRegister.id : null,
|
|
||||||
reference: reference,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
closePayRemissionModal();
|
|
||||||
showToast(`Nota NR-${sale.id} pagada`);
|
|
||||||
// Refresh sale object to print paid ticket
|
|
||||||
const updated = await api('/pos/api/sales/' + sale.id);
|
|
||||||
lastSaleId = updated.id;
|
|
||||||
lastSaleData = updated;
|
|
||||||
showTicket(updated);
|
|
||||||
} catch (e) {
|
|
||||||
alert('Error al cobrar nota: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePayRemissionMethod() {
|
|
||||||
const method = document.getElementById('payRemissionMethod').value;
|
|
||||||
const cashEl = document.getElementById('payRemissionCash');
|
|
||||||
const refEl = document.getElementById('payRemissionRef');
|
|
||||||
const mixedEl = document.getElementById('payRemissionMixed');
|
|
||||||
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
|
|
||||||
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
|
|
||||||
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Quotation ───────────────────────
|
// ─── Quotation ───────────────────────
|
||||||
async function saveQuotation() {
|
async function saveQuotation() {
|
||||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
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 = {
|
const body = {
|
||||||
items: cart.map(item => ({
|
items: cart.map(item => ({
|
||||||
@@ -1351,13 +1057,7 @@ const POS = (() => {
|
|||||||
|
|
||||||
// ─── Layaway ─────────────────────────
|
// ─── Layaway ─────────────────────────
|
||||||
async function createLayaway() {
|
async function createLayaway() {
|
||||||
if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
|
|
||||||
if (cart.length === 0) { alert('Carrito vacio'); 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; }
|
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
|
||||||
|
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
@@ -1397,138 +1097,6 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Service Order from POS ──────────
|
|
||||||
function createServiceOrder() {
|
|
||||||
if (!workshopModuleEnabled) { showToast('El módulo de taller no está habilitado'); return; }
|
|
||||||
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
|
|
||||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
|
||||||
const zeroItem = findZeroPriceItem();
|
|
||||||
if (zeroItem) {
|
|
||||||
showToast('No está permitido generar orden con artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const custInput = document.getElementById('soCustomer');
|
|
||||||
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
|
|
||||||
document.getElementById('serviceOrderModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeServiceOrderModal() {
|
|
||||||
document.getElementById('serviceOrderModal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmServiceOrder() {
|
|
||||||
const customerId = currentCustomer ? currentCustomer.id : null;
|
|
||||||
if (!customerId) { alert('Seleccione un cliente para la orden de servicio'); return; }
|
|
||||||
|
|
||||||
const vehicleText = document.getElementById('soVehicle').value.trim();
|
|
||||||
const delivery = document.getElementById('soDelivery').value;
|
|
||||||
const notes = document.getElementById('soNotes').value.trim();
|
|
||||||
const isDirect = document.getElementById('soDirect').checked;
|
|
||||||
|
|
||||||
const items = cart.map(item => ({
|
|
||||||
inventory_id: item.inventory_id,
|
|
||||||
part_number: item.part_number,
|
|
||||||
name: item.name,
|
|
||||||
quantity: item.quantity,
|
|
||||||
unit_price: item.unit_price,
|
|
||||||
status: 'pending'
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const order = await api('/pos/api/service-orders/from-pos', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
customer_id: customerId,
|
|
||||||
priority: 'normal',
|
|
||||||
reception_notes: (vehicleText ? 'Vehiculo: ' + vehicleText + '. ' : '') + (notes || ''),
|
|
||||||
delivery_method: delivery || null,
|
|
||||||
is_direct: isDirect,
|
|
||||||
items: items
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
showToast(`Orden de servicio ${order.order_number} creada`);
|
|
||||||
closeServiceOrderModal();
|
|
||||||
cart = [];
|
|
||||||
selectedRow = -1;
|
|
||||||
renderCart();
|
|
||||||
showServiceOrderTicket(order);
|
|
||||||
} catch (e) {
|
|
||||||
alert('Error: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showServiceOrderTicket(order) {
|
|
||||||
const dateStr = new Date(order.created_at).toLocaleString('es-MX', {
|
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit'
|
|
||||||
});
|
|
||||||
|
|
||||||
const customerName = order.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
|
||||||
let itemsHtml = '';
|
|
||||||
(order.items || []).forEach(item => {
|
|
||||||
const itemTotal = item.unit_price * item.quantity;
|
|
||||||
itemsHtml += `
|
|
||||||
<div class="item-line-wide ticket-line">
|
|
||||||
<span class="qty">${item.quantity}</span>
|
|
||||||
<span class="name">${item.name || ''}</span>
|
|
||||||
<span class="price">${fmt(item.unit_price)}</span>
|
|
||||||
<span class="subtotal">${fmt(itemTotal)}</span>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const ticketHtml = `
|
|
||||||
<div class="store-name">NEXUS AUTOPARTS</div>
|
|
||||||
<div class="store-tagline">Orden de servicio</div>
|
|
||||||
<div class="store-info">
|
|
||||||
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
|
|
||||||
RFC: NAU210315XX1
|
|
||||||
</div>
|
|
||||||
<hr class="divider-double">
|
|
||||||
<div class="folio-line">
|
|
||||||
<span>ORDEN: ${order.order_number}</span>
|
|
||||||
<span>${dateStr}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
|
||||||
<span>Cliente: ${customerName}</span>
|
|
||||||
</div>
|
|
||||||
<hr class="divider">
|
|
||||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
|
||||||
<span class="qty">Cant</span>
|
|
||||||
<span class="name">Descripcion</span>
|
|
||||||
<span class="price">P. Unit</span>
|
|
||||||
<span class="subtotal">Importe</span>
|
|
||||||
</div>
|
|
||||||
<hr class="divider" style="margin: 2px 0;">
|
|
||||||
${itemsHtml}
|
|
||||||
<hr class="divider-double">
|
|
||||||
<div class="total-section">
|
|
||||||
<div class="total-line">
|
|
||||||
<span>Refacciones:</span><span>${fmt(order.total_parts || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="total-line">
|
|
||||||
<span>Mano de obra:</span><span>${fmt(order.total_labor || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="total-line grand">
|
|
||||||
<span>TOTAL:</span><span>${fmt(order.total || 0)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr class="divider">
|
|
||||||
<div class="footer-section">
|
|
||||||
<div class="thanks">Gracias por su preferencia!</div>
|
|
||||||
<div>Conserve su ticket como comprobante.</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const printArea = document.getElementById('ticketContent');
|
|
||||||
if (printArea) printArea.innerHTML = ticketHtml;
|
|
||||||
const preview = document.getElementById('ticketPreviewContent');
|
|
||||||
if (preview) preview.innerHTML = ticketHtml;
|
|
||||||
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
|
||||||
if (modalHeader) modalHeader.textContent = 'Ticket de Orden de Servicio';
|
|
||||||
document.getElementById('ticketModal').classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Ticket ──────────────────────────
|
// ─── Ticket ──────────────────────────
|
||||||
function showTicket(sale) {
|
function showTicket(sale) {
|
||||||
const dateStr = new Date(sale.created_at).toLocaleString('es-MX', {
|
const dateStr = new Date(sale.created_at).toLocaleString('es-MX', {
|
||||||
@@ -1536,15 +1104,14 @@ const POS = (() => {
|
|||||||
hour: '2-digit', minute: '2-digit'
|
hour: '2-digit', minute: '2-digit'
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRemission = sale.status === 'pending_payment';
|
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
|
||||||
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
|
||||||
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
|
|
||||||
|
|
||||||
let itemsHtml = '';
|
let itemsHtml = '';
|
||||||
(sale.items || []).forEach(item => {
|
(sale.items || []).forEach(item => {
|
||||||
const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
|
const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
|
||||||
itemsHtml += `
|
itemsHtml += `
|
||||||
<div class="item-line-wide ticket-line">
|
<div class="item-line-wide">
|
||||||
<span class="qty">${item.quantity}</span>
|
<span class="qty">${item.quantity}</span>
|
||||||
<span class="name">${item.name || ''}</span>
|
<span class="name">${item.name || ''}</span>
|
||||||
<span class="price">${fmt(item.unit_price)}</span>
|
<span class="price">${fmt(item.unit_price)}</span>
|
||||||
@@ -1552,48 +1119,22 @@ const POS = (() => {
|
|||||||
</div>`;
|
</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 = `
|
const ticketHtml = `
|
||||||
${logoHtml}
|
<div class="store-name">NEXUS AUTOPARTS</div>
|
||||||
<div class="store-name">${storeName}</div>
|
<div class="store-tagline">Tu conexion con las refacciones</div>
|
||||||
${storeTagline ? `<div class="store-tagline">${storeTagline}</div>` : ''}
|
|
||||||
<div class="store-info">
|
<div class="store-info">
|
||||||
${storeInfoLines.join('<br>')}
|
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
|
||||||
|
RFC: NAU210315XX1
|
||||||
</div>
|
</div>
|
||||||
<hr class="divider-double">
|
<hr class="divider-double">
|
||||||
<div class="folio-line">
|
<div class="folio-line">
|
||||||
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
|
<span>VENTA: V-${sale.id}</span>
|
||||||
<span>${dateStr}</span>
|
<span>${dateStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
||||||
<span>Cliente: ${customerName}</span>
|
<span>Cliente: ${customerName}</span>
|
||||||
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
||||||
</div>
|
</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">
|
<hr class="divider">
|
||||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
||||||
<span class="qty">Cant</span>
|
<span class="qty">Cant</span>
|
||||||
@@ -1609,20 +1150,15 @@ const POS = (() => {
|
|||||||
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
|
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
|
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
|
||||||
${showIva ? `<div class="total-line"><span>IVA:</span><span>${fmt(sale.tax_total)}</span></div>` : ''}
|
<div class="total-line">
|
||||||
|
<span>IVA 16%:</span><span>${fmt(sale.tax_total)}</span>
|
||||||
|
</div>
|
||||||
<div class="total-line grand">
|
<div class="total-line grand">
|
||||||
<span>TOTAL:</span><span>${fmt(sale.total)}</span>
|
<span>TOTAL:</span><span>${fmt(sale.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${showPayment ? `
|
|
||||||
<hr class="divider">
|
<hr class="divider">
|
||||||
<div class="payment-section">
|
<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">
|
<div class="ticket-row">
|
||||||
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
|
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1633,13 +1169,11 @@ const POS = (() => {
|
|||||||
<div class="ticket-row" style="font-weight: bold;">
|
<div class="ticket-row" style="font-weight: bold;">
|
||||||
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
|
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
`}
|
</div>
|
||||||
</div>` : ''}
|
|
||||||
<hr class="divider">
|
<hr class="divider">
|
||||||
<div class="footer-section">
|
<div class="footer-section">
|
||||||
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
|
<div class="thanks">Gracias por su compra!</div>
|
||||||
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
|
<div>Conserve su ticket como comprobante.</div>
|
||||||
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1648,8 +1182,6 @@ const POS = (() => {
|
|||||||
if (printArea) printArea.innerHTML = ticketHtml;
|
if (printArea) printArea.innerHTML = ticketHtml;
|
||||||
const preview = document.getElementById('ticketPreviewContent');
|
const preview = document.getElementById('ticketPreviewContent');
|
||||||
if (preview) preview.innerHTML = ticketHtml;
|
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');
|
document.getElementById('ticketModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -1659,84 +1191,11 @@ const POS = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function printTicket() {
|
function printTicket() {
|
||||||
const src = document.getElementById('ticketContent');
|
// Make print area visible for @media print
|
||||||
if (!src) { console.warn('printTicket: ticketContent not found'); return; }
|
const area = document.getElementById('ticketPrintArea');
|
||||||
|
if (area) area.style.display = 'block';
|
||||||
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();
|
window.print();
|
||||||
}
|
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
|
||||||
}, 200);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('printTicket error:', e);
|
|
||||||
alert('No se pudo preparar la impresion del ticket. Revise la consola.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Thermal Printing ─────────────────
|
// ─── Thermal Printing ─────────────────
|
||||||
@@ -1758,8 +1217,7 @@ const POS = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
|
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
|
||||||
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
|
const ok = await NexusPrinter.printSale(lastSaleId);
|
||||||
const ok = await NexusPrinter.printSale(lastSaleId, paperWidth);
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
showToast('Ticket enviado a impresora termica');
|
showToast('Ticket enviado a impresora termica');
|
||||||
} else {
|
} else {
|
||||||
@@ -1828,10 +1286,6 @@ const POS = (() => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openDrawer();
|
openDrawer();
|
||||||
break;
|
break;
|
||||||
case 'F7':
|
|
||||||
e.preventDefault();
|
|
||||||
createServiceOrder();
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (document.getElementById('paymentModal').classList.contains('open')) {
|
if (document.getElementById('paymentModal').classList.contains('open')) {
|
||||||
@@ -1840,8 +1294,6 @@ const POS = (() => {
|
|||||||
closeNewCustomerModal();
|
closeNewCustomerModal();
|
||||||
} else if (document.getElementById('ticketModal').classList.contains('open')) {
|
} else if (document.getElementById('ticketModal').classList.contains('open')) {
|
||||||
closeTicketModal();
|
closeTicketModal();
|
||||||
} else if (document.getElementById('serviceOrderModal').classList.contains('open')) {
|
|
||||||
closeServiceOrderModal();
|
|
||||||
} else if (document.querySelector('.confirm-overlay.active')) {
|
} else if (document.querySelector('.confirm-overlay.active')) {
|
||||||
// Close cancel modal
|
// Close cancel modal
|
||||||
const overlay = document.getElementById('overlay-cancelar-venta');
|
const overlay = document.getElementById('overlay-cancelar-venta');
|
||||||
@@ -1909,14 +1361,6 @@ const POS = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register Cmd+K items
|
|
||||||
if (typeof registerCmdKItem === "function") {
|
|
||||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
|
||||||
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
|
|
||||||
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
|
|
||||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API ──────────────────────
|
// ─── Public API ──────────────────────
|
||||||
init();
|
init();
|
||||||
|
|
||||||
@@ -1928,10 +1372,7 @@ const POS = (() => {
|
|||||||
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
||||||
checkout, confirmPayment, closePaymentModal,
|
checkout, confirmPayment, closePaymentModal,
|
||||||
selectPaymentMethod, updateChange, updateMixedTotal,
|
selectPaymentMethod, updateChange, updateMixedTotal,
|
||||||
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
|
creditSale, saveQuotation, createLayaway,
|
||||||
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
|
|
||||||
saveQuotation, createLayaway,
|
|
||||||
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
|
|
||||||
showLastSale, openDrawer,
|
showLastSale, openDrawer,
|
||||||
showTicket, closeTicketModal, printTicket,
|
showTicket, closeTicketModal, printTicket,
|
||||||
connectThermal, thermalPrint,
|
connectThermal, thermalPrint,
|
||||||
@@ -1939,5 +1380,12 @@ const POS = (() => {
|
|||||||
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
||||||
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
|
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,25 +54,12 @@ const Reports = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track which tabs have been loaded
|
// Track which tabs have been loaded
|
||||||
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false };
|
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: 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
|
// Theme switcher
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function setTheme(theme) {
|
/* function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
|
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
|
||||||
var btnInd = document.getElementById('btn-industrial');
|
var btnInd = document.getElementById('btn-industrial');
|
||||||
@@ -80,7 +67,15 @@ const Reports = (() => {
|
|||||||
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
|
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
|
||||||
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
|
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
|
||||||
}
|
}
|
||||||
window.setTheme = setTheme;
|
window.setTheme = setTheme;*/
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
if (window.posSetTheme) window.posSetTheme(theme);
|
||||||
|
var btnInd = document.getElementById('btn-industrial');
|
||||||
|
var btnMod = document.getElementById('btn-modern');
|
||||||
|
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
|
||||||
|
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Tab switcher with lazy loading
|
// Tab switcher with lazy loading
|
||||||
@@ -99,7 +94,6 @@ const Reports = (() => {
|
|||||||
else if (id === 'clientes') loadClientes();
|
else if (id === 'clientes') loadClientes();
|
||||||
else if (id === 'financieros') loadFinancieros();
|
else if (id === 'financieros') loadFinancieros();
|
||||||
else if (id === 'historico') loadHistorico();
|
else if (id === 'historico') loadHistorico();
|
||||||
else if (id === 'cortes') loadCortes();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.switchTab = switchTab;
|
window.switchTab = switchTab;
|
||||||
@@ -518,7 +512,7 @@ const Reports = (() => {
|
|||||||
agingEl.innerHTML = spinner();
|
agingEl.innerHTML = spinner();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch('/pos/api/accounting/aging-summary');
|
var data = await apiFetch('/pos/api/accounting/aging');
|
||||||
var clients = data.data || [];
|
var clients = data.data || [];
|
||||||
var totals = data.totals || {};
|
var totals = data.totals || {};
|
||||||
|
|
||||||
@@ -751,153 +745,6 @@ const Reports = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// TAB 6: CORTES DE CAJA
|
|
||||||
// =========================================================================
|
|
||||||
async function loadCortes() {
|
|
||||||
loaded.cortes = true;
|
|
||||||
var dateFrom = document.getElementById('cortes-date-from').value;
|
|
||||||
var dateTo = document.getElementById('cortes-date-to').value;
|
|
||||||
var employeeId = document.getElementById('cortes-employee').value;
|
|
||||||
|
|
||||||
// Cashiers/counters without pos.view can only see their own cuts
|
|
||||||
var u = currentUser();
|
|
||||||
if (isLimitedUser()) {
|
|
||||||
employeeId = u.employeeId || '';
|
|
||||||
var empSel = document.getElementById('cortes-employee');
|
|
||||||
if (empSel) empSel.value = employeeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
var kpiEl = document.getElementById('cortes-kpis');
|
|
||||||
var detalleEl = document.getElementById('cortes-detalle');
|
|
||||||
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
|
|
||||||
kpiEl.innerHTML = spinner();
|
|
||||||
detalleEl.innerHTML = spinner();
|
|
||||||
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
|
|
||||||
|
|
||||||
var params = new URLSearchParams();
|
|
||||||
if (dateFrom) params.set('date_from', dateFrom);
|
|
||||||
if (dateTo) params.set('date_to', dateTo);
|
|
||||||
if (employeeId) params.set('employee_id', employeeId);
|
|
||||||
params.set('per_page', '200');
|
|
||||||
|
|
||||||
try {
|
|
||||||
var data = await apiFetch('/pos/api/register/history?' + params.toString());
|
|
||||||
var regs = data.data || [];
|
|
||||||
|
|
||||||
var totalEsperado = 0;
|
|
||||||
var totalCierre = 0;
|
|
||||||
var totalDiferencia = 0;
|
|
||||||
regs.forEach(function(r) {
|
|
||||||
totalEsperado += r.expected_amount || 0;
|
|
||||||
totalCierre += r.closing_amount || 0;
|
|
||||||
totalDiferencia += r.difference || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
kpiEl.innerHTML =
|
|
||||||
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
|
|
||||||
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
|
|
||||||
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
|
|
||||||
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
|
|
||||||
|
|
||||||
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
|
|
||||||
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
|
|
||||||
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
||||||
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
|
|
||||||
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
|
|
||||||
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
|
|
||||||
'<th>Acciones</th>' +
|
|
||||||
'</tr></thead><tbody>';
|
|
||||||
regs.forEach(function(r) {
|
|
||||||
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
|
|
||||||
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
|
|
||||||
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
|
|
||||||
'<td class="td-mono">#' + r.register_number + '</td>' +
|
|
||||||
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
|
|
||||||
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
|
|
||||||
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
|
|
||||||
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
|
|
||||||
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
|
|
||||||
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
|
|
||||||
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
|
|
||||||
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
|
|
||||||
});
|
|
||||||
cHtml += '</tbody></table></div>';
|
|
||||||
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
|
|
||||||
} catch (err) {
|
|
||||||
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
|
|
||||||
detalleEl.innerHTML = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Detail: sales for a selected cash cut
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
async function showCorteVentas(registerId) {
|
|
||||||
var panel = document.getElementById('corte-ventas-detalle');
|
|
||||||
if (!panel) return;
|
|
||||||
panel.style.display = 'block';
|
|
||||||
panel.innerHTML = spinner();
|
|
||||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
|
|
||||||
var methodLabels = {
|
|
||||||
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
|
|
||||||
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
|
|
||||||
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
|
|
||||||
var sales = data.sales || [];
|
|
||||||
var summary = data.summary || {};
|
|
||||||
|
|
||||||
var total = summary.total || 0;
|
|
||||||
var count = summary.count || 0;
|
|
||||||
var byMethod = summary.by_method || {};
|
|
||||||
|
|
||||||
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
|
|
||||||
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
|
|
||||||
|
|
||||||
// Summary by payment method
|
|
||||||
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
|
|
||||||
if (methods.length) {
|
|
||||||
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
|
|
||||||
methods.forEach(function(m) {
|
|
||||||
var label = methodLabels[m[0]] || m[0];
|
|
||||||
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
|
|
||||||
});
|
|
||||||
hHtml += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
||||||
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
|
|
||||||
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
|
|
||||||
'<th class="align-right">Total</th><th>Estado</th>' +
|
|
||||||
'</tr></thead><tbody>';
|
|
||||||
sales.forEach(function(s) {
|
|
||||||
var statusPill = s.status === 'completed' ? 'pill--success' :
|
|
||||||
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
|
|
||||||
var statusLabel = s.status === 'completed' ? 'Completada' :
|
|
||||||
s.status === 'cancelled' ? 'Cancelada' : s.status;
|
|
||||||
var method = methodLabels[s.payment_method] || s.payment_method || '--';
|
|
||||||
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
|
|
||||||
'<td>' + fmtDateTime(s.created_at) + '</td>' +
|
|
||||||
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
|
|
||||||
'<td>' + method + '</td>' +
|
|
||||||
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
|
|
||||||
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
|
|
||||||
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
|
|
||||||
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
|
|
||||||
});
|
|
||||||
hHtml += '</tbody></table></div>';
|
|
||||||
|
|
||||||
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
|
|
||||||
} catch (err) {
|
|
||||||
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.showCorteVentas = showCorteVentas;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -914,9 +761,6 @@ const Reports = (() => {
|
|||||||
updateClock();
|
updateClock();
|
||||||
setInterval(updateClock, 1000);
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
var u = currentUser();
|
|
||||||
var limited = isLimitedUser();
|
|
||||||
|
|
||||||
// Set default date range: first day of current month to today
|
// Set default date range: first day of current month to today
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
@@ -925,49 +769,6 @@ const Reports = (() => {
|
|||||||
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
|
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
|
||||||
if (toEl) toEl.value = now.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
|
// Populate financial period selectors
|
||||||
var monthSel = document.getElementById('fin-month');
|
var monthSel = document.getElementById('fin-month');
|
||||||
var yearSel = document.getElementById('fin-year');
|
var yearSel = document.getElementById('fin-year');
|
||||||
@@ -991,18 +792,15 @@ const Reports = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the default active tab (ventas) only for privileged users
|
// Load the default active tab (ventas)
|
||||||
if (!limited) {
|
|
||||||
loadVentas();
|
loadVentas();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init, setTheme, switchTab,
|
init, setTheme, switchTab,
|
||||||
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
|
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
|
||||||
showCorteVentas, fmt
|
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
var initials = u.initials || '?';
|
var initials = u.initials || '?';
|
||||||
var currentPath = window.location.pathname;
|
var currentPath = window.location.pathname;
|
||||||
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
|
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
|
||||||
|
var currentLang = localStorage.getItem('pos_lang') || 'es';
|
||||||
|
|
||||||
var modules = {};
|
var modules = {};
|
||||||
if (modulesOverride && typeof modulesOverride === 'object') {
|
if (modulesOverride && typeof modulesOverride === 'object') {
|
||||||
@@ -30,73 +31,30 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
return modules[key] !== false;
|
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 = [
|
var navSections = [
|
||||||
{ label: _t('nav_main'), items: [
|
{ label: _t('nav_main'), items: [
|
||||||
{ id: 'dashboard', name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
|
{ 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"/>' },
|
{ 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,
|
moduleEnabled('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"/>' },
|
{ 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); })},
|
].filter(Boolean)},
|
||||||
{ label: _t('nav_management'), items: [
|
{ label: _t('nav_management'), items: [
|
||||||
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
{ 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"/>' },
|
{ 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"/>' },
|
||||||
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,
|
{ 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"/>' },
|
||||||
{ 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') ? { 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('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') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : 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,
|
{ 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: '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"/>' },
|
{ 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: '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"/>' },
|
{ 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"/>' },
|
||||||
{ 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"/>' },
|
{ 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') ? { 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,
|
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).filter(function(i){ return itemAllowed(i.id); })},
|
].filter(Boolean)},
|
||||||
{ label: _t('nav_system'), items: [
|
{ label: _t('nav_system'), items: [
|
||||||
{ id: 'config', name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
|
{ 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) {
|
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>';
|
return '<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>';
|
||||||
}
|
}
|
||||||
@@ -124,6 +82,16 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
+ '</button>'
|
+ '</button>'
|
||||||
+ '</div>';
|
+ '</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() {
|
window.updateThemeButtons = function() {
|
||||||
var t = localStorage.getItem('pos_theme') || 'industrial';
|
var t = localStorage.getItem('pos_theme') || 'industrial';
|
||||||
document.querySelectorAll('.theme-toggle-btn').forEach(function(b, i) {
|
document.querySelectorAll('.theme-toggle-btn').forEach(function(b, i) {
|
||||||
@@ -141,6 +109,7 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<nav class="sidebar__nav">' + navHtml + '</nav>'
|
+ '<nav class="sidebar__nav">' + navHtml + '</nav>'
|
||||||
+ themeHtml
|
+ themeHtml
|
||||||
|
+ langHtml
|
||||||
+ '<div class="sidebar__footer">'
|
+ '<div class="sidebar__footer">'
|
||||||
+ ' <div class="sidebar__user-avatar">' + initials + '</div>'
|
+ ' <div class="sidebar__user-avatar">' + initials + '</div>'
|
||||||
+ ' <div class="sidebar__user-info">'
|
+ ' <div class="sidebar__user-info">'
|
||||||
@@ -153,14 +122,14 @@ window.renderSidebar = function(modulesOverride) {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|
||||||
// Replace existing sidebar
|
// Replace existing sidebar
|
||||||
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
|
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.className = 'pos-sidebar sidebar';
|
existing.className = 'pos-sidebar';
|
||||||
existing.innerHTML = sidebarHtml;
|
existing.innerHTML = sidebarHtml;
|
||||||
existing.removeAttribute('style');
|
existing.removeAttribute('style');
|
||||||
} else {
|
} else {
|
||||||
var el = document.createElement('aside');
|
var el = document.createElement('aside');
|
||||||
el.className = 'pos-sidebar sidebar';
|
el.className = 'pos-sidebar';
|
||||||
el.innerHTML = sidebarHtml;
|
el.innerHTML = sidebarHtml;
|
||||||
document.body.insertBefore(el, document.body.firstChild);
|
document.body.insertBefore(el, document.body.firstChild);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,12 @@
|
|||||||
// /home/Autopartes/pos/static/pwa/sw.js
|
// /home/Autopartes/pos/static/pwa/sw.js
|
||||||
// Nexus POS — Service Worker
|
// Nexus POS — Service Worker v17
|
||||||
// Self-contained vanilla JS. No external imports.
|
// Self-contained vanilla JS. No external imports.
|
||||||
//
|
//
|
||||||
// Bump VERSION whenever static assets change significantly.
|
// Bump CACHE_NAME whenever static assets change significantly.
|
||||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||||
// so templates can use cache-busting query params freely.
|
// so templates can use cache-busting query params freely.
|
||||||
|
|
||||||
const VERSION = 54;
|
const CACHE_NAME = 'nexus-pos-v20';
|
||||||
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
'/pos/static/css/tokens.css',
|
'/pos/static/css/tokens.css',
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/accounting.css?v=33"></head>
|
<link rel="stylesheet" href="/pos/static/css/accounting.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -227,18 +228,19 @@
|
|||||||
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<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..." />
|
<input type="text" placeholder="Buscar cliente o factura..." />
|
||||||
</div>
|
</div>
|
||||||
<select id="cxc-status-filter" class="select-filter" onchange="Accounting.loadAging()">
|
<select class="select-filter">
|
||||||
<option value="all">Todos los estados</option>
|
<option>Todos los estados</option>
|
||||||
<option value="pending">Vigente</option>
|
<option>Vigente</option>
|
||||||
<option value="overdue">Vencida</option>
|
<option>Vencida</option>
|
||||||
<option value="partial">Parcial</option>
|
<option>Parcial</option>
|
||||||
<option value="ok">Pagada</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select class="select-filter" title="Filtro de sucursal (próximamente)">
|
<select class="select-filter">
|
||||||
<option>Todas las sucursales</option>
|
<option>Todas las sucursales</option>
|
||||||
|
<option>Matriz</option>
|
||||||
|
<option>Sucursal Norte</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
<button class="btn btn--ghost btn--sm" onclick="window.exportarCuentasPorCobrar()">
|
<button class="btn btn--ghost btn--sm">
|
||||||
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
<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
|
Exportar
|
||||||
</button>
|
</button>
|
||||||
@@ -279,15 +281,13 @@
|
|||||||
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<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..." />
|
<input type="text" placeholder="Buscar proveedor o factura..." />
|
||||||
</div>
|
</div>
|
||||||
<select id="cxp-status-filter" class="select-filter" onchange="Accounting.loadAccountsPayable()">
|
<select class="select-filter">
|
||||||
<option value="all">Todos los estados</option>
|
<option>Todos los estados</option>
|
||||||
<option value="pending">Vigente</option>
|
<option>Vigente</option>
|
||||||
<option value="overdue">Vencida</option>
|
<option>Vencida</option>
|
||||||
<option value="partial">Parcial</option>
|
|
||||||
<option value="ok">Pagada</option>
|
|
||||||
</select>
|
</select>
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
<button id="cxp-register-payment" class="btn btn--primary btn--sm" onclick="Accounting.registerPayablePayment()">
|
<button class="btn btn--primary btn--sm">
|
||||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<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
|
Registrar Pago
|
||||||
</button>
|
</button>
|
||||||
@@ -424,11 +424,13 @@
|
|||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="tab-panel" id="panel-cierre">
|
<div class="tab-panel" id="panel-cierre">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<select id="cierre-period-filter" class="select-filter" title="Período a cerrar (próximamente)">
|
<select class="select-filter">
|
||||||
<option>Seleccionar período</option>
|
<option>Marzo 2026</option>
|
||||||
|
<option>Febrero 2026 (cerrado)</option>
|
||||||
|
<option>Enero 2026 (cerrado)</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
<button id="cierre-run-btn" class="btn btn--primary" onclick="Accounting.runPeriodClose()">
|
<button class="btn btn--primary">
|
||||||
<svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
<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
|
Ejecutar Cierre
|
||||||
</button>
|
</button>
|
||||||
@@ -476,7 +478,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="entryLines">
|
<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;">
|
<div class="entry-line" style="display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;">
|
||||||
<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select>
|
<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
|
||||||
<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
|
<input type="number" placeholder="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);" />
|
<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>
|
<button class="btn btn--ghost btn--sm" onclick="this.closest('.entry-line').remove()">×</button>
|
||||||
@@ -492,12 +494,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/accounting.v9.js?v=33" defer></script>
|
<script src="/pos/static/js/accounting.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>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/pwa-install.js" defer></script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>Catalogo — Nexus Autoparts POS</title>
|
<title>Catalogo — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.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/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
<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>
|
<body>
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="search-bar" id="searchBar">
|
<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>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" aria-label="Buscar productos" />
|
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
|
||||||
<button type="button" id="btnScanBarcode" title="Escanear codigo de barras" style="background:none;border:none;cursor:pointer;padding:4px 8px;color:var(--color-text-muted);display:flex;align-items:center;" onclick="CatalogApp.startBarcodeScan()">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -194,7 +195,7 @@
|
|||||||
<!-- Level title + optional filter -->
|
<!-- Level title + optional filter -->
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); flex-wrap:wrap;">
|
<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>
|
<h2 class="level-title" id="levelTitle">Selecciona una marca</h2>
|
||||||
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" aria-label="Filtrar niveles de catalogo" />
|
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
<!-- Loading spinner -->
|
||||||
@@ -314,17 +315,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/catalog.js?v=33" defer></script>
|
<script src="/pos/static/js/catalog.js?v=8" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/chat.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/sync-engine.js" defer></script>
|
||||||
<script src="/pos/static/js/onboarding.js?v=33" defer></script>
|
<script src="/pos/static/js/onboarding.js?v=2" defer></script>
|
||||||
<script>
|
<script>
|
||||||
if('serviceWorker' in navigator){
|
if('serviceWorker' in navigator){
|
||||||
navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'}).then(function(reg){
|
navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'}).then(function(reg){
|
||||||
@@ -340,6 +341,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
<script src="/pos/static/js/brand-catalog.js?v=33" defer></script>
|
<script src="/pos/static/js/brand-catalog.js?v=10" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,45 +8,14 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/config.css?v=34">
|
<link rel="stylesheet" href="/pos/static/css/config.css?v=2">
|
||||||
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -161,7 +130,7 @@
|
|||||||
<h1 class="page-header__title">Configuración</h1>
|
<h1 class="page-header__title">Configuración</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header__actions">
|
<div class="page-header__actions">
|
||||||
<button id="btn-save-all" class="btn btn--primary" type="button">
|
<button class="btn btn--primary">
|
||||||
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
<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
|
Guardar Cambios
|
||||||
</button>
|
</button>
|
||||||
@@ -170,20 +139,11 @@
|
|||||||
|
|
||||||
<!-- Scrollable Content -->
|
<!-- Scrollable Content -->
|
||||||
<div class="content-scroll">
|
<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
|
SECTION 1: APARIENCIA / TEMA
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="general">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
@@ -246,7 +206,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 2: DATOS DE LA EMPRESA
|
SECTION 2: DATOS DE LA EMPRESA
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="general">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
@@ -295,88 +255,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 3: PERSONALIZACIÓN DE TICKET
|
SECTION 3: MÓDULOS E INTEGRACIONES
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="general">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
|
||||||
<div class="settings-section__icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><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__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||||
@@ -428,26 +309,6 @@
|
|||||||
<span class="toggle__slider"></span>
|
<span class="toggle__slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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;">
|
<div style="margin-top:var(--space-4);text-align:right;">
|
||||||
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
|
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,13 +318,13 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 4: USUARIOS Y PERMISOS
|
SECTION 4: USUARIOS Y PERMISOS
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="employees">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<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>
|
<div>
|
||||||
<div class="settings-section__title">Empleados</div>
|
<div class="settings-section__title">Usuarios y Permisos</div>
|
||||||
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
|
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,7 +361,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 4: IMPRESORAS
|
SECTION 4: IMPRESORAS
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="general">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||||
@@ -526,7 +387,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 5: SUCURSALES
|
SECTION 5: SUCURSALES
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="fiscal">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
@@ -545,7 +406,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 6: PARÁMETROS FISCALES
|
SECTION 6: PARÁMETROS FISCALES
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="fiscal">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||||
@@ -681,7 +542,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 7: PREFERENCIAS DEL SISTEMA
|
SECTION 7: PREFERENCIAS DEL SISTEMA
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="general">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
@@ -753,33 +614,13 @@
|
|||||||
<span class="toggle__slider"></span>
|
<span class="toggle__slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 8: VEHICLE COMPATIBILITY SOURCE
|
SECTION 8: VEHICLE COMPATIBILITY SOURCE
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="catalog">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||||
@@ -812,7 +653,7 @@
|
|||||||
<!-- ===============================================================
|
<!-- ===============================================================
|
||||||
SECTION 9: MARCAS DE PARTES PERMITIDAS
|
SECTION 9: MARCAS DE PARTES PERMITIDAS
|
||||||
=============================================================== -->
|
=============================================================== -->
|
||||||
<div class="settings-section" data-tab="catalog">
|
<div class="settings-section">
|
||||||
<div class="settings-section__header">
|
<div class="settings-section__header">
|
||||||
<div class="settings-section__icon">
|
<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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
|
||||||
@@ -873,64 +714,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
</div><!-- /content-scroll -->
|
||||||
</main>
|
</main>
|
||||||
</div><!-- /app-shell -->
|
</div><!-- /app-shell -->
|
||||||
@@ -1039,11 +822,8 @@
|
|||||||
<option value="">-- Seleccionar --</option>
|
<option value="">-- Seleccionar --</option>
|
||||||
<option value="admin">Administrador</option>
|
<option value="admin">Administrador</option>
|
||||||
<option value="cashier">Cajero</option>
|
<option value="cashier">Cajero</option>
|
||||||
<option value="counter">Mostrador</option>
|
|
||||||
<option value="warehouse">Almacenista</option>
|
<option value="warehouse">Almacenista</option>
|
||||||
<option value="accountant">Contador</option>
|
<option value="accountant">Contador</option>
|
||||||
<option value="workshop">Taller</option>
|
|
||||||
<option value="mechanic">Mecanico</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -1069,13 +849,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/config.js?v=41" defer></script>
|
<script src="/pos/static/js/config.js?v=3" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script 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',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/customers.css?v=34"></head>
|
<link rel="stylesheet" href="/pos/static/css/customers.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -297,15 +298,15 @@
|
|||||||
<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
|
<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" aria-label="Buscar clientes" />
|
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" />
|
||||||
</div>
|
</div>
|
||||||
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter" aria-label="Filtrar por tipo de cliente">
|
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter">
|
||||||
<option value="">Todos los tipos</option>
|
<option value="">Todos los tipos</option>
|
||||||
<option value="Taller">Taller</option>
|
<option value="Taller">Taller</option>
|
||||||
<option value="Mostrador">Mostrador</option>
|
<option value="Mostrador">Mostrador</option>
|
||||||
<option value="Mayoreo">Mayoreo</option>
|
<option value="Mayoreo">Mayoreo</option>
|
||||||
</select>
|
</select>
|
||||||
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter" aria-label="Filtrar por estado de cliente">
|
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter">
|
||||||
<option value="">Todos los estados</option>
|
<option value="">Todos los estados</option>
|
||||||
<option value="Activo">Activo</option>
|
<option value="Activo">Activo</option>
|
||||||
<option value="Inactivo">Inactivo</option>
|
<option value="Inactivo">Inactivo</option>
|
||||||
@@ -341,7 +342,15 @@
|
|||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<div class="table-footer__info" id="tableInfo"></div>
|
<div class="table-footer__info" id="tableInfo"></div>
|
||||||
<div class="pagination" id="customersPagination"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,12 +375,12 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div class="customer-avatar" id="detailAvatar">—</div>
|
<div class="customer-avatar" id="detailAvatar">MA</div>
|
||||||
<div class="detail-header__info">
|
<div class="detail-header__info">
|
||||||
<div class="detail-header__name" id="detailName">—</div>
|
<div class="detail-header__name" id="detailName">MIGUEL ÁNGEL TORRES</div>
|
||||||
<div class="detail-header__rfc" id="detailRFC">—</div>
|
<div class="detail-header__rfc" id="detailRFC">TOAM820115HDF</div>
|
||||||
<div class="detail-header__meta">
|
<div class="detail-header__meta">
|
||||||
<span class="tipo-chip tipo-chip--taller" id="detailTipo">—</span>
|
<span class="tipo-chip tipo-chip--taller" id="detailTipo">Taller</span>
|
||||||
<span class="badge badge--active" id="detailStatus"><span class="badge-dot"></span>Activo</span>
|
<span class="badge badge--active" id="detailStatus"><span class="badge-dot"></span>Activo</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +401,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-row info-row--full">
|
<div class="info-row info-row--full">
|
||||||
<span class="info-label">Dirección</span>
|
<span class="info-label">Dirección</span>
|
||||||
<span class="info-value" id="detailAddress">—</span>
|
<span class="info-value" id="detailAddress">Av. Insurgentes Sur 1602, Col. Crédito Constructor, CDMX</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">CP</span>
|
<span class="info-label">CP</span>
|
||||||
@@ -451,14 +460,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Quick Actions -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<div class="detail-section__title">Acciones Rápidas</div>
|
<div class="detail-section__title">Acciones Rápidas</div>
|
||||||
@@ -497,15 +498,6 @@
|
|||||||
</span>
|
</span>
|
||||||
Historial
|
Historial
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -650,13 +642,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
||||||
<script src="/pos/static/js/customers.js?v=35" defer></script>
|
<script src="/pos/static/js/customers.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=33"></head>
|
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=3">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -158,16 +159,6 @@
|
|||||||
Ventas Históricas
|
Ventas Históricas
|
||||||
</a>
|
</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>
|
<div class="sidebar__section-label">Gestión</div>
|
||||||
|
|
||||||
<a href="/pos/marketplace" class="nav-link">
|
<a href="/pos/marketplace" class="nav-link">
|
||||||
@@ -520,40 +511,6 @@
|
|||||||
</div><!-- end alerts-grid -->
|
</div><!-- end alerts-grid -->
|
||||||
</section>
|
</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
|
RECENT SALES TABLE
|
||||||
================================================================= -->
|
================================================================= -->
|
||||||
@@ -605,15 +562,15 @@
|
|||||||
|
|
||||||
|
|
||||||
<script src="/pos/static/js/chart.umd.min.js" defer></script>
|
<script src="/pos/static/js/chart.umd.min.js" defer></script>
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/dashboard-stats.js?v=33" defer></script>
|
<script src="/pos/static/js/dashboard-stats.js?v=3" defer></script>
|
||||||
<script src="/pos/static/js/dashboard.js?v=33" defer></script>
|
<script src="/pos/static/js/dashboard.js?v=7" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js?v=34',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
<script src="/pos/static/js/chat.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/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.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/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
<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>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
|
|
||||||
@@ -148,12 +149,12 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/diagrams.js" defer></script>
|
<script src="/pos/static/js/diagrams.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>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/pwa-install.js" defer></script>
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<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>
|
<body>
|
||||||
|
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
@@ -302,11 +303,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/fleet.js" defer></script>
|
<script src="/pos/static/js/fleet.js" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.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>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.pagination span { font-size: 13px; color: #4b5563; }
|
.pagination span { font-size: 13px; color: #4b5563; }
|
||||||
.loading { text-align: center; padding: 40px; color: #6b7280; }
|
.loading { text-align: center; padding: 40px; color: #6b7280; }
|
||||||
</style></head>
|
</style>
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>📊 Ventas Históricas - Atlas</h1>
|
<h1>📊 Ventas Históricas - Atlas</h1>
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34"></head>
|
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=8">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -192,7 +193,7 @@
|
|||||||
<h1 class="page-header__title">Inventario</h1>
|
<h1 class="page-header__title">Inventario</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header__actions">
|
<div class="page-header__actions">
|
||||||
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
||||||
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
<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
|
Importar CSV
|
||||||
</button>
|
</button>
|
||||||
@@ -204,7 +205,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
|
<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
|
Sincronizar
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--primary" id="btnHeaderNewProduct" onclick="showCreateModal()">
|
<button class="btn btn--primary" onclick="showCreateModal()">
|
||||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<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
|
Nuevo Producto
|
||||||
</button>
|
</button>
|
||||||
@@ -336,7 +337,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
|
<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>
|
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--primary btn--sm" id="btnStockNewProduct" onclick="showCreateModal()">
|
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
|
||||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<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
|
Nuevo Producto
|
||||||
</button>
|
</button>
|
||||||
@@ -710,48 +711,42 @@
|
|||||||
|
|
||||||
<!-- ===== MODALS ===== -->
|
<!-- ===== MODALS ===== -->
|
||||||
|
|
||||||
<!-- Create/Edit Item Modal -->
|
<!-- Create Item Modal -->
|
||||||
<div class="inv-modal-overlay" id="createModal">
|
<div class="inv-modal-overlay" id="createModal">
|
||||||
<div class="inv-modal">
|
<div class="inv-modal">
|
||||||
<div class="inv-modal__header">
|
<div class="inv-modal__header">
|
||||||
<h3 id="createModalTitle">Nuevo Producto</h3>
|
<h3>Nuevo Producto</h3>
|
||||||
<button class="inv-modal__close" onclick="closeCreateModal()">×</button>
|
<button class="inv-modal__close" onclick="closeCreateModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-modal__body">
|
<div class="inv-modal__body">
|
||||||
<input type="hidden" id="editItemId" />
|
|
||||||
<div class="inv-form-grid">
|
<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>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>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>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
|
||||||
<div class="inv-field"><label>Categoría</label>
|
<div class="inv-field"><label>Categoría</label>
|
||||||
<select class="select-filter" id="newCategory" style="width:100%;">
|
<select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
|
||||||
<option value="">Sin categoría</option>
|
<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>
|
</select>
|
||||||
</div>
|
</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>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 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>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div>
|
||||||
<div class="inv-field"><label>Unidad</label><input type="text" id="newUnit" placeholder="pza, kit, lt..." /></div>
|
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></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"><label>Precio Mostrador</label><input type="number" id="newPrice1" 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 Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
|
||||||
<div class="inv-field" id="initialStockField"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
|
<div class="inv-field"><label>Stock 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>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>
|
||||||
<div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
|
<div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-modal__footer">
|
<div class="inv-modal__footer">
|
||||||
<button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button>
|
<button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button>
|
||||||
<button class="btn btn--primary" id="createModalBtn" onclick="createItem()">Crear Producto</button>
|
<button class="btn btn--primary" onclick="createItem()">Crear Producto</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1018,9 +1013,6 @@
|
|||||||
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">×</button>
|
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-modal__body">
|
<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;">
|
<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>
|
<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);" />
|
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
|
||||||
@@ -1042,8 +1034,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
|
<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>
|
<strong>Columnas esperadas:</strong>
|
||||||
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, sku_secondary, description, category, make, model, year, engine, engine_code</code>
|
<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, sku_secundario, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
|
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1061,13 +1053,13 @@
|
|||||||
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
|
<script src="/pos/static/js/virtual-scroll.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/inventory.js?v=38" defer></script>
|
<script src="/pos/static/js/inventory.js?v=18" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<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>
|
<body>
|
||||||
|
|
||||||
@@ -338,14 +339,25 @@
|
|||||||
<circle cx="11" cy="11" r="8"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input id="facturas-search" type="text" placeholder="Buscar folio, RFC, cliente…" oninput="Invoicing.filterFacturas()" aria-label="Buscar facturas" />
|
<input type="text" placeholder="Buscar folio, RFC, cliente…" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select id="facturas-status-filter" class="select-filter" aria-label="Filtrar por estatus" onchange="Invoicing.loadFacturas()">
|
<div class="date-range">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
01 Mar 2026 — 31 Mar 2026
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select class="select-filter" aria-label="Filtrar por estatus">
|
||||||
<option value="">Todas</option>
|
<option value="">Todas</option>
|
||||||
<option value="stamped">Timbradas</option>
|
<option value="timbrada">Timbradas</option>
|
||||||
<option value="pending">Pendientes</option>
|
<option value="pendiente">Pendientes</option>
|
||||||
<option value="cancelled">Canceladas</option>
|
<option value="cancelada">Canceladas</option>
|
||||||
|
<option value="ppd">PPD</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
@@ -360,7 +372,7 @@
|
|||||||
Factura Global
|
Factura Global
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button id="facturas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportFacturasCSV()">
|
<button class="btn btn--ghost">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<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"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
@@ -410,10 +422,10 @@
|
|||||||
<circle cx="11" cy="11" r="8"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" placeholder="Buscar nota de crédito…" aria-label="Buscar notas de credito" />
|
<input type="text" placeholder="Buscar nota de crédito…" />
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
<button id="notas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportNotasCSV()">
|
<button class="btn btn--ghost">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<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"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
@@ -421,7 +433,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Exportar
|
Exportar
|
||||||
</button>
|
</button>
|
||||||
<button id="notas-new" class="btn btn--primary" onclick="Invoicing.newCreditNote()">
|
<button class="btn btn--primary">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
@@ -466,9 +478,9 @@
|
|||||||
<circle cx="11" cy="11" r="8"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" placeholder="Buscar complemento de pago…" aria-label="Buscar complementos de pago" />
|
<input type="text" placeholder="Buscar complemento de pago…" />
|
||||||
</div>
|
</div>
|
||||||
<select id="complementos-method-filter" class="select-filter" aria-label="Método de pago" onchange="Invoicing.loadComplementos()">
|
<select class="select-filter" aria-label="Método de pago">
|
||||||
<option value="">Todos los métodos</option>
|
<option value="">Todos los métodos</option>
|
||||||
<option value="03">03 Transferencia electrónica</option>
|
<option value="03">03 Transferencia electrónica</option>
|
||||||
<option value="04">04 Tarjeta de crédito</option>
|
<option value="04">04 Tarjeta de crédito</option>
|
||||||
@@ -477,7 +489,7 @@
|
|||||||
<option value="99">99 Por definir</option>
|
<option value="99">99 Por definir</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="toolbar__spacer"></div>
|
<div class="toolbar__spacer"></div>
|
||||||
<button id="complementos-new" class="btn btn--primary" onclick="Invoicing.newPaymentComplement()">
|
<button class="btn btn--primary">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
@@ -507,7 +519,17 @@
|
|||||||
|
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<span></span>
|
<span></span>
|
||||||
<div class="pagination" id="complementos-pagination"></div>
|
<div class="pagination">
|
||||||
|
<button class="page-btn" aria-label="Anterior">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="page-btn is-active">1</button>
|
||||||
|
<button class="page-btn">2</button>
|
||||||
|
<button class="page-btn">3</button>
|
||||||
|
<button class="page-btn" aria-label="Siguiente">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /panel-complementos -->
|
</div><!-- /panel-complementos -->
|
||||||
@@ -757,47 +779,11 @@
|
|||||||
<span class="form-hint">Régimen del emisor según el SAT</span>
|
<span class="form-hint">Régimen del emisor según el SAT</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="form-field">
|
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
|
||||||
<label class="form-label" for="numero-exterior">Número Exterior</label>
|
<button class="btn btn--ghost">Cancelar</button>
|
||||||
<input class="form-input" id="numero-exterior" type="text" value="" placeholder="Ej: 123" />
|
<button class="btn btn--primary">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="numero-interior">Número Interior</label>
|
|
||||||
<input class="form-input" id="numero-interior" type="text" value="" placeholder="Ej: 4B" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="colonia-fiscal">Colonia</label>
|
|
||||||
<input class="form-input" id="colonia-fiscal" type="text" value="" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="ciudad-fiscal">Ciudad</label>
|
|
||||||
<input class="form-input" id="ciudad-fiscal" type="text" value="" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="municipio-fiscal">Municipio / Alcaldía</label>
|
|
||||||
<input class="form-input" id="municipio-fiscal" type="text" value="" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label" for="estado-fiscal">Estado</label>
|
|
||||||
<input class="form-input" id="estado-fiscal" type="text" value="" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);align-items:center;">
|
|
||||||
<span id="emisor-save-status" style="font-size:var(--text-caption);color:var(--color-text-muted);"></span>
|
|
||||||
<button class="btn btn--ghost" type="button" onclick="Invoicing.loadEmisorData()">Cancelar</button>
|
|
||||||
<button class="btn btn--primary" type="button" onclick="Invoicing.saveEmisorData()">
|
|
||||||
<svg viewBox="0 0 24 24">
|
<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"/>
|
<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="17 21 17 13 7 13 7 21"/>
|
||||||
@@ -902,44 +888,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- CONFIGURACIÓN DE SERIES — full width -->
|
||||||
<div class="config-section" style="grid-column: span 2;">
|
<div class="config-section" style="grid-column: span 2;">
|
||||||
<div class="config-section__header">
|
<div class="config-section__header">
|
||||||
@@ -1137,12 +1085,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/invoicing.js?v=35" defer></script>
|
<script src="/pos/static/js/invoicing.js?v=3" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>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/pwa-install.js" defer></script>
|
||||||
|
|||||||
@@ -155,7 +155,6 @@
|
|||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
const state = {
|
const state = {
|
||||||
selectedUser: null,
|
selectedUser: null,
|
||||||
selectedUserId: null,
|
|
||||||
pin: [],
|
pin: [],
|
||||||
maxPinLength: 6,
|
maxPinLength: 6,
|
||||||
};
|
};
|
||||||
@@ -326,21 +325,18 @@
|
|||||||
localStorage.setItem('pos_device_id', deviceId);
|
localStorage.setItem('pos_device_id', deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional auto-redirect disabled to allow switching users on shared devices.
|
// Auto-redirect if already logged in with valid token
|
||||||
// Users with a valid session can navigate directly to /pos/catalog or /pos/workshop.
|
|
||||||
(function checkExistingSession() {
|
(function checkExistingSession() {
|
||||||
var token = localStorage.getItem('pos_token');
|
var token = localStorage.getItem('pos_token');
|
||||||
if (token && tenantId) {
|
if (token && tenantId) {
|
||||||
try {
|
try {
|
||||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
if (payload.exp * 1000 <= Date.now() + 30000) {
|
if (payload.exp * 1000 > Date.now() + 30000) {
|
||||||
localStorage.removeItem('pos_token');
|
window.location.href = '/pos/catalog';
|
||||||
localStorage.removeItem('pos_employee');
|
return;
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {}
|
||||||
localStorage.removeItem('pos_token');
|
localStorage.removeItem('pos_token');
|
||||||
localStorage.removeItem('pos_employee');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -355,7 +351,6 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tenant_id: parseInt(tenantId),
|
tenant_id: parseInt(tenantId),
|
||||||
employee_id: state.selectedUserId,
|
|
||||||
pin: enteredPin,
|
pin: enteredPin,
|
||||||
device_id: deviceId
|
device_id: deviceId
|
||||||
})
|
})
|
||||||
@@ -379,19 +374,13 @@
|
|||||||
localStorage.setItem('pos_token', result.data.token);
|
localStorage.setItem('pos_token', result.data.token);
|
||||||
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
||||||
localStorage.setItem('pos_tenant_id', tenantId);
|
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.innerHTML = '<span class="btn-login__icon" aria-hidden="true">✓</span> Bienvenido, ' + result.data.employee.name;
|
||||||
btnLogin.style.background = 'var(--color-success)';
|
btnLogin.style.background = 'var(--color-success)';
|
||||||
showToast('¡Acceso concedido! Redirigiendo…');
|
showToast('¡Acceso concedido! Redirigiendo…');
|
||||||
|
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
var role = (result.data.employee.role || '').toLowerCase();
|
|
||||||
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
|
||||||
window.location.href = '/pos/workshop';
|
|
||||||
} else {
|
|
||||||
window.location.href = '/pos/catalog';
|
window.location.href = '/pos/catalog';
|
||||||
}
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
@@ -409,7 +398,6 @@
|
|||||||
|
|
||||||
function resetLoginState() {
|
function resetLoginState() {
|
||||||
state.selectedUser = null;
|
state.selectedUser = null;
|
||||||
state.selectedUserId = null;
|
|
||||||
state.pin = [];
|
state.pin = [];
|
||||||
|
|
||||||
userBtns.forEach(b => {
|
userBtns.forEach(b => {
|
||||||
@@ -499,7 +487,6 @@
|
|||||||
employees.forEach(function(emp) {
|
employees.forEach(function(emp) {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.className = 'user-avatar-btn';
|
btn.className = 'user-avatar-btn';
|
||||||
btn.setAttribute('data-id', emp.id);
|
|
||||||
btn.setAttribute('data-user', emp.initials);
|
btn.setAttribute('data-user', emp.initials);
|
||||||
btn.setAttribute('data-name', emp.name);
|
btn.setAttribute('data-name', emp.name);
|
||||||
btn.setAttribute('data-role', emp.role_label);
|
btn.setAttribute('data-role', emp.role_label);
|
||||||
@@ -519,7 +506,6 @@
|
|||||||
btn.classList.add('selected');
|
btn.classList.add('selected');
|
||||||
btn.setAttribute('aria-checked', 'true');
|
btn.setAttribute('aria-checked', 'true');
|
||||||
state.selectedUser = emp.initials;
|
state.selectedUser = emp.initials;
|
||||||
state.selectedUserId = emp.id;
|
|
||||||
state.pin = [];
|
state.pin = [];
|
||||||
enablePinPad();
|
enablePinPad();
|
||||||
updatePinDisplay();
|
updatePinDisplay();
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<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>
|
<body>
|
||||||
|
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/inventory.css" />
|
<link rel="stylesheet" href="/pos/static/css/inventory.css" />
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
.meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); }
|
.meli-config-row { 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 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); }
|
.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>
|
<body>
|
||||||
|
|
||||||
@@ -342,12 +343,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/marketplace_external.js?v=33" defer></script>
|
<script src="/pos/static/js/marketplace_external.js?v=4" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>Nexus Autoparts — Punto de Venta</title>
|
<title>Nexus Autoparts — Punto de Venta</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.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/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
<script src="/pos/static/js/native-bridge.js" defer></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos.css?v=35"></head>
|
<link rel="stylesheet" href="/pos/static/css/pos.css?v=4">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body class="pos-shell" id="appBody">
|
<body class="pos-shell" id="appBody">
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
<div class="cart-header">
|
<div class="cart-header">
|
||||||
<div class="cart-header__top">
|
<div class="cart-header__top">
|
||||||
<div class="cart-header__sale-id">Venta Activa</div>
|
<div class="cart-header__sale-id">Venta Activa</div>
|
||||||
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" aria-label="Mostrar costo y margen" style="display:none;">C/M</button>
|
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" style="display:none;">C/M</button>
|
||||||
<span class="cart-header__status">Activa</span>
|
<span class="cart-header__status">Activa</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -198,31 +199,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- COBRAR Button -->
|
||||||
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
|
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
|
||||||
<span>COBRAR</span>
|
<span>COBRAR</span>
|
||||||
</button>
|
</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 -->
|
<!-- Secondary Actions -->
|
||||||
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
|
<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.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" 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.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" 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>
|
<button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button>
|
||||||
@@ -254,9 +239,6 @@
|
|||||||
<div class="fkey" onclick="POS.openDrawer()" title="Abrir cajon">
|
<div class="fkey" onclick="POS.openDrawer()" title="Abrir cajon">
|
||||||
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
|
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
|
||||||
</div>
|
</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-sep"></div>
|
||||||
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
|
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
|
||||||
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
|
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
|
||||||
@@ -308,15 +290,6 @@
|
|||||||
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
|
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
|
||||||
Mixto
|
Mixto
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- TAB: Efectivo -->
|
<!-- TAB: Efectivo -->
|
||||||
@@ -352,26 +325,26 @@
|
|||||||
<div class="tab-content" id="mixedPayment">
|
<div class="tab-content" id="mixedPayment">
|
||||||
<div class="mixed-row" style="margin-bottom:var(--space-3);">
|
<div class="mixed-row" style="margin-bottom:var(--space-3);">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" id="label-method-1">Método 1</label>
|
<label class="form-label">Metodo 1</label>
|
||||||
<select id="mixed-method-1" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 1">
|
<select class="form-input" style="margin-bottom:var(--space-2);">
|
||||||
<option value="efectivo">Efectivo</option>
|
<option value="efectivo">Efectivo</option>
|
||||||
<option value="tarjeta">Tarjeta</option>
|
<option value="tarjeta">Tarjeta</option>
|
||||||
<option value="transferencia">Transferencia</option>
|
<option value="transferencia">Transferencia</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="mixed-amount-1" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 1" />
|
<input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
|
||||||
<input id="mixed-ref-1" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 1" />
|
<input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mixed-row" style="margin-bottom:var(--space-3);">
|
<div class="mixed-row" style="margin-bottom:var(--space-3);">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" id="label-method-2">Método 2</label>
|
<label class="form-label">Metodo 2</label>
|
||||||
<select id="mixed-method-2" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 2">
|
<select class="form-input" style="margin-bottom:var(--space-2);">
|
||||||
<option value="tarjeta">Tarjeta</option>
|
<option value="tarjeta">Tarjeta</option>
|
||||||
<option value="efectivo">Efectivo</option>
|
<option value="efectivo">Efectivo</option>
|
||||||
<option value="transferencia">Transferencia</option>
|
<option value="transferencia">Transferencia</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="mixed-amount-2" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 2" />
|
<input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
|
||||||
<input id="mixed-ref-2" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 2" />
|
<input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="split-remaining">
|
<div class="split-remaining">
|
||||||
@@ -380,35 +353,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- CFDI Checkbox -->
|
||||||
<div class="cfdi-check">
|
<div class="cfdi-check">
|
||||||
<input type="checkbox" id="cfdiCheck" />
|
<input type="checkbox" id="cfdiCheck" />
|
||||||
@@ -424,68 +368,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
CANCEL SALE CONFIRMATION MODAL
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
@@ -602,10 +484,10 @@
|
|||||||
<div style="margin-top:var(--space-4);border-top:1px solid var(--color-border);padding-top:var(--space-4);">
|
<div 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 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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);">
|
||||||
<div class="form-group"><label class="form-label">Marca</label><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" aria-label="Marca del vehiculo" /></div>
|
<div class="form-group"><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" /></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"><input type="text" class="form-input" id="ncVehModel" placeholder="Modelo" /></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"><input type="text" class="form-input" id="ncVehYear" placeholder="Ano" /></div>
|
||||||
<div class="form-group"><label class="form-label">Placas</label><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" aria-label="Placas del vehiculo" /></div>
|
<div class="form-group"><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -679,61 +561,17 @@
|
|||||||
================================================================ -->
|
================================================================ -->
|
||||||
<div class="toast-container" id="toastContainer" aria-live="assertive" aria-atomic="true"></div>
|
<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
|
JAVASCRIPT
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/push.js" defer></script>
|
<script src="/pos/static/js/push.js" defer></script>
|
||||||
<script src="/pos/static/js/printer.js" defer></script>
|
<script src="/pos/static/js/printer.js" defer></script>
|
||||||
<script src="/pos/static/js/pos.js?v=42" defer></script>
|
<script src="/pos/static/js/pos.js?v=7" defer></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Cancel sale button wiring
|
// Cancel sale button wiring
|
||||||
|
|||||||
@@ -8,16 +8,17 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.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>
|
<body>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1 class="page-title">Cotizaciones</h1>
|
<h1 class="page-title">Cotizaciones</h1>
|
||||||
|
|||||||
@@ -1,430 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Notas de Remisión — Nexus Autoparts POS</title>
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/remission_notes.css?v=1" />
|
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
|
||||||
<meta name="theme-color" content="#F5A623" />
|
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- THEME BAR -->
|
|
||||||
<header class="theme-bar" role="banner">
|
|
||||||
<div class="theme-bar__left">
|
|
||||||
<div class="theme-bar__store">
|
|
||||||
<span class="theme-bar__dot"></span>
|
|
||||||
Nexus Autoparts
|
|
||||||
</div>
|
|
||||||
<div class="theme-bar__sep"></div>
|
|
||||||
<span class="theme-bar__label">Sucursal Centro — 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,14 +8,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<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>
|
<body>
|
||||||
|
|
||||||
@@ -229,14 +230,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Histórico
|
Histórico
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ==================================================================
|
<!-- ==================================================================
|
||||||
@@ -363,38 +356,6 @@
|
|||||||
<!-- Sales detail table -->
|
<!-- Sales detail table -->
|
||||||
<div class="table-card mb-5" id="historico-detalle"></div>
|
<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>
|
</div>
|
||||||
<!-- End panels -->
|
<!-- End panels -->
|
||||||
|
|
||||||
@@ -405,12 +366,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- End app-shell -->
|
<!-- End app-shell -->
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/reports.js?v=35" defer></script>
|
<script src="/pos/static/js/reports.js?v=3" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>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/pwa-install.js" defer></script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>Catalogo de Proveedores — Nexus Autoparts POS</title>
|
<title>Catalogo de Proveedores — Nexus Autoparts POS</title>
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.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/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
@@ -49,7 +49,8 @@
|
|||||||
.sc-interchange-list { display:flex; flex-wrap:wrap; gap:var(--space-2); }
|
.sc-interchange-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-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; }
|
.sc-close { background:none; border:none; font-size:20px; color:var(--color-text-muted); cursor:pointer; }
|
||||||
</style></head>
|
</style>
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Theme bar -->
|
<!-- Theme bar -->
|
||||||
@@ -126,9 +127,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/supplier_catalog.js?v=33" defer></script>
|
<script src="/pos/static/js/supplier_catalog.js?v=2" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,14 +11,15 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<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>
|
<body>
|
||||||
|
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
@@ -131,12 +132,12 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/whatsapp2.js?v=33" defer></script>
|
<script src="/pos/static/js/whatsapp2.js?v=5" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
|
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -8,19 +8,14 @@
|
|||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
<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/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=44">
|
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=2">
|
||||||
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -33,13 +28,13 @@
|
|||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-header__title-group">
|
<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>
|
<h1 class="page-header__title">Taller</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header__actions">
|
<div class="page-header__actions">
|
||||||
<button class="btn btn--ghost" id="btnCatalog" onclick="Workshop.openCatalogModal()">
|
<button class="btn btn--ghost" id="btnCatalog" onclick="Workshop.openCatalogModal()">
|
||||||
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
|
||||||
Catálogo de servicios
|
Catálogo de servicios
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--primary" id="btnNewOrder" onclick="Workshop.openNewOrderModal()">
|
<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>
|
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
@@ -55,7 +50,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
|
<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>
|
||||||
<div class="summary-card__body">
|
<div class="summary-card__body">
|
||||||
<div class="summary-card__label">Por revisar</div>
|
<div class="summary-card__label">Recibidos</div>
|
||||||
<div class="summary-card__value" id="statReceived">--</div>
|
<div class="summary-card__value" id="statReceived">--</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +59,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
<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>
|
||||||
<div class="summary-card__body">
|
<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 class="summary-card__value" id="statRepair">--</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +68,7 @@
|
|||||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card__body">
|
<div class="summary-card__body">
|
||||||
<div class="summary-card__label">Por entregar</div>
|
<div class="summary-card__label">Listos</div>
|
||||||
<div class="summary-card__value" id="statReady">--</div>
|
<div class="summary-card__value" id="statReady">--</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,81 +83,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Kanban board -->
|
||||||
<div class="kanban-board" id="kanbanBoard" style="display:none;">
|
<div class="kanban-board" id="kanbanBoard">
|
||||||
<!-- Columns injected by JS -->
|
<!-- Columns injected by JS -->
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -193,72 +115,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
<form id="newOrderForm" class="form-grid">
|
<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">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noCustomer">Cliente</label>
|
<label class="form-label" for="noCustomer">Cliente</label>
|
||||||
<div style="display:flex;gap:var(--space-2);">
|
<select class="form-input" id="noCustomer" required></select>
|
||||||
<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>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noWorkshopName">Taller</label>
|
<label class="form-label" for="noVehicle">Vehículo</label>
|
||||||
<input class="form-input" id="noWorkshopName" placeholder="Nombre del taller" />
|
<select class="form-input" id="noVehicle"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noCustomerPhone">Teléfono</label>
|
<label class="form-label" for="noMechanic">Mecánico asignado</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>
|
<select class="form-input" id="noMechanic"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noMechanicName">Mecánico asignado (nombre libre)</label>
|
<label class="form-label" for="noPriority">Prioridad</label>
|
||||||
<input class="form-input" type="text" id="noMechanicName" placeholder="Ej. Juan Pérez" />
|
<select class="form-input" id="noPriority">
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="high">Alta</option>
|
||||||
|
<option value="urgent">Urgente</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="noEstimatedCost">Presupuesto</label>
|
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
|
||||||
<input class="form-input" type="number" id="noEstimatedCost" step="0.01" placeholder="0.00" />
|
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="toolbar-toggle">
|
<label class="form-label" for="noMileage">Kilometraje</label>
|
||||||
<input type="checkbox" id="noRequiresInvoice" />
|
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
|
||||||
<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>
|
||||||
<div class="form-field form-field--span2">
|
<div class="form-field form-field--span2">
|
||||||
<label class="form-label" for="noNotes">Observaciones</label>
|
<label class="form-label" for="noNotes">Notas de recepción</label>
|
||||||
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
|
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -270,184 +156,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Catalog modal -->
|
||||||
<div class="modal-overlay" id="catalogModal">
|
<div class="modal-overlay" id="catalogModal">
|
||||||
<div class="modal modal--lg">
|
<div class="modal modal--lg">
|
||||||
<div class="modal__header">
|
<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>
|
<button class="modal__close" onclick="Workshop.closeCatalogModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
@@ -458,11 +171,11 @@
|
|||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" />
|
<input class="form-input" id="catHours" type="number" step="0.1" placeholder="Horas" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field price-col">
|
<div class="form-field">
|
||||||
<input class="form-input" id="catRate" type="number" step="0.01" placeholder="Precio/hora" />
|
<input class="form-input" id="catRate" type="number" step="0.01" placeholder="Precio/hora" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field form-field--span3">
|
<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>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<button class="btn btn--primary" onclick="Workshop.addCatalogItem()">Agregar</button>
|
<button class="btn btn--primary" onclick="Workshop.addCatalogItem()">Agregar</button>
|
||||||
@@ -475,8 +188,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Servicio</th>
|
<th>Servicio</th>
|
||||||
<th>Horas</th>
|
<th>Horas</th>
|
||||||
<th class="price-col">Precio/hora</th>
|
<th>Precio/hora</th>
|
||||||
<th class="price-col">Total sugerido</th>
|
<th>Total sugerido</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -487,19 +200,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
|
||||||
<button class="btn btn--ghost" onclick="Workshop.closeCatalogModal()">Cerrar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js?v=8" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/splash-loader.js?v=33" 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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/workshop.js?v=57" defer></script>
|
<script src="/pos/static/js/workshop.js?v=2" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>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/pwa-install.js" defer></script>
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
|
|||||||
@@ -64,20 +64,17 @@ def conn():
|
|||||||
return MockConn(MockCursor())
|
return MockConn(MockCursor())
|
||||||
|
|
||||||
|
|
||||||
def test_generate_order_number_first_of_day(conn):
|
def test_generate_order_number_first_of_year(conn):
|
||||||
conn._cursor.responses = [(None,)]
|
conn._cursor.responses = [(None,)]
|
||||||
number = engine._generate_order_number(conn)
|
number = engine._generate_order_number(conn)
|
||||||
# Format DDMMYYYY-N
|
assert number.startswith("SO-")
|
||||||
assert len(number.split("-")) == 2
|
assert number.endswith("-0001")
|
||||||
assert number.split("-")[1] == "1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_order_number_increments(conn):
|
def test_generate_order_number_increments(conn):
|
||||||
from datetime import datetime
|
conn._cursor.responses = [("SO-2026-0042",)]
|
||||||
today = datetime.utcnow().strftime('%d%m%Y')
|
|
||||||
conn._cursor.responses = [(f"{today}-42",)]
|
|
||||||
number = engine._generate_order_number(conn)
|
number = engine._generate_order_number(conn)
|
||||||
assert number == f"{today}-43"
|
assert number.endswith("-0043")
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("services.inventory_engine.get_stock", return_value=10)
|
@mock.patch("services.inventory_engine.get_stock", return_value=10)
|
||||||
@@ -85,11 +82,11 @@ def test_generate_order_number_increments(conn):
|
|||||||
def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn):
|
def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_stock, conn):
|
||||||
|
|
||||||
conn._cursor.responses = [
|
conn._cursor.responses = [
|
||||||
(1, 5, 3, "pending", "SO-2026-0001", 2), # item lookup (branch_id=2)
|
(1, 5, 3, "pending", "SO-2026-0001"), # item lookup
|
||||||
None, # update
|
None, # update
|
||||||
]
|
]
|
||||||
|
|
||||||
result = engine.reserve_item(conn, 7, branch_id=99, employee_id=9)
|
result = engine.reserve_item(conn, 7, branch_id=2, employee_id=9)
|
||||||
|
|
||||||
assert result["reserved"] == 3
|
assert result["reserved"] == 3
|
||||||
mock_stock.assert_called_once_with(conn, 5, 2)
|
mock_stock.assert_called_once_with(conn, 5, 2)
|
||||||
@@ -102,11 +99,11 @@ def test_reserve_item_inserts_so_reserve_and_updates_quantity(mock_record, mock_
|
|||||||
@mock.patch("services.inventory_engine.get_stock", return_value=1)
|
@mock.patch("services.inventory_engine.get_stock", return_value=1)
|
||||||
def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn):
|
def test_reserve_item_raises_when_insufficient_stock(mock_stock, conn):
|
||||||
conn._cursor.responses = [
|
conn._cursor.responses = [
|
||||||
(1, 5, 3, "pending", "SO-2026-0001", 2),
|
(1, 5, 3, "pending", "SO-2026-0001"),
|
||||||
]
|
]
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Insufficient stock"):
|
with pytest.raises(ValueError, match="Insufficient stock"):
|
||||||
engine.reserve_item(conn, 7, branch_id=99)
|
engine.reserve_item(conn, 7, branch_id=2)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("services.inventory_engine.record_operation", return_value=124)
|
@mock.patch("services.inventory_engine.record_operation", return_value=124)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ flask-sqlalchemy>=3.1
|
|||||||
PyJWT>=2.8
|
PyJWT>=2.8
|
||||||
bcrypt>=4.0
|
bcrypt>=4.0
|
||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
fpdf2>=2.8
|
|
||||||
orjson
|
orjson
|
||||||
quart
|
quart
|
||||||
asyncpg
|
asyncpg
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
One-off helper: allocate existing customer_payments (abonos) to the oldest
|
|
||||||
unpaid credit sales for each customer.
|
|
||||||
|
|
||||||
This fixes historical data recorded before the customer-payment endpoint
|
|
||||||
started creating sale_payments rows automatically.
|
|
||||||
|
|
||||||
Run against a tenant database, e.g.:
|
|
||||||
DATABASE_URL=postgresql://postgres@localhost/tenant_refaccionaria_la_casita \
|
|
||||||
python3 scripts/allocate_existing_customer_payments.py
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
DB_URL = os.environ.get('DATABASE_URL')
|
|
||||||
if not DB_URL:
|
|
||||||
print('DATABASE_URL is required')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
conn = psycopg2.connect(DB_URL)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT customer_id, SUM(amount) as total_paid
|
|
||||||
FROM customer_payments
|
|
||||||
GROUP BY customer_id
|
|
||||||
ORDER BY customer_id
|
|
||||||
""")
|
|
||||||
customer_payments = cur.fetchall()
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
for customer_id, total_paid in customer_payments:
|
|
||||||
total_paid = float(total_paid or 0)
|
|
||||||
if total_paid <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.id,
|
|
||||||
s.total - COALESCE(SUM(sp.amount), 0) as balance
|
|
||||||
FROM sales s
|
|
||||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
|
||||||
WHERE s.customer_id = %s
|
|
||||||
AND s.sale_type = 'credit'
|
|
||||||
AND s.status = 'completed'
|
|
||||||
GROUP BY s.id, s.total, s.created_at
|
|
||||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
|
||||||
ORDER BY s.created_at
|
|
||||||
""", (customer_id,))
|
|
||||||
|
|
||||||
remaining = round(total_paid, 2)
|
|
||||||
for sale_id, balance in cur.fetchall():
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
pay = round(min(remaining, float(balance)), 2)
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO sale_payments (sale_id, method, amount, reference)
|
|
||||||
VALUES (%s, 'efectivo', %s, %s)
|
|
||||||
""", (sale_id, pay, f'Abono cliente #{customer_id} (reproceso)'))
|
|
||||||
remaining = round(remaining - pay, 2)
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
if remaining > 0:
|
|
||||||
print(f' Warning: customer {customer_id} has ${remaining} not allocated to any sale')
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
print(f'Allocated existing customer payments into {inserted} sale_payments rows.')
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
const { chromium } = require('playwright');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
function apiLogin() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
grant_type: 'password',
|
|
||||||
client_secret: 'JiRE9iL3pqRnqcFp6wDeYH0tYu97QSpkrwVKAvEP',
|
|
||||||
client_id: 2,
|
|
||||||
username: 'IVAN@flechasyventiladores-rached.com',
|
|
||||||
password: 'Nexus01'
|
|
||||||
});
|
|
||||||
const req = http.request({
|
|
||||||
hostname: 'appapi.flechasyventiladores-rached.com',
|
|
||||||
path: '/api/seguridad/login',
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Origin': 'http://app.flechasyventiladores-rached.com',
|
|
||||||
'Referer': 'http://app.flechasyventiladores-rached.com/',
|
|
||||||
'Content-Length': Buffer.byteLength(payload)
|
|
||||||
}
|
|
||||||
}, res => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.write(payload);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const outDir = path.resolve(__dirname, '../data/rached_import/har');
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
|
||||||
const loginResp = await apiLogin();
|
|
||||||
const sesion = JSON.stringify(loginResp.datos);
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
const allRequests = [];
|
|
||||||
await page.route('**/*', route => {
|
|
||||||
const req = route.request();
|
|
||||||
allRequests.push({ method: req.method(), url: req.url(), headers: req.headers(), postData: req.postData() });
|
|
||||||
route.continue();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto('http://app.flechasyventiladores-rached.com/', { waitUntil: 'networkidle' });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.evaluate(s => { localStorage.setItem('sesion', s); }, sesion);
|
|
||||||
|
|
||||||
const routes = [
|
|
||||||
'/administracion/servicios/ordenesservicio',
|
|
||||||
'/administracion/catalogos/clientes',
|
|
||||||
'/administracion/catalogos/articulos',
|
|
||||||
'/administracion/catalogos/mecanicos',
|
|
||||||
'/administracion/catalogos/sucursales',
|
|
||||||
'/administracion/catalogos/motociclistas',
|
|
||||||
'/administracion/catalogos/viasentrega',
|
|
||||||
'/administracion/catalogos/telefonostipos',
|
|
||||||
'/administracion/catalogos/direccionestipos',
|
|
||||||
];
|
|
||||||
for (const r of routes) {
|
|
||||||
await page.goto('http://app.flechasyventiladores-rached.com' + r, { waitUntil: 'networkidle' });
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(outDir, 'all_requests.json'), JSON.stringify(allRequests, null, 2));
|
|
||||||
await page.screenshot({ path: path.join(outDir, 'last_screen.png'), fullPage: true });
|
|
||||||
await browser.close();
|
|
||||||
console.log('Captured', allRequests.length, 'total requests');
|
|
||||||
})();
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Clone inventory tables from one tenant DB to another.
|
|
||||||
|
|
||||||
Uses COPY with the columns common to both source and target, so minor schema
|
|
||||||
mismatches (e.g. missing latitude/longitude in branches) are handled.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
python scripts/clone_inventory.py \
|
|
||||||
--source tenant_autopartes_estrada \
|
|
||||||
--target tenant_originales_autopartes
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from contextlib import closing
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
|
|
||||||
# (table, source_where_clause)
|
|
||||||
TABLES = [
|
|
||||||
("branches", "id <> 1"),
|
|
||||||
("inventory", None),
|
|
||||||
("inventory_stock", None),
|
|
||||||
("inventory_sku_aliases", None),
|
|
||||||
("inventory_stock_summary", None),
|
|
||||||
("inventory_vehicle_compat", None),
|
|
||||||
]
|
|
||||||
|
|
||||||
SEQUENCES = [
|
|
||||||
("branches_id_seq", "branches"),
|
|
||||||
("inventory_id_seq", "inventory"),
|
|
||||||
("inventory_stock_id_seq", "inventory_stock"),
|
|
||||||
("inventory_sku_aliases_id_seq", "inventory_sku_aliases"),
|
|
||||||
("inventory_vehicle_compat_id_seq", "inventory_vehicle_compat"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def connect(db_name: str):
|
|
||||||
return psycopg2.connect(host="localhost", user="postgres", dbname=db_name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_columns(cur, table: str):
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = %s
|
|
||||||
AND table_schema = 'public'
|
|
||||||
ORDER BY ordinal_position
|
|
||||||
""",
|
|
||||||
(table,),
|
|
||||||
)
|
|
||||||
return [r[0] for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
def copy_table(src_conn, dst_conn, table: str, where: str | None):
|
|
||||||
with src_conn.cursor() as src_cur, dst_conn.cursor() as dst_cur:
|
|
||||||
src_cols = get_columns(src_cur, table)
|
|
||||||
dst_cols = set(get_columns(dst_cur, table))
|
|
||||||
common = [c for c in src_cols if c in dst_cols]
|
|
||||||
if not common:
|
|
||||||
print(f"Skipping {table}: no common columns")
|
|
||||||
return
|
|
||||||
|
|
||||||
col_sql = ", ".join(f'"{c}"' for c in common)
|
|
||||||
copy_to = f'COPY (SELECT {col_sql} FROM "{table}"'
|
|
||||||
if where:
|
|
||||||
copy_to += f" WHERE {where}"
|
|
||||||
copy_to += ") TO STDOUT"
|
|
||||||
copy_from = f'COPY "{table}" ({col_sql}) FROM STDIN'
|
|
||||||
|
|
||||||
print(f"Copying {table} ({len(common)} columns)...", end=" ", flush=True)
|
|
||||||
start = time.time()
|
|
||||||
|
|
||||||
with tempfile.SpooledTemporaryFile(max_size=50 * 1024 * 1024, mode="w+b") as tmp:
|
|
||||||
src_cur.copy_expert(copy_to, tmp)
|
|
||||||
tmp.seek(0)
|
|
||||||
dst_cur.copy_expert(copy_from, tmp)
|
|
||||||
|
|
||||||
dst_conn.commit()
|
|
||||||
elapsed = time.time() - start
|
|
||||||
print(f"done in {elapsed:.1f}s")
|
|
||||||
|
|
||||||
|
|
||||||
def reset_sequences(dst_conn):
|
|
||||||
with dst_conn.cursor() as cur:
|
|
||||||
for seq, table in SEQUENCES:
|
|
||||||
cur.execute(
|
|
||||||
f"SELECT setval('{seq}', COALESCE((SELECT MAX(id) FROM \"{table}\"), 1), true)"
|
|
||||||
)
|
|
||||||
dst_conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Clone inventory between tenant DBs")
|
|
||||||
parser.add_argument("--source", required=True)
|
|
||||||
parser.add_argument("--target", required=True)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
src = connect(args.source)
|
|
||||||
dst = connect(args.target)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Prepare target: remove extra branches, truncate inventory tables.
|
|
||||||
with dst.cursor() as cur:
|
|
||||||
print("Preparing target tables...", end=" ", flush=True)
|
|
||||||
cur.execute("DELETE FROM branches WHERE id <> 1")
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
TRUNCATE TABLE inventory,
|
|
||||||
inventory_stock,
|
|
||||||
inventory_sku_aliases,
|
|
||||||
inventory_stock_summary,
|
|
||||||
inventory_vehicle_compat
|
|
||||||
CASCADE
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
dst.commit()
|
|
||||||
print("done")
|
|
||||||
|
|
||||||
for table, where in TABLES:
|
|
||||||
copy_table(src, dst, table, where)
|
|
||||||
|
|
||||||
print("Resetting sequences...", end=" ", flush=True)
|
|
||||||
reset_sequences(dst)
|
|
||||||
print("done")
|
|
||||||
|
|
||||||
print("Inventory clone completed.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: {e}", file=sys.stderr)
|
|
||||||
dst.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
src.close()
|
|
||||||
dst.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Importa solo el stock positivo del respaldo Punto Zero (datos1.productos.Existencia)
|
|
||||||
al tenant de La Casita. Usa part_number como llave de cruce.
|
|
||||||
|
|
||||||
Requiere pymysql y psycopg2. Instalar con:
|
|
||||||
pip3 install --target /tmp/pylibs pymysql psycopg2-binary
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, "/tmp/pylibs")
|
|
||||||
|
|
||||||
import pymysql
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
|
|
||||||
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3307"))
|
|
||||||
MYSQL_DB = os.getenv("MYSQL_DB", "datos1")
|
|
||||||
MYSQL_USER = os.getenv("MYSQL_USER", "root")
|
|
||||||
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
|
|
||||||
|
|
||||||
PG_URL = os.getenv(
|
|
||||||
"TENANT_DB_URL",
|
|
||||||
"postgresql://postgres@localhost/tenant_refaccionaria_la_casita",
|
|
||||||
)
|
|
||||||
BRANCH_ID = int(os.getenv("BRANCH_ID", "1"))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
mysql = pymysql.connect(
|
|
||||||
host=MYSQL_HOST,
|
|
||||||
port=MYSQL_PORT,
|
|
||||||
user=MYSQL_USER,
|
|
||||||
password=MYSQL_PASS,
|
|
||||||
db=MYSQL_DB,
|
|
||||||
charset="latin1",
|
|
||||||
)
|
|
||||||
pg = psycopg2.connect(PG_URL)
|
|
||||||
|
|
||||||
mycur = mysql.cursor()
|
|
||||||
mycur.execute("SELECT Clave, Existencia FROM productos WHERE Existencia > 0")
|
|
||||||
rows = mycur.fetchall()
|
|
||||||
|
|
||||||
pgcur = pg.cursor()
|
|
||||||
inserted = 0
|
|
||||||
updated = 0
|
|
||||||
skipped = 0
|
|
||||||
for clave, existencia in rows:
|
|
||||||
sku = str(clave).strip() if clave else ""
|
|
||||||
if not sku:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
stock = int(round(float(existencia)))
|
|
||||||
if stock <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Buscar inventory_id por part_number
|
|
||||||
pgcur.execute(
|
|
||||||
"SELECT id FROM inventory WHERE part_number = %s LIMIT 1",
|
|
||||||
(sku,),
|
|
||||||
)
|
|
||||||
inv_row = pgcur.fetchone()
|
|
||||||
if not inv_row:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
inventory_id = inv_row[0]
|
|
||||||
|
|
||||||
pgcur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
|
|
||||||
VALUES (%s, %s, %s, NULL)
|
|
||||||
ON CONFLICT (inventory_id, branch_id) DO UPDATE
|
|
||||||
SET stock = EXCLUDED.stock,
|
|
||||||
updated_at = NOW()
|
|
||||||
""",
|
|
||||||
(inventory_id, BRANCH_ID, stock),
|
|
||||||
)
|
|
||||||
if pgcur.rowcount == 1:
|
|
||||||
# psycopg2 rowcount for INSERT ... ON CONFLICT is tricky
|
|
||||||
inserted += 1
|
|
||||||
else:
|
|
||||||
updated += 1
|
|
||||||
|
|
||||||
pg.commit()
|
|
||||||
pgcur.close()
|
|
||||||
mycur.close()
|
|
||||||
mysql.close()
|
|
||||||
pg.close()
|
|
||||||
|
|
||||||
print(f"Productos con stock en respaldo: {len(rows)}")
|
|
||||||
print(f"Filas insertadas/actualizadas en inventory_stock: {inserted + updated}")
|
|
||||||
print(f"Sin coincidencia de part_number o SKU vacío: {skipped}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Import Rached legacy workshop data into the Nexus tenant_refaccionaria_rached DB."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
DATA_DIR = BASE_DIR / 'data' / 'rached_import'
|
|
||||||
TENANT_DB = 'tenant_refaccionaria_rached'
|
|
||||||
|
|
||||||
# Load DB URL from environment or use localhost defaults
|
|
||||||
DB_URL = os.environ.get(
|
|
||||||
'TENANT_DB_URL',
|
|
||||||
f'postgresql://postgres@localhost/{TENANT_DB}'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(s):
|
|
||||||
if not s:
|
|
||||||
return ''
|
|
||||||
return re.sub(r'[^a-z0-9]', '', s.lower().replace('sucursal', '').strip())
|
|
||||||
|
|
||||||
|
|
||||||
def load_json(name):
|
|
||||||
with open(DATA_DIR / f'{name}.json', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def load_detail_json(cid):
|
|
||||||
path = DATA_DIR / 'client_details' / f'client_{cid}.json'
|
|
||||||
if path.exists():
|
|
||||||
with open(path, encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def connect():
|
|
||||||
return psycopg2.connect(DB_URL)
|
|
||||||
|
|
||||||
|
|
||||||
ORDER_STATUS_MAP = {
|
|
||||||
1: 'por_recolectar',
|
|
||||||
2: 'por_revisar',
|
|
||||||
3: 'en_revision',
|
|
||||||
4: 'revisada',
|
|
||||||
5: 'cotizada',
|
|
||||||
6: 'por_autorizar',
|
|
||||||
7: 'autorizada',
|
|
||||||
8: 'autorizacion_parcial',
|
|
||||||
9: 'cancelada',
|
|
||||||
10: 'en_reparacion',
|
|
||||||
11: 'reparada',
|
|
||||||
12: 'por_entregar',
|
|
||||||
13: 'enviado',
|
|
||||||
14: 'entregado',
|
|
||||||
15: 'por_facturar',
|
|
||||||
16: 'facturada',
|
|
||||||
17: 'por_enviar',
|
|
||||||
}
|
|
||||||
|
|
||||||
ITEM_STATUS_MAP = {
|
|
||||||
1: 'por_revisar',
|
|
||||||
2: 'revisando',
|
|
||||||
3: 'revisado',
|
|
||||||
4: 'cotizado',
|
|
||||||
5: 'por_autorizar',
|
|
||||||
6: 'autorizado',
|
|
||||||
7: 'cancelado',
|
|
||||||
8: 'en_reparacion',
|
|
||||||
9: 'reparado',
|
|
||||||
10: 'por_entregar',
|
|
||||||
11: 'enviado',
|
|
||||||
12: 'entregado',
|
|
||||||
13: 'por_enviar',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_branch(cur):
|
|
||||||
cur.execute("SELECT id FROM branches WHERE is_main = true LIMIT 1")
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
return row[0]
|
|
||||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_branches(cur):
|
|
||||||
cur.execute("SELECT id, name FROM branches")
|
|
||||||
existing = {normalize_name(name): id for id, name in cur.fetchall()}
|
|
||||||
catalog = load_json('catalog_sucursales')['datos']
|
|
||||||
mapping = {}
|
|
||||||
for s in catalog:
|
|
||||||
name = (s.get('nombre') or '').strip()
|
|
||||||
# Skip the erroneous Rached branch
|
|
||||||
if name.upper() == 'CREMALLERAS':
|
|
||||||
continue
|
|
||||||
key = normalize_name(name)
|
|
||||||
if key in existing:
|
|
||||||
mapping[s['id']] = existing[key]
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO branches (name, is_active) VALUES (%s, true) RETURNING id",
|
|
||||||
(s['nombre'],)
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()[0]
|
|
||||||
existing[key] = new_id
|
|
||||||
mapping[s['id']] = new_id
|
|
||||||
print(f"Created branch {s['nombre']} -> {new_id}")
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_customers(cur, branch_map):
|
|
||||||
# collect unique client ids referenced in orders
|
|
||||||
client_ids = set()
|
|
||||||
for f in DATA_DIR.glob('details/order_*.json'):
|
|
||||||
d = json.loads(f.read_text(encoding='utf-8')).get('datos', {})
|
|
||||||
cid = d.get('idCliente')
|
|
||||||
if cid:
|
|
||||||
client_ids.add(cid)
|
|
||||||
|
|
||||||
# load existing customers by name
|
|
||||||
cur.execute("SELECT id, name FROM customers")
|
|
||||||
existing_by_name = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
||||||
|
|
||||||
client_map = {}
|
|
||||||
default_branch = get_default_branch(cur)
|
|
||||||
for cid in sorted(client_ids):
|
|
||||||
detail = load_detail_json(cid)
|
|
||||||
datos = detail.get('datos', {}) if detail else {}
|
|
||||||
# Name: prefer 'taller' if meaningful, else full name
|
|
||||||
taller = (datos.get('taller') or '').strip()
|
|
||||||
nombre = (datos.get('nombre') or '').strip()
|
|
||||||
ap1 = (datos.get('primerApellido') or '').strip()
|
|
||||||
ap2 = (datos.get('segundoApellido') or '').strip()
|
|
||||||
if taller:
|
|
||||||
name = taller
|
|
||||||
else:
|
|
||||||
name = ' '.join([nombre, ap1, ap2]).strip()
|
|
||||||
if not name:
|
|
||||||
name = f"Cliente Rached {cid}"
|
|
||||||
|
|
||||||
if name.lower() in existing_by_name:
|
|
||||||
client_map[cid] = existing_by_name[name.lower()]
|
|
||||||
continue
|
|
||||||
|
|
||||||
# phone / address
|
|
||||||
phone = None
|
|
||||||
for t in datos.get('telefonos', []):
|
|
||||||
num = t.get('numero')
|
|
||||||
if num:
|
|
||||||
phone = str(num)
|
|
||||||
break
|
|
||||||
address = None
|
|
||||||
for drec in datos.get('direcciones', []):
|
|
||||||
address = drec.get('completa')
|
|
||||||
if address:
|
|
||||||
break
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO customers (branch_id, name, phone, address, price_tier, is_active)
|
|
||||||
VALUES (%s, %s, %s, %s, 2, true) RETURNING id
|
|
||||||
""",
|
|
||||||
(default_branch, name, phone, address)
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()[0]
|
|
||||||
existing_by_name[name.lower()] = new_id
|
|
||||||
client_map[cid] = new_id
|
|
||||||
print(f"Created customer {cid}: {name}")
|
|
||||||
return client_map
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_employees(cur, branch_map, role, catalog, existing_names=None):
|
|
||||||
default_branch = get_default_branch(cur)
|
|
||||||
emp_map = {}
|
|
||||||
if existing_names is None:
|
|
||||||
cur.execute("SELECT id, name FROM employees")
|
|
||||||
existing_names = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
||||||
for e in catalog:
|
|
||||||
name = (e.get('nombre') or '').strip()
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
key = name.lower()
|
|
||||||
if key in existing_names:
|
|
||||||
emp_map[e['id']] = existing_names[key]
|
|
||||||
continue
|
|
||||||
is_active = bool(e.get('activo', True))
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO employees (name, role, branch_id, is_active)
|
|
||||||
VALUES (%s, %s, %s, %s) RETURNING id
|
|
||||||
""",
|
|
||||||
(name, role, default_branch, is_active)
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()[0]
|
|
||||||
existing_names[key] = new_id
|
|
||||||
emp_map[e['id']] = new_id
|
|
||||||
print(f"Created employee {role} {e['id']}: {name}")
|
|
||||||
return emp_map
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_couriers(cur, catalog, tenant_id=31):
|
|
||||||
cur.execute("SELECT id, name FROM couriers WHERE tenant_id = %s", (tenant_id,))
|
|
||||||
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
||||||
courier_map = {}
|
|
||||||
for c in catalog:
|
|
||||||
name = (c.get('nombre') or '').strip()
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
key = name.lower()
|
|
||||||
if key in existing:
|
|
||||||
courier_map[c['id']] = existing[key]
|
|
||||||
continue
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO couriers (tenant_id, name, code, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
|
||||||
(tenant_id, name, f"MOT-{c['id']}")
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()[0]
|
|
||||||
existing[key] = new_id
|
|
||||||
courier_map[c['id']] = new_id
|
|
||||||
print(f"Created courier {c['id']}: {name}")
|
|
||||||
return courier_map
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_articles(cur, branch_map):
|
|
||||||
"""Rached articles are kept as free-text lines, not inventory products."""
|
|
||||||
catalog = load_json('catalog_articulos')
|
|
||||||
article_map = {}
|
|
||||||
for a in catalog:
|
|
||||||
name = (a.get('nombre') or '').strip()
|
|
||||||
if name:
|
|
||||||
article_map[a['id']] = name
|
|
||||||
return article_map
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_users(cur, branch_map, order_details):
|
|
||||||
# Map Rached user ids to employees
|
|
||||||
default_branch = get_default_branch(cur)
|
|
||||||
cur.execute("SELECT id, name FROM employees")
|
|
||||||
existing = {name.strip().lower(): id for id, name in cur.fetchall()}
|
|
||||||
user_map = {}
|
|
||||||
for d in order_details:
|
|
||||||
datos = d.get('datos', {})
|
|
||||||
uid = datos.get('idUsuario')
|
|
||||||
name = (datos.get('usuarioNombre') or '').strip()
|
|
||||||
if not uid or not name:
|
|
||||||
continue
|
|
||||||
key = name.lower()
|
|
||||||
if key in existing:
|
|
||||||
user_map[uid] = existing[key]
|
|
||||||
continue
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO employees (name, role, branch_id, is_active) VALUES (%s, %s, %s, true) RETURNING id",
|
|
||||||
(name, 'workshop', default_branch)
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()[0]
|
|
||||||
existing[key] = new_id
|
|
||||||
user_map[uid] = new_id
|
|
||||||
print(f"Created user {uid}: {name}")
|
|
||||||
return user_map
|
|
||||||
|
|
||||||
|
|
||||||
def import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map):
|
|
||||||
order_files = sorted(DATA_DIR.glob('details/order_*.json'))
|
|
||||||
imported = 0
|
|
||||||
skipped = 0
|
|
||||||
for f in order_files:
|
|
||||||
d = json.loads(f.read_text(encoding='utf-8'))
|
|
||||||
datos = d.get('datos', {})
|
|
||||||
order_number = datos.get('numero')
|
|
||||||
if not order_number:
|
|
||||||
continue
|
|
||||||
cur.execute("SELECT id FROM service_orders WHERE order_number = %s", (order_number,))
|
|
||||||
if cur.fetchone():
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
branch_id = branch_map.get(datos.get('idSucursal'))
|
|
||||||
customer_id = client_map.get(datos.get('idCliente'))
|
|
||||||
status = ORDER_STATUS_MAP.get(datos.get('idEstatus'), 'por_revisar')
|
|
||||||
|
|
||||||
# delivery method
|
|
||||||
via = datos.get('idViaEntrega')
|
|
||||||
mot_rec = datos.get('idMotociclistaRecoleccion')
|
|
||||||
mot_ent = datos.get('idMotociclistaEntrega')
|
|
||||||
courier_id = None
|
|
||||||
if via == 1:
|
|
||||||
delivery_method = 'pickup'
|
|
||||||
elif via == 2:
|
|
||||||
if mot_ent:
|
|
||||||
delivery_method = 'courier'
|
|
||||||
courier_id = courier_map.get(mot_ent)
|
|
||||||
elif mot_rec:
|
|
||||||
delivery_method = 'courier'
|
|
||||||
courier_id = courier_map.get(mot_rec)
|
|
||||||
else:
|
|
||||||
delivery_method = 'delivery'
|
|
||||||
else:
|
|
||||||
delivery_method = None
|
|
||||||
|
|
||||||
# customer address/phone from order overrides if present? order detail only has ids.
|
|
||||||
# Prefill from customer record (already stored in customers)
|
|
||||||
workshop_name = None
|
|
||||||
customer_phone = None
|
|
||||||
customer_address = None
|
|
||||||
if customer_id:
|
|
||||||
cur.execute("SELECT name, phone, address FROM customers WHERE id = %s", (customer_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
workshop_name, customer_phone, customer_address = row
|
|
||||||
|
|
||||||
created_by = user_map.get(datos.get('idUsuario'))
|
|
||||||
fecha = datos.get('fecha')
|
|
||||||
created_at = datetime.strptime(fecha, '%Y-%m-%d %H:%M:%S') if fecha else datetime.now()
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO service_orders
|
|
||||||
(tenant_id, branch_id, customer_id, order_number, status,
|
|
||||||
workshop_name, customer_address, customer_phone, vehicle_description,
|
|
||||||
reception_notes, estimated_cost, final_cost,
|
|
||||||
delivery_method, courier_id, is_direct, requires_invoice,
|
|
||||||
created_by, created_at, updated_at)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
31, # tenant_id for Rached
|
|
||||||
branch_id,
|
|
||||||
customer_id,
|
|
||||||
order_number,
|
|
||||||
status,
|
|
||||||
workshop_name,
|
|
||||||
customer_address,
|
|
||||||
customer_phone,
|
|
||||||
datos.get('vehiculo'),
|
|
||||||
datos.get('observaciones'),
|
|
||||||
datos.get('presupuesto') or 0,
|
|
||||||
datos.get('total') or 0,
|
|
||||||
delivery_method,
|
|
||||||
courier_id,
|
|
||||||
bool(datos.get('ordenDirecta')),
|
|
||||||
bool(datos.get('requiereFactura')),
|
|
||||||
created_by,
|
|
||||||
created_at,
|
|
||||||
created_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
so_id = cur.fetchone()[0]
|
|
||||||
|
|
||||||
# status history
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO service_order_status_history (service_order_id, new_status, changed_by, notes, created_at)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(so_id, status, created_by, 'Importado desde app Rached', created_at)
|
|
||||||
)
|
|
||||||
|
|
||||||
# items (free-text lines; not linked to inventory)
|
|
||||||
for it in datos.get('detalles', []):
|
|
||||||
articulo = it.get('articulo', {})
|
|
||||||
art_id = articulo.get('id')
|
|
||||||
art_name = article_map.get(art_id) or articulo.get('nombre') or 'Concepto'
|
|
||||||
mech_id = mech_map.get(it.get('idMecanico'))
|
|
||||||
item_status = ITEM_STATUS_MAP.get(it.get('idEstatusDetalle'), 'por_revisar')
|
|
||||||
qty = it.get('cantidad', 1)
|
|
||||||
price = it.get('precio') or 0
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO service_order_items
|
|
||||||
(service_order_id, inventory_id, part_number, name, quantity,
|
|
||||||
unit_cost, unit_price, status, mechanic_id, observations)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
so_id,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
art_name,
|
|
||||||
qty,
|
|
||||||
price,
|
|
||||||
price,
|
|
||||||
item_status,
|
|
||||||
mech_id,
|
|
||||||
it.get('observaciones'),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
imported += 1
|
|
||||||
if imported % 100 == 0:
|
|
||||||
print(f"Imported {imported} orders...")
|
|
||||||
|
|
||||||
return imported, skipped
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print(f"Importing Rached workshop data into {TENANT_DB}")
|
|
||||||
conn = connect()
|
|
||||||
cur = conn.cursor()
|
|
||||||
try:
|
|
||||||
branch_map = ensure_branches(cur)
|
|
||||||
print(f"Branch map: {branch_map}")
|
|
||||||
|
|
||||||
client_map = ensure_customers(cur, branch_map)
|
|
||||||
print(f"Customers to import: {len(client_map)}")
|
|
||||||
|
|
||||||
mech_catalog = load_json('catalog_mecanicos')
|
|
||||||
mech_map = ensure_employees(cur, branch_map, 'mechanic', mech_catalog)
|
|
||||||
print(f"Mechanics map: {len(mech_map)}")
|
|
||||||
|
|
||||||
courier_catalog = load_json('catalog_motociclistas')
|
|
||||||
courier_map = ensure_couriers(cur, courier_catalog, tenant_id=31)
|
|
||||||
print(f"Couriers map: {len(courier_map)}")
|
|
||||||
|
|
||||||
article_map = ensure_articles(cur, branch_map)
|
|
||||||
print(f"Articles map: {len(article_map)}")
|
|
||||||
|
|
||||||
order_details = [json.loads(f.read_text(encoding='utf-8')) for f in DATA_DIR.glob('details/order_*.json')]
|
|
||||||
user_map = ensure_users(cur, branch_map, order_details)
|
|
||||||
print(f"Users map: {len(user_map)}")
|
|
||||||
|
|
||||||
imported, skipped = import_orders(cur, branch_map, client_map, mech_map, courier_map, article_map, user_map)
|
|
||||||
print(f"Imported: {imported}, Skipped (already exist): {skipped}")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
print(f"ERROR: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user