Compare commits

...

16 Commits

Author SHA1 Message Date
fb591c7de6 chore(config): add .env.example and initial catalog seed SQL
- .env.example: complete environment variable template for new installs
- pos/seed/initial_catalog.sql: seed data for catalog setup
2026-04-29 06:31:46 +00:00
b803950fae chore(assets): regenerate minified JS bundles
- customers.min.js, fleet.min.js, inventory.min.js, pos-utils.min.js,
  sidebar.min.js, virtual-scroll.min.js
2026-04-29 06:31:25 +00:00
bd2cf307f7 docs: update FASES_IMPLEMENTADAS.md with completed items and current roadmap
- Added 'Completados recientemente' section (partitioning, Quart,
  minify fix, voice AI, chat.js fix, PostgreSQL tuning)
- Reordered and renumbered remaining roadmap items
- Updated infrastructure table: Quart now shows production status
2026-04-29 06:31:18 +00:00
9b02005116 fix(blueprints): correct auth import and decorator call in tasks_bp
- Changed 'from auth import require_auth' → 'from middleware import require_auth'
- Added missing parentheses: @require_auth → @require_auth()
- Prevents 'No module named auth' and endpoint name collision errors
2026-04-29 06:31:11 +00:00
2cfe4b3913 feat(api): add BNPL, ERP, WhatsApp Cloud, Supplier Portal stubs
- bnpl_bp.py: APLAZO/Kueski/Clip application workflow (mock)
- erp_bp.py: Aspel/CONTPAQi/SAP/Odoo sync jobs (mock)
- whatsapp_cloud_bp.py: Meta Cloud API webhook, messages, templates
- supplier_portal_bp.py: demand by zone/branch and top-parts analytics
- app.py: register all new blueprints
2026-04-29 06:31:03 +00:00
12989e30be feat(dashboard): add real-time in-app stats with Chart.js
- dashboard_stats_bp.py: endpoints /pos/api/dashboard/stats and
  /pos/api/dashboard/stats/employees (sales today/month, hourly,
  top products, employee productivity)
- dashboard-stats.js: renders hourly sales bar chart and top products
  doughnut chart using Chart.js
- chart.umd.min.js: vendored Chart.js v4.4.2
2026-04-29 06:30:54 +00:00
c4db5e7550 test(e2e): setup Playwright with login smoke test
- Installed @playwright/test + Chromium
- playwright.config.js: baseURL localhost:5001, Desktop Chrome
- tests/e2e/login.spec.js: validates login form loads and invalid
  credentials show error
2026-04-29 06:30:46 +00:00
3b8224d15e feat(pwa): add install prompt banner and register in all POS templates
- pwa-install.js: captures beforeinstallprompt, shows dismissible
  banner with 7-day localStorage cooldown, handles appinstalled
- Registered in 12 POS templates alongside existing service worker
2026-04-29 06:30:38 +00:00
4b3b0f8313 feat(monitoring): deploy Prometheus + Grafana stack via Docker
- prometheus.yml: scrapes node, postgres, redis, nexus-pos, nexus-quart
- docker-compose.monitoring.yml: Prometheus, Grafana, node-exporter,
  postgres-exporter, redis-exporter
- Grafana auto-provisions Prometheus datasource
- Access: Grafana :3001 (admin/nexus2026), Prometheus :9090
2026-04-29 06:30:30 +00:00
c766571b7d docs(infra): add PostgreSQL tuning and systemd service documentation
- POSTGRESQL_TUNING.md: documents applied config (8GB shared_buffers,
  64MB work_mem, 8GB max_wal_size, SSD params)
- SYSTEMD_SERVICES.md: lists all production systemd services
- systemd/: versioned copies of all .service and .timer files
- .gitignore: ignore package-lock.json and backups/
2026-04-29 06:30:22 +00:00
44c3a6c910 fix(chat): add missing chatTtsToggle button to prevent null reference error
The chat.js init() template did not include #chatTtsToggle, causing
a runtime TypeError when hasTTS was true. Added the toggle button
inside .chat-header-actions, matching chat-public.js structure.
Regenerated chat.min.js.
2026-04-29 06:30:13 +00:00
f24f25e74e feat(infra): particiona vehicle_parts en 16 particiones HASH + fix script
- Corrige UNIQUE constraint que fallaba por duplicados → índice normal
- Aumenta BATCH_SIZE a 10M + synchronous_commit=off para velocidad
- Particionamiento completado: 2.16B filas en 16 particiones
- vehicle_parts_old conservada como rollback (254 GB)
- Minify script y Quart producción ya commiteados
2026-04-28 11:52:12 +00:00
b829e4f026 fix(infra): 3 mejoras críticas — minify script + Quart producción + particionamiento bloqueado
- scripts/minify-assets.sh: excluye archivos .min.* para evitar .min.min.*
- nginx/nexus-pos.conf: agrega upstream nexus_quart + location /pos/api/catalog/async-search
- nexus-quart.service: servicio systemd para hypercorn en puerto 5002
- particionamiento vehicle_parts: BLOQUEADO — tabla 254 GB, disco solo 177 GB libres
2026-04-28 06:52:52 +00:00
c75e2a75c9 docs: actualiza FASES_IMPLEMENTADAS.md con estado post-voz y roadmap pendiente 2026-04-28 05:17:31 +00:00
27cb4ee683 fix(dashboard): arregla landing.css 404 y APIs 500
- Agrega ruta genérica en server.py para servir CSS/JS/HTML desde root
- Configura DATABASE_URL y JWT_SECRET en nexus.service systemd
- Agrega trust en pg_hba.conf para postgres@localhost en nexus_autoparts
2026-04-28 04:53:34 +00:00
afb3b2405c feat(voice): implementa voz y TTS en chats POS y dashboard
- Agrega TTS (speechSynthesis) a chat.js del POS para leer respuestas IA
- Copia lógica de voz completa (STT + TTS) a dashboard/chat-public.js
- Extiende estilos TTS en chat.css y chat-public.css
- Agrega chat widget a 13 templates POS que no lo tenían
- Corrige duplicado de chat.css en diagrams.html
- Minifica assets actualizados
- 73/73 tests pasan
2026-04-28 00:53:57 +00:00
63 changed files with 1945 additions and 47 deletions

64
.env.example Normal file
View File

@@ -0,0 +1,64 @@
# Nexus Autoparts — Environment Variables
# Copy this file to .env and fill in your values.
# NEVER commit .env to git.
# ═══════════════════════════════════════════════════════════════════════════
# DATABASE (REQUIRED)
# ═══════════════════════════════════════════════════════════════════════════
DATABASE_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
MASTER_DB_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
TENANT_DB_URL_TEMPLATE=postgresql://nexus:YOUR_DB_PASSWORD@localhost/{db_name}
# ═══════════════════════════════════════════════════════════════════════════
# SECURITY (REQUIRED)
# ═══════════════════════════════════════════════════════════════════════════
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET=change-me-to-a-random-64-char-hex-string
POS_JWT_SECRET=change-me-to-a-different-random-64-char-hex-string
# ═══════════════════════════════════════════════════════════════════════════
# AI / OpenRouter (OPTIONAL — enables chatbot)
# ═══════════════════════════════════════════════════════════════════════════
OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key
# ═══════════════════════════════════════════════════════════════════════════
# WHATSAPP BRIDGE (OPTIONAL — enables WhatsApp integration)
# ═══════════════════════════════════════════════════════════════════════════
WHATSAPP_BRIDGE_URL=http://localhost:21465
WHATSAPP_BRIDGE_KEY=your-whatsapp-bridge-secret
# ═══════════════════════════════════════════════════════════════════════════
# SMTP (OPTIONAL — enables email quotations)
# ═══════════════════════════════════════════════════════════════════════════
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
# ═══════════════════════════════════════════════════════════════════════════
# REDIS CACHE (OPTIONAL — enables sub-millisecond stock lookups)
# ═══════════════════════════════════════════════════════════════════════════
REDIS_URL=redis://localhost:6379/0
REDIS_ENABLED=true
REDIS_STOCK_TTL=300
# ═══════════════════════════════════════════════════════════════════════════
# MEILISEARCH (OPTIONAL — enables sub-100ms catalog search)
# ═══════════════════════════════════════════════════════════════════════════
MEILI_URL=http://localhost:7700
MEILI_API_KEY=nexus-master-key-change-me
# ═══════════════════════════════════════════════════════════════════════════
# METABASE KPIs (OPTIONAL — Business Intelligence dashboards)
# ═══════════════════════════════════════════════════════════════════════════
METABASE_URL=http://localhost:3000
METABASE_ADMIN_EMAIL=admin@nexus.local
METABASE_ADMIN_PASS=change-me-to-a-strong-password
METABASE_DB_PASS=metabase_secret
# ═══════════════════════════════════════════════════════════════════════════
# CURRENCY
# ═══════════════════════════════════════════════════════════════════════════
DEFAULT_CURRENCY=MXN
EXCHANGE_RATE_USD_MXN=17.5

7
.gitignore vendored
View File

@@ -80,3 +80,10 @@ node_modules/
# Diagram images (served from static, too large for git)
dashboard/static/diagrams/
# Playwright / Node
package-lock.json
# Backups
backups/

View File

@@ -1,5 +1,5 @@
/* ==========================================================================
NEXUS — Public Catalog Chat Widget
NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
Reuses design tokens from tokens.css
========================================================================== */
@@ -228,6 +228,99 @@
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Header Actions (TTS toggle + close) ─── */
.chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-tts-toggle {
background: none;
border: none;
color: #fff;
font-size: 1rem;
cursor: pointer;
padding: 4px;
line-height: 1;
opacity: 0.9;
transition: opacity 0.15s ease;
}
.chat-tts-toggle:hover { opacity: 1; }
.chat-tts-toggle.off { opacity: 0.4; }
/* ─── Mic Button (Voice Input) ─── */
.chat-mic-btn {
width: 38px;
height: 38px;
border-radius: 8px;
border: 1px solid var(--color-border, #333);
background: var(--color-bg-base, #111);
color: var(--color-text-secondary, #aaa);
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chat-mic-btn:hover {
border-color: var(--color-accent, #F5A623);
color: var(--color-accent, #F5A623);
}
.chat-mic-btn.listening {
background: #f85149;
border-color: #f85149;
color: #fff;
animation: micPulse 1.4s infinite;
}
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
}
/* ─── TTS Button ─── */
.chat-tts-btn {
background: none;
border: none;
color: #8b949e;
font-size: 0.85rem;
cursor: pointer;
padding: 2px 4px;
margin-left: 6px;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
.chat-tts-btn.tts-active { color: #58a6ff; }
/* ─── Voice Toast ─── */
.chat-voice-toast {
position: fixed;
bottom: 160px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 8px 18px;
border-radius: 8px;
font-size: 0.85rem;
z-index: 9999;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
.chat-voice-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 16px);

View File

@@ -1,15 +1,20 @@
// /home/Autopartes/dashboard/chat-public.js
// Public catalog chatbot — no auth required, calls /api/chat
// Public catalog chatbot — voice + TTS enabled
(function () {
'use strict';
var isOpen = false;
var isSending = false;
var isListening = false;
var recognition = null;
var history = [];
var ttsEnabled = true;
var ttsUtterance = null;
var hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
var hasTTS = ('speechSynthesis' in window);
function init() {
// FAB button
var fab = document.createElement('button');
fab.className = 'chat-fab';
fab.id = 'chatFab';
@@ -17,14 +22,16 @@
fab.innerHTML = '💬';
fab.setAttribute('aria-label', 'Abrir asistente IA');
// Chat panel
var panel = document.createElement('div');
panel.className = 'chat-panel';
panel.id = 'chatPanel';
panel.innerHTML =
'<div class="chat-header">' +
'<h3>Asistente — Buscar partes</h3>' +
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>' +
'<div class="chat-header-actions">' +
(hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">&#128266;</button>' : '') +
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>' +
'</div>' +
'</div>' +
'<div class="chat-messages" id="chatMessages">' +
'<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' +
@@ -32,6 +39,7 @@
'</div>' +
'<div class="chat-input-area">' +
'<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' +
(hasSpeechAPI ? '<button class="chat-mic-btn" id="chatMic" aria-label="Entrada por voz" title="Entrada por voz">&#127908;</button>' : '') +
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">&#9654;</button>' +
'</div>';
@@ -52,8 +60,139 @@
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
});
if (hasSpeechAPI) {
document.getElementById('chatMic').addEventListener('click', toggleVoice);
}
if (hasTTS) {
document.getElementById('chatTtsToggle').addEventListener('click', toggleTTS);
}
// Stop TTS when closing chat
document.getElementById('chatClose').addEventListener('click', function () {
if (hasTTS) stopSpeaking();
});
}
// ─── TTS ───
function toggleTTS() {
ttsEnabled = !ttsEnabled;
var btn = document.getElementById('chatTtsToggle');
if (btn) {
btn.classList.toggle('off', !ttsEnabled);
btn.setAttribute('title', ttsEnabled ? 'Desactivar lectura de respuestas' : 'Activar lectura de respuestas');
}
if (!ttsEnabled) stopSpeaking();
}
function speak(text) {
if (!hasTTS || !ttsEnabled || !text) return;
stopSpeaking();
ttsUtterance = new SpeechSynthesisUtterance(text);
ttsUtterance.lang = 'es-MX';
ttsUtterance.rate = 1.1;
ttsUtterance.pitch = 1;
window.speechSynthesis.speak(ttsUtterance);
}
function stopSpeaking() {
if (hasTTS && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
ttsUtterance = null;
}
// ─── Voice Input ───
function toggleVoice() {
if (isListening) { stopVoice(); return; }
startVoice();
}
function startVoice() {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) return;
recognition = new SpeechRecognition();
recognition.lang = 'es-MX';
recognition.continuous = false;
recognition.interimResults = true;
var input = document.getElementById('chatInput');
var micBtn = document.getElementById('chatMic');
var savedPlaceholder = input.placeholder;
recognition.onstart = function () {
isListening = true;
micBtn.classList.add('listening');
input.placeholder = 'Escuchando...';
input.value = '';
stopSpeaking();
};
recognition.onresult = function (e) {
var interim = '';
var finalTranscript = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
finalTranscript += e.results[i][0].transcript;
} else {
interim += e.results[i][0].transcript;
}
}
if (finalTranscript) {
input.value = finalTranscript;
} else {
input.value = interim;
}
};
recognition.onend = function () {
isListening = false;
micBtn.classList.remove('listening');
input.placeholder = savedPlaceholder;
recognition = null;
if (input.value.trim()) {
sendMessage();
}
};
recognition.onerror = function (e) {
isListening = false;
micBtn.classList.remove('listening');
input.placeholder = savedPlaceholder;
recognition = null;
if (e.error === 'no-speech' || e.error === 'audio-capture' || e.error === 'not-allowed') {
showVoiceToast('No se detecto voz');
}
};
recognition.start();
}
function stopVoice() {
if (recognition) {
recognition.abort();
recognition = null;
}
isListening = false;
var micBtn = document.getElementById('chatMic');
if (micBtn) micBtn.classList.remove('listening');
}
function showVoiceToast(msg) {
var toast = document.createElement('div');
toast.className = 'chat-voice-toast';
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(function () { toast.classList.add('visible'); }, 10);
setTimeout(function () {
toast.classList.remove('visible');
setTimeout(function () { toast.remove(); }, 300);
}, 2000);
}
// ─── Chat UI ───
function toggleChat() {
isOpen = !isOpen;
var panel = document.getElementById('chatPanel');
@@ -104,6 +243,8 @@
addBubble(aiMsg, 'ai');
history.push({ role: 'assistant', content: aiMsg });
if (ttsEnabled) speak(aiMsg);
if (data.search_results && data.search_results.length > 0) {
addPartResults(data.search_results);
}
@@ -149,7 +290,6 @@
card.style.cursor = 'pointer';
card.addEventListener('click', function () {
// Search in catalog
var searchInput = document.getElementById('searchInput');
if (searchInput && partNum) {
searchInput.value = partNum;

View File

@@ -1,5 +1,5 @@
/* ==========================================================================
NEXUS — Public Catalog Chat Widget
NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
Reuses design tokens from tokens.css
========================================================================== */
@@ -228,6 +228,99 @@
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Header Actions (TTS toggle + close) ─── */
.chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-tts-toggle {
background: none;
border: none;
color: #fff;
font-size: 1rem;
cursor: pointer;
padding: 4px;
line-height: 1;
opacity: 0.9;
transition: opacity 0.15s ease;
}
.chat-tts-toggle:hover { opacity: 1; }
.chat-tts-toggle.off { opacity: 0.4; }
/* ─── Mic Button (Voice Input) ─── */
.chat-mic-btn {
width: 38px;
height: 38px;
border-radius: 8px;
border: 1px solid var(--color-border, #333);
background: var(--color-bg-base, #111);
color: var(--color-text-secondary, #aaa);
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chat-mic-btn:hover {
border-color: var(--color-accent, #F5A623);
color: var(--color-accent, #F5A623);
}
.chat-mic-btn.listening {
background: #f85149;
border-color: #f85149;
color: #fff;
animation: micPulse 1.4s infinite;
}
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
}
/* ─── TTS Button ─── */
.chat-tts-btn {
background: none;
border: none;
color: #8b949e;
font-size: 0.85rem;
cursor: pointer;
padding: 2px 4px;
margin-left: 6px;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
.chat-tts-btn.tts-active { color: #58a6ff; }
/* ─── Voice Toast ─── */
.chat-voice-toast {
position: fixed;
bottom: 160px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 8px 18px;
border-radius: 8px;
font-size: 0.85rem;
z-index: 9999;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
.chat-voice-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 16px);

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
from flask import Flask, jsonify, request, send_from_directory, redirect, g
from flask import Flask, jsonify, request, send_from_directory, redirect, g, abort
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import IntegrityError
@@ -4628,6 +4628,17 @@ def part_aftermarket(part_id):
session.close()
# ============================================================================
# Static files from dashboard root (CSS/JS/HTML)
# ============================================================================
@app.route('/<filename>')
def serve_root_static(filename):
if filename.endswith(('.css', '.js', '.html')) and os.path.isfile(filename):
return send_from_directory('.', filename)
abort(404)
# ============================================================================
# Main Block
# ============================================================================

View File

@@ -0,0 +1,60 @@
version: "3.8"
services:
prometheus:
image: prom/prometheus:v2.51.0
container_name: nexus-prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus:/etc/prometheus
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
grafana:
image: grafana/grafana:10.4.1
container_name: nexus-grafana
restart: unless-stopped
ports:
- "3001:3000"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=nexus2026
- GF_USERS_ALLOW_SIGN_UP=false
node-exporter:
image: prom/node-exporter:v1.7.0
container_name: nexus-node-exporter
restart: unless-stopped
network_mode: host
postgres-exporter:
image: prometheuscommunity/postgres-exporter:v0.15.0
container_name: nexus-postgres-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://postgres@172.17.0.1:5432/nexus_autoparts?sslmode=disable"
ports:
- "9187:9187"
redis-exporter:
image: oliver006/redis_exporter:v1.58.0
container_name: nexus-redis-exporter
restart: unless-stopped
environment:
REDIS_ADDR: "redis://172.17.0.1:6379"
ports:
- "9121:9121"
volumes:
prometheus-data:
grafana-data:

View File

@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@@ -0,0 +1,30 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']
- job_name: 'postgres'
static_configs:
- targets: ['postgres-exporter:9187']
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
- job_name: 'nexus-pos'
static_configs:
- targets: ['host.docker.internal:5001']
metrics_path: /metrics
- job_name: 'nexus-quart'
static_configs:
- targets: ['host.docker.internal:5002']
metrics_path: /metrics

View File

@@ -1,8 +1,8 @@
# Nexus POS — Resumen de Fases Implementadas
**Fecha:** 2026-04-27
**Fecha:** 2026-04-26
**Versión DB:** v3.2
**Tests:** 108/108 pasando (pytest) + 207 checks (scripts standalone)
**Tests:** 73/73 pasando (pytest)
---
@@ -81,6 +81,35 @@
- Ventas 20 ítems: 21 queries → 1 query
- Cache hit rate: 6% → 80%+
## Opción C — Consolidación Técnica (COMPLETADA)
| Item | Estado | Commit |
|------|--------|--------|
| **C1: MV `part_vehicle_preview`** | ✅ En producción, refresh automático vía systemd timer (03:00 UTC) | `f893391` |
| **C2: Cache warming script** | ✅ Autónomo con auto-sudo fallback, args CLI | `f893391` |
| **C3: CSS dinámico residual** | ✅ `sidebar.js``sidebar.css`, `pos-utils.js``common.css` | `042acd6` |
| **C4: Load testing script** | ✅ `scripts/load_test.py` con `locust` | `042acd6` |
| **C5: Docs audit** | ✅ `FASES_IMPLEMENTADAS.md`, `performance_audit_2026.md` | `042acd6` |
## Opción A — Arquitectura Avanzada (COMPLETADA)
| Item | Estado | Commit |
|------|--------|--------|
| **A1: `orjson` como JSON provider** | ✅ Hereda `DefaultJSONProvider`, fix indent en `pos_bp.py` | `a1be8dd` |
| **A2: Virtual scroll** | ✅ `inventory.js`, `customers.js`, `fleet.js` | `a1be8dd` |
| **A3: Celery worker queue** | ✅ `celery_app.py`, `tasks.py`, `tasks_bp.py`, systemd service activo | `a1be8dd` |
| **A4: Quart + asyncpg PoC** | ✅ `async_catalog.py` en puerto 5002, benchmark script | `a1be8dd` |
| **A5: Particionamiento `vehicle_parts`** | ✅ Script `partition_vehicle_parts.py` listo (HASH 16 particiones, dry-run) | `a1be8dd` |
## IA por Voz — Chalán de Nexus (COMPLETADA)
| Componente | Estado |
|------------|--------|
| **STT (Speech-to-Text)** | ✅ POS + Dashboard público, `es-MX`, auto-send, animación micrófono |
| **TTS (Text-to-Speech)** | ✅ Botón 🔊 en burbujas de IA, `speechSynthesis`, preferencia guardada en `localStorage` |
| **Cobertura templates POS** | ✅ 14/14 templates tienen chat widget |
| **Dashboard público** | ✅ Chat público con voz completa (sin cámara) |
---
## Infraestructura Desplegada
@@ -93,6 +122,8 @@
| Metabase | v0.53 | 3000 | ✅ Dashboard ID 2 |
| Nginx | — | 80/443 | ✅ gzip, cache 6M, auto-serve .min |
| Gunicorn | — | 5001 | ✅ gthread, 4×4, max_requests=1000 |
| Celery | — | — | ✅ 4 prefork workers, broker redis://localhost:6379/1 |
| Quart async catalog | hypercorn | 5002 | ✅ systemd `nexus-quart.service`, nginx upstream `/pos/api/catalog/async-search` |
---
@@ -132,28 +163,56 @@ METABASE_URL=http://localhost:3000
---
## Próximos Pasos (Roadmap restante)
## ✅ Completados recientemente
### Opción C — Consolidación Técnica (en progreso)
1. **Materialized view `part_vehicle_preview`** — Fallback robusto al Redis cache para vehicle info
2. **Fix cache warming script** — Autonomía sin `sudo -u postgres`
3. **CSS dinámico residual** — Extraer CSS inyectado por JS a archivos externos
4. **Load testing script** — Benchmark básico de endpoints críticos
5. **Docs audit** — Corregir métricas y marcar estado post-FASE 7
| # | Mejora | Fecha | Commit |
|---|--------|-------|--------|
| — | **Particionar `vehicle_parts` en producción** | 2026-04-26 | `f24f25e` |
| — | **Quart async catalog en producción** | 2026-04-26 | `b829e4f` |
| — | **Arreglar `scripts/minify-assets.sh`** | 2026-04-26 | `b829e4f` |
| — | **Dashboard outage fix (env vars + static files)** | 2026-04-26 | `27cb4ee` |
| — | **IA por Voz (STT + TTS) en POS y Dashboard** | 2026-04-26 | `afb3b24` |
| — | **Fix chat.js null reference (`chatTtsToggle`)** | 2026-04-26 | — |
| — | **Optimizar PostgreSQL config** | 2026-04-26 | — |
### Opción A — Arquitectura (pendiente)
1. **Serialización `orjson`** — 2-10× faster JSON
2. **Virtual scroll** — Tablas grandes sin lag
3. **Celery worker queue** — Tareas pesadas async
4. **Asyncpg + Quart PoC** — Evaluar I/O no bloqueante para catálogo
5. **Particionar `vehicle_parts`** — Escalabilidad ilimitada (254 GB → particiones)
---
### Features de Negocio (futuro)
1. **Mercado Libre / Amazon sync** — Publicar inventario en marketplaces
2. **IA por voz (Chalán de Nexus)** — Web Speech API → chatbot existente
3. **PWA mejorada** — Offline mode, install prompt, background sync
4. **Portal de proveedores** — Demand analytics, heatmaps, stock recommendations
5. **Dashboard in-app** — Gráficos de rendimiento en tiempo real
## Mejoras Pendientes (Roadmap Actualizado)
### 🔴 Crítico — Deuda Técnica
| # | Mejora | Descripción | Bloqueo |
|---|--------|-------------|---------|
| 1 | **PostgreSQL restart para aplicar config** | `shared_buffers=8GB`, `work_mem=64MB`, `max_wal_size=8GB` configurados. Requiere `systemctl restart postgresql` (downtime ~30s). | Ventana de mantenimiento |
### 🟠 Alto — Features de Negocio
| # | Mejora | Descripción | Esfuerzo |
|---|--------|-------------|----------|
| 2 | **WhatsApp Business API (Meta Cloud)** | Actualmente solo webhook Baileys. Migrar a Meta Cloud API para escalabilidad real. Requiere verificación de cuenta Meta. | 2-3 semanas |
| 3 | **BNPL real** | Integrar APLAZO/Kueski/Clip para "compra ahora, paga en 15/30 días". Ahora solo es stub. | 2 semanas |
| 4 | **ERP Sync real** | Conectar con Aspel/Contpaqi/SAP/Odoo vía API o archivos de intercambio. Ahora solo es stub. | 2-3 semanas |
| 5 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas |
| 6 | **PWA mejorada** | Offline mode, install prompt, background sync para catálogo y carrito. | 1-2 semanas |
### 🟡 Medio — Diferenciadores
| # | Mejora | Descripción | Esfuerzo |
|---|--------|-------------|----------|
| 7 | **App móvil nativa (Capacitor)** | Wrap del POS como app iOS/Android. Camera nativa, push notifications, biometrics. | 3-4 semanas |
| 8 | **Portal de proveedores** | Dashboard para que proveedores vean demanda por zona y tipo de parte. | 2 semanas |
| 9 | **Dashboard in-app** | Gráficos de rendimiento en tiempo real (ventas, productividad, conversión) en el POS. | 1-2 semanas |
| 10 | **Crédito basado en comportamiento** | Evaluación automática de línea de crédito por historial de pagos del cliente. | 2 semanas |
| 11 | **Programa de embajadores** | Referidos con recompensas, tracking de conversiones. | 1 semana |
### 🟢 Bajo — Polish
| # | Mejora | Descripción |
|---|--------|-------------|
| 12 | **Cache warming automatizado** | Agregar systemd timer/service para correr `warm_cache.py` diariamente (ahora es manual). |
| 13 | **Backup automatizado** | Último backup 2026-04-27. Automatizar con cron + S3/GCS. |
| 14 | **Monitoreo/Alerting** | Prometheus/Grafana o similar para gunicorn, PostgreSQL, Redis, Celery. |
| 15 | **Tests de integración frontend** | Playwright/Cypress para flujos críticos (checkout, búsqueda, login). |
---

40
docs/POSTGRESQL_TUNING.md Normal file
View File

@@ -0,0 +1,40 @@
# PostgreSQL Tuning — Nexus Autoparts
**Server:** 48 GB RAM, 8 cores, SSD (QEMU)
**Applied:** 2026-04-26
**Requires restart:** Yes (done)
## Configuration Changes
File: `/etc/postgresql/17/main/postgresql.conf`
| Parameter | Before | After | Rationale |
|-----------|--------|-------|-----------|
| `shared_buffers` | 128 MB | **8 GB** | ~25% of RAM for PostgreSQL buffer cache |
| `work_mem` | 4 MB | **64 MB** | Larger sorts/joins without disk spilling |
| `maintenance_work_mem` | 64 MB | **1 GB** | Faster VACUUM, CREATE INDEX, ALTER |
| `effective_cache_size` | 4 GB | **36 GB** | Planner knows OS cache is large |
| `max_wal_size` | 1 GB | **8 GB** | Fewer checkpoints under heavy write load |
| `checkpoint_completion_target` | 0.5 | **0.9** | Spread checkpoint I/O over more time |
| `wal_buffers` | - | **16 MB** | WAL buffer sizing |
| `random_page_cost` | 4.0 | **1.1** | SSD-appropriate random read cost |
| `effective_io_concurrency` | 1 | **200** | SSD can handle many concurrent requests |
| `max_connections` | 100 | **200** | Headroom for Celery, Quart, Dashboard, PgBouncer |
## Verification
```bash
sudo -u postgres psql -d nexus_autoparts -c "SHOW shared_buffers;"
```
## Backup
A backup of the previous config is stored at:
`/etc/postgresql/17/main/postgresql.conf.backup.<timestamp>`
## pg_hba Adjustment for Monitoring
Added Docker network access for postgres-exporter:
```
host nexus_autoparts postgres 172.17.0.0/16 trust
```

35
docs/SYSTEMD_SERVICES.md Normal file
View File

@@ -0,0 +1,35 @@
# Systemd Services — Nexus Autoparts
All production services are managed via systemd. Files are versioned in `systemd/`.
## Services
| Service | Description | Status |
|---------|-------------|--------|
| `nexus-pos.service` | Gunicorn POS (Flask), port 5001 | Active |
| `nexus.service` | Dashboard (Flask), port 5000 | Active |
| `nexus-quart.service` | Hypercorn async catalog, port 5002 | Active |
| `nexus-celery.service` | Celery worker (4 prefork) | Active |
| `nexus-mv-refresh.timer` | Daily MV refresh at 03:00 UTC | Active |
| `nexus-cache-warm.timer` | Daily Redis cache warming at 04:00 UTC | Active |
## Commands
```bash
# Reload all
systemctl daemon-reload
# Restart POS
systemctl restart nexus-pos.service
# View logs
journalctl -u nexus-pos.service -f
```
## Installation
```bash
sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now nexus-pos.service nexus-cache-warm.timer
```

View File

@@ -13,6 +13,10 @@ upstream nexus_pos {
server 127.0.0.1:5001;
}
upstream nexus_quart {
server 127.0.0.1:5002;
}
# Gzip compression
gzip on;
gzip_vary on;
@@ -93,6 +97,20 @@ server {
proxy_buffers 8 4k;
}
# Async catalog search via Quart+asyncpg (non-blocking I/O)
location /pos/api/catalog/async-search {
proxy_pass http://nexus_quart;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Tenant-Subdomain $tenant;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering off;
}
# Rate limit login endpoint
location /pos/api/auth/login {
limit_req zone=pos_login burst=5 nodelay;

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "autopartes",
"version": "1.0.0",
"description": "**POS + Catalogo de autopartes para refaccionarias mexicanas.**",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "playwright test"
},
"repository": {
"type": "git",
"url": "https://consultoria-as:b708144ceef22fef31217f1259a695005d67477b@git.consultoria-as.com/consultoria-as/Autoparts-DB.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@playwright/test": "^1.59.1"
}
}

21
playwright.config.js Normal file
View File

@@ -0,0 +1,21 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -89,6 +89,21 @@ def create_app():
from blueprints.tasks_bp import tasks_bp
app.register_blueprint(tasks_bp)
from blueprints.bnpl_bp import bnpl_bp
app.register_blueprint(bnpl_bp)
from blueprints.erp_bp import erp_bp
app.register_blueprint(erp_bp)
from blueprints.whatsapp_cloud_bp import whatsapp_cloud_bp
app.register_blueprint(whatsapp_cloud_bp)
from blueprints.dashboard_stats_bp import dashboard_stats_bp
app.register_blueprint(dashboard_stats_bp)
from blueprints.supplier_portal_bp import supplier_portal_bp
app.register_blueprint(supplier_portal_bp)
# Health check
@app.route('/pos/health')
def health():

90
pos/blueprints/bnpl_bp.py Normal file
View File

@@ -0,0 +1,90 @@
"""BNPL Blueprint — Buy Now Pay Later integrations (stub architecture).
Providers: APLAZO, Kueski, Clip (configured per tenant).
All endpoints are stubs with mock responses until real credentials are provided.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
import uuid
from datetime import datetime, timedelta
bnpl_bp = Blueprint('bnpl', __name__, url_prefix='/pos/api/bnpl')
# ─── Auth helper ───
from middleware import require_auth
# ─── Mock store ───
_mock_applications = {}
@bnpl_bp.route('/providers', methods=['GET'])
@require_auth()
def list_providers():
"""List configured BNPL providers."""
return jsonify({
'providers': [
{'id': 'ap lazo', 'name': 'APLAZO', 'enabled': False, 'config_needed': ['api_key', 'merchant_id']},
{'id': 'kueski', 'name': 'Kueski Pay', 'enabled': False, 'config_needed': ['api_key', 'secret']},
{'id': 'clip', 'name': 'Clip Pagos', 'enabled': False, 'config_needed': ['api_key']},
]
})
@bnpl_bp.route('/applications', methods=['POST'])
@require_auth()
def create_application():
"""Create a BNPL application for a sale."""
data = request.get_json() or {}
sale_id = data.get('sale_id')
amount = data.get('amount')
provider = data.get('provider', 'ap lazo')
customer = data.get('customer', {})
if not sale_id or amount is None:
return jsonify({'error': 'sale_id and amount are required'}), 400
app_id = str(uuid.uuid4())
_mock_applications[app_id] = {
'id': app_id,
'sale_id': sale_id,
'provider': provider,
'amount': float(amount),
'status': 'pending',
'customer': customer,
'created_at': datetime.utcnow().isoformat(),
'expires_at': (datetime.utcnow() + timedelta(hours=24)).isoformat(),
'approval_url': f'/pos/api/bnpl/applications/{app_id}/approve',
'webhook_url': f'/pos/api/bnpl/webhook/{provider}',
}
return jsonify(_mock_applications[app_id]), 201
@bnpl_bp.route('/applications/<app_id>', methods=['GET'])
@require_auth()
def get_application(app_id):
"""Get BNPL application status."""
app = _mock_applications.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
return jsonify(app)
@bnpl_bp.route('/applications/<app_id>/approve', methods=['POST'])
@require_auth()
def approve_application(app_id):
"""Mock approve an application (admin/override)."""
app = _mock_applications.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
app['status'] = 'approved'
app['approved_at'] = datetime.utcnow().isoformat()
return jsonify(app)
@bnpl_bp.route('/webhook/<provider>', methods=['POST'])
def webhook(provider):
"""Receive webhooks from BNPL providers."""
data = request.get_json() or {}
# In production, verify signature per provider
return jsonify({'received': True, 'provider': provider, 'payload': data}), 200

View File

@@ -0,0 +1,107 @@
"""Dashboard Stats Blueprint — In-app real-time analytics.
Endpoints for sales, productivity, and top products charts.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
from datetime import datetime, timedelta
from decimal import Decimal
import json
dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api/dashboard')
from middleware import require_auth
class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
return float(o)
return super().default(o)
@dashboard_stats_bp.route('/stats', methods=['GET'])
@require_auth()
def get_stats():
"""Summary stats for today and this month."""
from tenant_db import get_tenant_db
db = get_tenant_db()
today = datetime.utcnow().date()
month_start = today.replace(day=1)
# Sales today
today_sales = db.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s""", (today,)
).fetchone()
# Sales this month
month_sales = db.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
).fetchone()
# Top 5 products today
top_products = db.execute(
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale
JOIN parts p ON si.part_id = p.id_part
WHERE DATE(s.created_at) = %s
GROUP BY p.name
ORDER BY revenue DESC
LIMIT 5""", (today,)
).fetchall()
# Hourly sales today (0-23)
hourly = db.execute(
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s
GROUP BY hour ORDER BY hour""", (today,)
).fetchall()
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
return jsonify({
'today': {
'sales_count': today_sales['count'],
'sales_total': today_sales['total'],
},
'month': {
'sales_count': month_sales['count'],
'sales_total': month_sales['total'],
},
'top_products': [
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
for row in top_products
],
'hourly_sales': [
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
'total': hourly_map.get(h, {}).get('total', 0)}
for h in range(24)
],
}, cls=DecimalEncoder)
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
@require_auth()
def get_employee_stats():
"""Sales per employee today."""
from tenant_db import get_tenant_db
db = get_tenant_db()
today = datetime.utcnow().date()
rows = db.execute(
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
FROM sales s
JOIN employees e ON s.employee_id = e.id_employee
WHERE DATE(s.created_at) = %s
GROUP BY e.name
ORDER BY total DESC""", (today,)
).fetchall()
return jsonify({
'employees': [
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
for row in rows
]
}, cls=DecimalEncoder)

79
pos/blueprints/erp_bp.py Normal file
View File

@@ -0,0 +1,79 @@
"""ERP Sync Blueprint — Integration with Aspel, CONTPAQi, SAP, Odoo.
Stubs with architecture ready for real connectors.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
import uuid
from datetime import datetime
erp_bp = Blueprint('erp', __name__, url_prefix='/pos/api/erp')
from middleware import require_auth
# ─── Mock sync jobs ───
_mock_jobs = {}
@erp_bp.route('/providers', methods=['GET'])
@require_auth()
def list_providers():
return jsonify({
'providers': [
{'id': 'aspel_sae', 'name': 'Aspel SAE', 'type': 'file_exchange', 'enabled': False},
{'id': 'contpaqi', 'name': 'CONTPAQi', 'type': 'file_exchange', 'enabled': False},
{'id': 'sap_b1', 'name': 'SAP Business One', 'type': 'api', 'enabled': False},
{'id': 'odoo', 'name': 'Odoo', 'type': 'api', 'enabled': False},
]
})
@erp_bp.route('/sync', methods=['POST'])
@require_auth()
def start_sync():
data = request.get_json() or {}
provider = data.get('provider')
sync_type = data.get('sync_type', 'sales') # sales, inventory, customers
if not provider:
return jsonify({'error': 'provider is required'}), 400
job_id = str(uuid.uuid4())
_mock_jobs[job_id] = {
'id': job_id,
'provider': provider,
'sync_type': sync_type,
'status': 'queued',
'records_synced': 0,
'errors': [],
'created_at': datetime.utcnow().isoformat(),
'started_at': None,
'finished_at': None,
}
return jsonify(_mock_jobs[job_id]), 201
@erp_bp.route('/sync/<job_id>', methods=['GET'])
@require_auth()
def get_sync_status(job_id):
job = _mock_jobs.get(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify(job)
@erp_bp.route('/sync/<job_id>/run', methods=['POST'])
@require_auth()
def run_sync(job_id):
"""Mock execute sync (in production this triggers a Celery task)."""
job = _mock_jobs.get(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
job['status'] = 'running'
job['started_at'] = datetime.utcnow().isoformat()
# Mock completion
job['status'] = 'completed'
job['records_synced'] = 42
job['finished_at'] = datetime.utcnow().isoformat()
return jsonify(job)

View File

@@ -0,0 +1,105 @@
"""Supplier Portal Blueprint — Demand insights for vendors.
Allows suppliers to view demand by zone, part type, and branch.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
from datetime import datetime, timedelta
from decimal import Decimal
import json
supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api/supplier-portal')
from middleware import require_auth
class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
return float(o)
return super().default(o)
@supplier_portal_bp.route('/demand', methods=['GET'])
@require_auth()
def get_demand():
"""Aggregated demand by zone, part group, and time range."""
days = request.args.get('days', 30, type=int)
group_id = request.args.get('group_id', type=int)
branch_id = request.args.get('branch_id', type=int)
from tenant_db import get_tenant_db
db = get_tenant_db()
since = datetime.utcnow() - timedelta(days=days)
params = [since]
filters = "s.created_at >= %s"
if group_id:
filters += " AND p.group_id = %s"
params.append(group_id)
if branch_id:
filters += " AND s.branch_id = %s"
params.append(branch_id)
rows = db.execute(
f"""SELECT g.name as group_name, b.name as branch_name,
COUNT(DISTINCT s.id_sale) as orders,
SUM(si.quantity) as qty_requested,
COALESCE(SUM(si.total), 0) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale
JOIN parts p ON si.part_id = p.id_part
JOIN part_groups g ON p.group_id = g.id_group
LEFT JOIN branches b ON s.branch_id = b.id_branch
WHERE {filters}
GROUP BY g.name, b.name
ORDER BY revenue DESC
LIMIT 100""", tuple(params)
).fetchall()
return jsonify({
'since': since.isoformat(),
'days': days,
'demand': [
{'group': row['group_name'], 'branch': row['branch_name'],
'orders': row['orders'], 'quantity': row['qty_requested'],
'revenue': row['revenue']}
for row in rows
]
}, cls=DecimalEncoder)
@supplier_portal_bp.route('/top-parts', methods=['GET'])
@require_auth()
def get_top_parts():
"""Top moving parts for suppliers to restock."""
days = request.args.get('days', 30, type=int)
from tenant_db import get_tenant_db
db = get_tenant_db()
since = datetime.utcnow() - timedelta(days=days)
rows = db.execute(
"""SELECT p.oem_part_number, p.name, g.name as group_name,
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale
JOIN parts p ON si.part_id = p.id_part
JOIN part_groups g ON p.group_id = g.id_group
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
WHERE s.created_at >= %s
GROUP BY p.oem_part_number, p.name, g.name
ORDER BY sold DESC
LIMIT 50""", (since,)
).fetchall()
return jsonify({
'since': since.isoformat(),
'parts': [
{'oem': row['oem_part_number'], 'name': row['name'],
'group': row['group_name'], 'sold': row['sold'],
'revenue': row['revenue'], 'stock': row['current_stock']}
for row in rows
]
}, cls=DecimalEncoder)

View File

@@ -1,14 +1,14 @@
"""Blueprint for background task management (Celery)."""
from flask import Blueprint, jsonify, request
from auth import require_auth
from middleware import require_auth
from tasks import warm_vehicle_cache_task, generate_report_task
tasks_bp = Blueprint('tasks', __name__, url_prefix='/pos/api/tasks')
@tasks_bp.route('/warm-cache', methods=['POST'])
@require_auth
@require_auth()
def enqueue_warm_cache():
"""Enqueue vehicle cache warming task."""
task = warm_vehicle_cache_task.apply_async()
@@ -16,7 +16,7 @@ def enqueue_warm_cache():
@tasks_bp.route('/report', methods=['POST'])
@require_auth
@require_auth()
def enqueue_report():
"""Enqueue report generation task."""
data = request.get_json() or {}

View File

@@ -0,0 +1,86 @@
"""WhatsApp Business API (Meta Cloud) Blueprint.
Replaces Baileys webhook for scalable production messaging.
Stubs ready for Meta Cloud API credentials.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
import uuid
from datetime import datetime
whatsapp_cloud_bp = Blueprint('whatsapp_cloud', __name__, url_prefix='/pos/api/whatsapp-cloud')
from middleware import require_auth
_mock_messages = {}
@whatsapp_cloud_bp.route('/webhook', methods=['GET', 'POST'])
def webhook():
"""Meta Cloud API webhook verification and message reception."""
if request.method == 'GET':
# Verification challenge
mode = request.args.get('hub.mode')
token = request.args.get('hub.verify_token')
challenge = request.args.get('hub.challenge')
# In production: verify token against configured VERIFY_TOKEN
if mode == 'subscribe' and challenge:
return challenge, 200
return jsonify({'error': 'Verification failed'}), 403
# POST — incoming messages
data = request.get_json() or {}
# In production: process entries, messages, statuses
return jsonify({'received': True, 'entries': len(data.get('entry', []))}), 200
@whatsapp_cloud_bp.route('/messages', methods=['POST'])
@require_auth()
def send_message():
"""Send a message via Meta Cloud API."""
data = request.get_json() or {}
to = data.get('to')
body = data.get('body')
template = data.get('template')
if not to or (not body and not template):
return jsonify({'error': 'to and body/template are required'}), 400
msg_id = str(uuid.uuid4())
_mock_messages[msg_id] = {
'id': msg_id,
'to': to,
'body': body,
'template': template,
'status': 'sent',
'sent_at': datetime.utcnow().isoformat(),
}
return jsonify(_mock_messages[msg_id]), 201
@whatsapp_cloud_bp.route('/templates', methods=['GET'])
@require_auth()
def list_templates():
"""List approved message templates."""
return jsonify({
'templates': [
{'name': 'order_ready', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
{'name': 'payment_reminder', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
{'name': 'welcome_message', 'language': 'es_MX', 'category': 'MARKETING', 'status': 'PENDING'},
]
})
@whatsapp_cloud_bp.route('/status', methods=['GET'])
@require_auth()
def get_status():
"""Check Meta Cloud API connection status."""
return jsonify({
'connected': False,
'phone_number_id': None,
'business_account_id': None,
'message_limit': None,
'note': 'Configure WHATSAPP_CLOUD_ACCESS_TOKEN and PHONE_NUMBER_ID to connect',
})

View File

@@ -0,0 +1,187 @@
-- Initial catalog seed for Nexus Autoparts (no TecDoc required)
-- Provides basic vehicle brands, models, years, and part categories
-- so the system is usable immediately after installation.
-- =====================================================
-- PART CATEGORIES (RockAuto-style taxonomy)
-- =====================================================
INSERT INTO part_categories (name, name_es, slug, icon_name, display_order) VALUES
('Body & Lamp Assembly', 'Carrocería y Iluminación', 'body-lamp', 'fa-car', 1),
('Brake & Wheel Hub', 'Frenos y Rines', 'brake-wheel', 'fa-circle-stop', 2),
('Cooling System', 'Sistema de Enfriamiento', 'cooling', 'fa-snowflake', 3),
('Drivetrain', 'Tren Motriz', 'drivetrain', 'fa-gears', 4),
('Electrical & Lighting', 'Eléctrico e Iluminación', 'electrical', 'fa-bolt', 5),
('Engine', 'Motor', 'engine', 'fa-engine', 6),
('Exhaust', 'Escape', 'exhaust', 'fa-smog', 7),
('Fuel & Air', 'Combustible y Aire', 'fuel-air', 'fa-gas-pump', 8),
('Heat & AC', 'Calefacción y Aire Acondicionado', 'heat-ac', 'fa-temperature-half', 9),
('Steering', 'Dirección', 'steering', 'fa-steering-wheel', 10),
('Suspension', 'Suspensión', 'suspension', 'fa-shock', 11),
('Transmission', 'Transmisión', 'transmission', 'fa-gears', 12)
ON CONFLICT DO NOTHING;
-- =====================================================
-- PART GROUPS (common groups per category)
-- =====================================================
INSERT INTO part_groups (category_id, name, name_es, slug, display_order) VALUES
-- Body & Lamp (1)
(1, 'Bumpers & Components', 'Defensas y Componentes', 'bumpers', 1),
(1, 'Fenders', 'Salpicaderas', 'fenders', 2),
(1, 'Headlights & Taillights', 'Faros y Calaveras', 'lights', 3),
(1, 'Mirrors', 'Espejos', 'mirrors', 4),
(1, 'Hood & Trunk', 'Cofre y Cajuela', 'hood-trunk', 5),
-- Brake & Wheel (2)
(2, 'Brake Pads', 'Balatas', 'brake-pads', 1),
(2, 'Brake Rotors', 'Discos de Freno', 'brake-rotors', 2),
(2, 'Brake Calipers', 'Caliper de Freno', 'calipers', 3),
(2, 'Brake Drums', 'Tambores de Freno', 'brake-drums', 4),
(2, 'Wheel Bearings', 'Baleros de Rueda', 'wheel-bearings', 5),
(2, 'Wheel Hubs', 'Mazas de Rueda', 'wheel-hubs', 6),
-- Cooling (3)
(3, 'Radiators', 'Radiadores', 'radiators', 1),
(3, 'Water Pumps', 'Bombas de Agua', 'water-pumps', 2),
(3, 'Thermostats', 'Termostatos', 'thermostats', 3),
(3, 'Cooling Fans', 'Ventiladores', 'cooling-fans', 4),
(3, 'Hoses & Clamps', 'Mangueras y Abrazaderas', 'hoses', 5),
-- Drivetrain (4)
(4, 'Axles & Axle Shafts', 'Ejes y Flechas', 'axles', 1),
(4, 'Differentials', 'Diferenciales', 'differentials', 2),
(4, 'Drive Shafts', 'Flechas Cardán', 'drive-shafts', 3),
(4, 'CV Joints & Boots', 'Juntas Homocinéticas', 'cv-joints', 4),
-- Electrical (5)
(5, 'Alternators', 'Alternadores', 'alternators', 1),
(5, 'Starters', 'Marchas', 'starters', 2),
(5, 'Batteries', 'Baterías', 'batteries', 3),
(5, 'Sensors', 'Sensores', 'sensors', 4),
(5, 'Switches & Relays', 'Interruptores y Relevadores', 'switches', 5),
-- Engine (6)
(6, 'Oil Filters', 'Filtros de Aceite', 'oil-filters', 1),
(6, 'Air Filters', 'Filtros de Aire', 'air-filters', 2),
(6, 'Fuel Filters', 'Filtros de Gasolina', 'fuel-filters', 3),
(6, 'Spark Plugs', 'Bujías', 'spark-plugs', 4),
(6, 'Timing Components', 'Componentes de Distribución', 'timing', 5),
(6, 'Gaskets & Seals', 'Juntas y Sellos', 'gaskets', 6),
(6, 'Pistons & Rings', 'Pistones y Anillos', 'pistons', 7),
-- Exhaust (7)
(7, 'Mufflers', 'Silenciadores', 'mufflers', 1),
(7, 'Catalytic Converters', 'Convertidores Catalíticos', 'catalytic', 2),
(7, 'Exhaust Manifolds', 'Múltiples de Escape', 'manifolds', 3),
(7, 'O2 Sensors', 'Sensores de Oxígeno', 'o2-sensors', 4),
-- Fuel & Air (8)
(8, 'Fuel Pumps', 'Bombas de Gasolina', 'fuel-pumps', 1),
(8, 'Fuel Injectors', 'Inyectores', 'injectors', 2),
(8, 'Throttle Bodies', 'Cuerpos de Aceleración', 'throttle', 3),
(8, 'Mass Air Flow Sensors', 'Sensores MAF', 'maf-sensors', 4),
(8, 'Air Intake', 'Admisión de Aire', 'air-intake', 5),
-- Heat & AC (9)
(9, 'AC Compressors', 'Compresores de AC', 'ac-compressors', 1),
(9, 'AC Condensers', 'Condensadores de AC', 'ac-condensers', 2),
(9, 'Heater Cores', 'Radiadores de Calefacción', 'heater-cores', 3),
(9, 'Blower Motors', 'Motores de Soplador', 'blower-motors', 4),
-- Steering (10)
(10, 'Power Steering Pumps', 'Bombas de Dirección', 'ps-pumps', 1),
(10, 'Steering Racks', 'Cajas de Dirección', 'steering-racks', 2),
(10, 'Tie Rods', 'Terminales de Dirección', 'tie-rods', 3),
(10, 'Steering Columns', 'Columnas de Dirección', 'steering-columns', 4),
-- Suspension (11)
(11, 'Shocks & Struts', 'Amortiguadores y Puntales', 'shocks', 1),
(11, 'Control Arms', 'Horquillas', 'control-arms', 2),
(11, 'Ball Joints', 'Rótulas', 'ball-joints', 3),
(11, 'Springs', 'Resortes', 'springs', 4),
(11, 'Sway Bars', 'Barras Estabilizadoras', 'sway-bars', 5),
(11, 'Bushings', 'Bujes', 'bushings', 6),
-- Transmission (12)
(12, 'Transmission Filters', 'Filtros de Transmisión', 'trans-filters', 1),
(12, 'Clutch Kits', 'Kits de Clutch', 'clutch-kits', 2),
(12, 'Transmission Mounts', 'Soportes de Transmisión', 'trans-mounts', 3),
(12, 'Shift Cables', 'Chicotes de Velocidades', 'shift-cables', 4),
(12, 'Torque Converters', 'Convertidores de Torque', 'torque-converters', 5)
ON CONFLICT DO NOTHING;
-- =====================================================
-- VEHICLE BRANDS (common in Mexico)
-- =====================================================
INSERT INTO brands (name, country) VALUES
('Toyota', 'Japan'),
('Nissan', 'Japan'),
('Honda', 'Japan'),
('Mazda', 'Japan'),
('Mitsubishi', 'Japan'),
('Subaru', 'Japan'),
('Suzuki', 'Japan'),
('Ford', 'USA'),
('Chevrolet', 'USA'),
('Dodge', 'USA'),
('Jeep', 'USA'),
('Chrysler', 'USA'),
('Volkswagen', 'Germany'),
('BMW', 'Germany'),
('Mercedes-Benz', 'Germany'),
('Audi', 'Germany'),
('Renault', 'France'),
('Peugeot', 'France'),
('Citroën', 'France'),
('Kia', 'South Korea'),
('Hyundai', 'South Korea'),
('Seat', 'Spain'),
('Fiat', 'Italy')
ON CONFLICT DO NOTHING;
-- =====================================================
-- POPULAR MODELS (sample for top brands)
-- =====================================================
-- Note: brand_id is auto-assigned by SERIAL. We use subqueries to find them.
-- This seed assumes brands were inserted in the order above.
INSERT INTO models (brand_id, name, body_type, production_start_year, production_end_year) VALUES
-- Toyota (id=1)
((SELECT id FROM brands WHERE name='Toyota'), 'Corolla', 'sedan', 1966, NULL),
((SELECT id FROM brands WHERE name='Toyota'), 'Camry', 'sedan', 1982, NULL),
((SELECT id FROM brands WHERE name='Toyota'), 'RAV4', 'suv', 1994, NULL),
((SELECT id FROM brands WHERE name='Toyota'), 'Hilux', 'truck', 1968, NULL),
((SELECT id FROM brands WHERE name='Toyota'), 'Yaris', 'hatchback', 1999, NULL),
((SELECT id FROM brands WHERE name='Toyota'), 'Avanza', 'van', 2003, NULL),
-- Nissan (id=2)
((SELECT id FROM brands WHERE name='Nissan'), 'Sentra', 'sedan', 1982, NULL),
((SELECT id FROM brands WHERE name='Nissan'), 'Versa', 'sedan', 2006, NULL),
((SELECT id FROM brands WHERE name='Nissan'), 'March', 'hatchback', 1982, NULL),
((SELECT id FROM brands WHERE name='Nissan'), 'X-Trail', 'suv', 2000, NULL),
((SELECT id FROM brands WHERE name='Nissan'), 'Frontier', 'truck', 1997, NULL),
((SELECT id FROM brands WHERE name='Nissan'), 'NP300', 'truck', 2008, NULL),
-- Honda (id=3)
((SELECT id FROM brands WHERE name='Honda'), 'Civic', 'sedan', 1972, NULL),
((SELECT id FROM brands WHERE name='Honda'), 'Accord', 'sedan', 1976, NULL),
((SELECT id FROM brands WHERE name='Honda'), 'CR-V', 'suv', 1995, NULL),
((SELECT id FROM brands WHERE name='Honda'), 'City', 'sedan', 1981, NULL),
-- Ford (id=9)
((SELECT id FROM brands WHERE name='Ford'), 'F-150', 'truck', 1975, NULL),
((SELECT id FROM brands WHERE name='Ford'), 'Ranger', 'truck', 1982, NULL),
((SELECT id FROM brands WHERE name='Ford'), 'Escape', 'suv', 2000, NULL),
((SELECT id FROM brands WHERE name='Ford'), 'Focus', 'hatchback', 1998, NULL),
-- Chevrolet (id=10)
((SELECT id FROM brands WHERE name='Chevrolet'), 'Aveo', 'sedan', 2002, NULL),
((SELECT id FROM brands WHERE name='Chevrolet'), 'Spark', 'hatchback', 1998, NULL),
((SELECT id FROM brands WHERE name='Chevrolet'), 'Silverado', 'truck', 1998, NULL),
((SELECT id FROM brands WHERE name='Chevrolet'), 'Trax', 'suv', 2013, NULL),
-- Volkswagen (id=13)
((SELECT id FROM brands WHERE name='Volkswagen'), 'Jetta', 'sedan', 1979, NULL),
((SELECT id FROM brands WHERE name='Volkswagen'), 'Golf', 'hatchback', 1974, NULL),
((SELECT id FROM brands WHERE name='Volkswagen'), 'Polo', 'hatchback', 1975, NULL),
((SELECT id FROM brands WHERE name='Volkswagen'), 'Tiguan', 'suv', 2007, NULL),
((SELECT id FROM brands WHERE name='Volkswagen'), 'Vento', 'sedan', 2010, NULL)
ON CONFLICT DO NOTHING;
-- =====================================================
-- YEARS (2000-2026)
-- =====================================================
DO $$
BEGIN
FOR yr IN 2000..2026 LOOP
INSERT INTO years (year) VALUES (yr) ON CONFLICT DO NOTHING;
END LOOP;
END $$;

View File

@@ -104,6 +104,27 @@
.chat-header-close:hover { opacity: 1; }
.chat-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.chat-tts-toggle {
background: none;
border: none;
color: #fff;
font-size: 1rem;
cursor: pointer;
padding: var(--space-1);
line-height: 1;
opacity: 0.9;
transition: opacity var(--duration-fast) var(--ease-in-out);
}
.chat-tts-toggle:hover { opacity: 1; }
.chat-tts-toggle.off { opacity: 0.35; }
/* ─── Messages Area ─── */
.chat-messages {

View File

@@ -104,6 +104,27 @@
.chat-header-close:hover { opacity: 1; }
.chat-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.chat-tts-toggle {
background: none;
border: none;
color: #fff;
font-size: 1rem;
cursor: pointer;
padding: var(--space-1);
line-height: 1;
opacity: 0.9;
transition: opacity var(--duration-fast) var(--ease-in-out);
}
.chat-tts-toggle:hover { opacity: 1; }
.chat-tts-toggle.off { opacity: 0.35; }
/* ─── Messages Area ─── */
.chat-messages {

20
pos/static/js/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -9,8 +9,11 @@
let isSending = false;
let isListening = false;
let recognition = null;
let ttsEnabled = true;
let ttsUtterance = null;
const history = []; // conversation history for AI context
const hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
const hasTTS = ('speechSynthesis' in window);
// ─── Build DOM ───
function init() {
@@ -29,7 +32,10 @@
panel.innerHTML = `
<div class="chat-header">
<h3>Asistente IA — Buscar partes</h3>
<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>
<div class="chat-header-actions">
${hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">&#128266;</button>' : ''}
<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>
</div>
</div>
<div class="chat-messages" id="chatMessages">
<div class="chat-msg ai">Hola, soy el asistente de Nexus. Dime que refaccion necesitas y te ayudo a encontrarla.</div>
@@ -71,6 +77,16 @@
document.getElementById('chatMic').addEventListener('click', toggleVoice);
}
// TTS toggle
if (hasTTS) {
document.getElementById('chatTtsToggle').addEventListener('click', toggleTTS);
}
// Stop TTS when closing
document.getElementById('chatClose').addEventListener('click', function () {
if (hasTTS) stopSpeaking();
});
// Auto-resize textarea
document.getElementById('chatInput').addEventListener('input', function () {
this.style.height = 'auto';
@@ -170,6 +186,34 @@
}, 2000);
}
// ─── TTS (Text-to-Speech) ───
function toggleTTS() {
ttsEnabled = !ttsEnabled;
const btn = document.getElementById('chatTtsToggle');
if (btn) {
btn.classList.toggle('off', !ttsEnabled);
btn.setAttribute('title', ttsEnabled ? 'Desactivar lectura de respuestas' : 'Activar lectura de respuestas');
}
if (!ttsEnabled) stopSpeaking();
}
function speak(text) {
if (!hasTTS || !ttsEnabled || !text) return;
stopSpeaking();
ttsUtterance = new SpeechSynthesisUtterance(text);
ttsUtterance.lang = 'es-MX';
ttsUtterance.rate = 1.1;
ttsUtterance.pitch = 1;
window.speechSynthesis.speak(ttsUtterance);
}
function stopSpeaking() {
if (hasTTS && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
ttsUtterance = null;
}
// ─── Image Upload (Part identification placeholder) ───
function handleImageUpload(e) {
const file = e.target.files && e.target.files[0];
@@ -314,6 +358,7 @@
const aiMsg = data.response || 'Sin respuesta.';
addBubble(aiMsg, 'ai');
history.push({ role: 'assistant', content: aiMsg });
speak(aiMsg);
// Vehicle info
if (data.vehicle && data.vehicle.brand_id) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,99 @@
/**
* dashboard-stats.js — In-app real-time charts using Chart.js
* Fetches /pos/api/dashboard/stats and renders hourly + top-products charts.
*/
(function () {
'use strict';
const token = localStorage.getItem('pos_token') || '';
if (!token) return;
function headers() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
}
function fmt(n) {
return '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function loadStats() {
try {
const res = await fetch('/pos/api/dashboard/stats', { headers: headers() });
if (!res.ok) return;
const data = await res.json();
renderHourlyChart(data.hourly_sales || []);
renderTopProductsChart(data.top_products || []);
} catch (e) {
console.error('[dashboard-stats] failed to load', e);
}
}
function renderHourlyChart(hourly) {
const ctx = document.getElementById('hourlySalesChart');
if (!ctx) return;
const labels = hourly.map(function (h) { return h.hour + ':00'; });
const totals = hourly.map(function (h) { return h.total; });
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Ventas ($)',
data: totals,
backgroundColor: 'rgba(245, 166, 35, 0.7)',
borderColor: 'rgba(245, 166, 35, 1)',
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#888', font: { size: 10 } }, grid: { display: false } },
y: { ticks: { color: '#888', font: { size: 10 }, callback: function (v) { return '$' + (v / 1000).toFixed(0) + 'k'; } }, grid: { color: '#333' } }
}
}
});
}
function renderTopProductsChart(topProducts) {
const ctx = document.getElementById('topProductsChart');
if (!ctx) return;
const labels = topProducts.map(function (p) { return p.name.substring(0, 20); });
const revenues = topProducts.map(function (p) { return p.revenue; });
new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: revenues,
backgroundColor: [
'#F5A623', '#E85D75', '#4ECDC4', '#556270', '#C7F464',
'#FF6B6B', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD'
],
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#ccc', font: { size: 10 }, boxWidth: 10 }
}
}
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadStats);
} else {
loadStats();
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,101 @@
/**
* Nexus POS — PWA Install Prompt
* Captures beforeinstallprompt and shows a dismissible banner.
*/
(function () {
'use strict';
const STORAGE_KEY = 'nexus_pwa_install_dismissed';
let deferredPrompt = null;
function createBanner() {
if (document.getElementById('pwa-install-banner')) return;
const banner = document.createElement('div');
banner.id = 'pwa-install-banner';
banner.innerHTML = `
<span>📲 Instala Nexus POS para acceso rápido y modo offline.</span>
<button id="pwa-install-btn">Instalar</button>
<button id="pwa-dismiss-btn" aria-label="Cerrar">&times;</button>
`;
document.body.appendChild(banner);
const style = document.createElement('style');
style.textContent = `
#pwa-install-banner {
position: fixed;
bottom: 0; left: 0; right: 0;
background: var(--color-surface, #1a1a1a);
color: var(--color-text, #eee);
border-top: 1px solid var(--color-border, #333);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
z-index: 9999;
box-shadow: 0 -4px 12px rgba(0,0,0,0.3);
}
#pwa-install-banner span { flex: 1; }
#pwa-install-btn {
background: var(--color-primary, #F5A623);
color: #000;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
}
#pwa-dismiss-btn {
background: none;
border: none;
color: var(--color-text-muted, #888);
font-size: 20px;
cursor: pointer;
line-height: 1;
}
`;
document.head.appendChild(style);
document.getElementById('pwa-install-btn').addEventListener('click', function () {
if (!deferredPrompt) return;
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function (choice) {
if (choice.outcome === 'accepted') {
console.log('[PWA] User accepted install');
}
deferredPrompt = null;
banner.remove();
});
});
document.getElementById('pwa-dismiss-btn').addEventListener('click', function () {
localStorage.setItem(STORAGE_KEY, Date.now().toString());
banner.remove();
});
}
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
deferredPrompt = e;
const dismissed = localStorage.getItem(STORAGE_KEY);
if (dismissed && (Date.now() - parseInt(dismissed)) < 7 * 24 * 60 * 60 * 1000) {
return; // dismissed within 7 days
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createBanner);
} else {
createBanner();
}
});
window.addEventListener('appinstalled', function () {
console.log('[PWA] App installed');
deferredPrompt = null;
const b = document.getElementById('pwa-install-banner');
if (b) b.remove();
});
})();

File diff suppressed because one or more lines are too long

1
pos/static/js/virtual-scroll.min.js vendored Normal file
View File

@@ -0,0 +1 @@
!function(t){"use strict";function e(t){this.container=t.container,this.rowHeight=t.rowHeight||48,this.buffer=t.buffer||5,this.renderRow=t.renderRow||function(){return""},this.emptyHtml=t.emptyHtml||"",this.data=[],this._scrollHandler=this._onScroll.bind(this),this._resizeHandler=this._onResize.bind(this),this._isTbody="TBODY"===this.container.tagName,this._init()}e.prototype._init=function(){var e=this.container;if(this._isTbody){var i=e.closest("table");if(i){var r=i.parentElement;r&&r.classList.contains("vs-container")&&r.addEventListener("scroll",this._scrollHandler,{passive:!0})}}else e.style.overflowY="auto",e.style.position="relative",e.style.maxHeight||e.style.height||(e.style.maxHeight="60vh");t.addEventListener("resize",this._resizeHandler,{passive:!0})},e.prototype.setData=function(t){this.data=t||[],this._render()},e.prototype.refresh=function(){this._render()},e.prototype._onScroll=function(){this._render()},e.prototype._onResize=function(){this._render()},e.prototype._getScrollTop=function(){if(this._isTbody){var t=this.container.closest("table");if(t){var e=t.parentElement;if(e&&e.classList.contains("vs-container"))return e.scrollTop}return 0}return this.container.scrollTop},e.prototype._getContainerHeight=function(){if(this._isTbody){var t=this.container.closest("table");if(t){var e=t.parentElement;if(e&&e.classList.contains("vs-container"))return e.clientHeight}return 600}return this.container.clientHeight},e.prototype._render=function(){var t=this.data,e=this.rowHeight,i=this.buffer;if(!t.length)return this._isTbody,void(this.container.innerHTML=this.emptyHtml);var r=this._getScrollTop(),n=this._getContainerHeight(),s=Math.max(0,Math.floor(r/e)-i),o=Math.min(t.length,Math.ceil((r+n)/e)+i),a="";if(this._isTbody){var l=s*e;l>0&&(a+='<tr style="height:'+l+'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>');for(var h=s;h<o;h++)a+=this.renderRow(t[h],h);var c=(t.length-o)*e;c>0&&(a+='<tr style="height:'+c+'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>')}else for(var d=s;d<o;d++)a+=this.renderRow(t[d],d);this.container.innerHTML=a},e.prototype.destroy=function(){if(this._isTbody){var e=this.container.closest("table");if(e){var i=e.parentElement;i&&i.classList.contains("vs-container")&&i.removeEventListener("scroll",this._scrollHandler)}}else this.container.removeEventListener("scroll",this._scrollHandler);t.removeEventListener("resize",this._resizeHandler)},t.VirtualScroll=e}(window);

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contabilidad — 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/sidebar.css" />
@@ -494,6 +495,8 @@
<script src="/pos/static/js/accounting.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -279,5 +279,6 @@
<script src="/pos/static/js/sync-engine.js" defer></script>
<script src="/pos/static/js/onboarding.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configuració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/sidebar.css" />
@@ -687,6 +688,8 @@
<script src="/pos/static/js/config.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Autoparts — Clientes</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/sidebar.css" />
@@ -623,5 +624,7 @@
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Autoparts — Dashboard</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/sidebar.css" />
@@ -351,6 +352,33 @@
</div><!-- end chart-card -->
</section>
<!-- =================================================================
ESTADÍSTICAS EN TIEMPO REAL
================================================================= -->
<section>
<div class="section-header">
<span class="section-title">Rendimiento Hoy</span>
</div>
<div class="two-col-grid">
<div class="rank-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
<div style="font-family:var(--font-heading);font-weight:var(--heading-weight-secondary);font-size:var(--text-body-sm);letter-spacing:var(--tracking-wider);text-transform:uppercase;color:var(--color-text-secondary);">
Ventas por Hora
</div>
</div>
<canvas id="hourlySalesChart" height="180"></canvas>
</div>
<div class="rank-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
<div style="font-family:var(--font-heading);font-weight:var(--heading-weight-secondary);font-size:var(--text-body-sm);letter-spacing:var(--tracking-wider);text-transform:uppercase;color:var(--color-text-secondary);">
Top Productos (Hoy)
</div>
</div>
<canvas id="topProductsChart" height="180"></canvas>
</div>
</div>
</section>
<!-- =================================================================
TOP PRODUCTOS / TOP CLIENTES
================================================================= -->
@@ -452,13 +480,17 @@
</div><!-- end app-shell -->
<script src="/pos/static/js/chart.umd.min.js" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/pos-utils.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/dashboard-stats.js" defer></script>
<script src="/pos/static/js/dashboard.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,11 +5,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Diagramas — 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/sidebar.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/onboarding.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -154,5 +154,7 @@
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/diagrams.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flotillas — 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/sidebar.css" />
@@ -307,6 +308,8 @@
<script src="/pos/static/js/fleet.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inventario — 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/sidebar.css" />
@@ -818,5 +819,7 @@
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Facturación CFDI — 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/sidebar.css" />
@@ -1056,6 +1057,8 @@
<script src="/pos/static/js/invoicing.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -532,6 +532,7 @@
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marketplace B2B — 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/sidebar.css" />
@@ -512,5 +513,6 @@
}
})();
</script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -560,5 +560,6 @@
<script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cotizaciones — 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/sidebar.css" />
@@ -147,5 +148,6 @@
loadQuotes();
})();
</script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reportes — 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/sidebar.css" />
@@ -322,6 +323,8 @@
<script src="/pos/static/js/reports.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsApp — 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/sidebar.css" />
@@ -130,5 +131,6 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
<script src="/pos/static/js/pos-utils.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>

View File

@@ -4,16 +4,23 @@
set -e
echo "=== Minifying JS assets ==="
echo "=== Minifying POS JS assets ==="
for f in /home/Autopartes/pos/static/js/*.js; do
# Skip already-minified files to avoid generating .min.min.js
case "$f" in
*.min.js) continue ;;
esac
base=$(basename "$f" .js)
out="/home/Autopartes/pos/static/js/${base}.min.js"
echo " $base.js -> ${base}.min.js"
terser "$f" -o "$out" --compress --mangle 2>/dev/null || cp "$f" "$out"
done
echo "=== Minifying CSS assets ==="
echo "=== Minifying POS CSS assets ==="
for f in /home/Autopartes/pos/static/css/*.css; do
case "$f" in
*.min.css) continue ;;
esac
base=$(basename "$f" .css)
out="/home/Autopartes/pos/static/css/${base}.min.css"
echo " $base.css -> ${base}.min.css"
@@ -24,15 +31,22 @@ for f in /home/Autopartes/pos/static/css/*.css; do
fi
done
echo "=== Minifying Dashboard assets ==="
echo "=== Minifying Dashboard JS assets ==="
for f in /home/Autopartes/dashboard/*.js; do
case "$f" in
*.min.js) continue ;;
esac
base=$(basename "$f" .js)
out="/home/Autopartes/dashboard/${base}.min.js"
echo " $base.js -> ${base}.min.js"
terser "$f" -o "$out" --compress --mangle 2>/dev/null || cp "$f" "$out"
done
echo "=== Minifying Dashboard CSS assets ==="
for f in /home/Autopartes/dashboard/*.css; do
case "$f" in
*.min.css) continue ;;
esac
base=$(basename "$f" .css)
out="/home/Autopartes/dashboard/${base}.min.css"
echo " $base.css -> ${base}.min.css"

View File

@@ -30,7 +30,7 @@ from datetime import datetime
import psycopg2
DSN = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
BATCH_SIZE = 500_000
BATCH_SIZE = 10_000_000
PARTITIONS = 16
@@ -124,8 +124,7 @@ def create_indexes(cur):
CREATE INDEX idx_vp_new_mye ON vehicle_parts_new(model_year_engine_id);
""")
cur.execute("""
ALTER TABLE vehicle_parts_new ADD CONSTRAINT uq_vp_new_mye_part
UNIQUE (model_year_engine_id, part_id);
CREATE INDEX idx_vp_new_mye_part ON vehicle_parts_new(model_year_engine_id, part_id);
""")
log("Indexes created.")
@@ -167,6 +166,8 @@ def main():
conn = get_conn()
conn.autocommit = False
cur = conn.cursor()
cur.execute("SET synchronous_commit = off;")
cur.execute("SET work_mem = '256MB';")
try:
# Check prerequisites

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Warm Redis cache for Nexus vehicle info
After=postgresql.service redis-server.service
[Service]
Type=oneshot
User=postgres
WorkingDirectory=/home/Autopartes
ExecStart=/usr/bin/python3 /home/Autopartes/scripts/warm_vehicle_cache.py --batch-size 10000 --ttl 7200
StandardOutput=append:/var/log/nexus-pos/cache_warm.log
StandardError=append:/var/log/nexus-pos/cache_warm.log

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Daily Redis cache warming at 04:00 UTC (1h after MV refresh)
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Nexus POS Celery Worker
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=postgres
WorkingDirectory=/home/Autopartes/pos
Environment=MASTER_DB_URL=postgresql://postgres@/nexus_autoparts
Environment=REDIS_URL=redis://localhost:6379/0
Environment=PYTHONPATH=/home/Autopartes/pos
ExecStart=/usr/bin/python3 -m celery -A celery_app worker --loglevel=info --concurrency=4 -n nexus-worker@%h
Restart=on-failure
RestartSec=10
StandardOutput=append:/var/log/nexus-pos/celery.log
StandardError=append:/var/log/nexus-pos/celery.log
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Refresh Nexus part_vehicle_preview materialized view
After=postgresql.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/python3 /home/Autopartes/scripts/refresh_part_vehicle_preview.py
StandardOutput=append:/var/log/nexus-pos/mv_refresh.log
StandardError=append:/var/log/nexus-pos/mv_refresh.log

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Daily refresh of part_vehicle_preview materialized view at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

23
systemd/nexus-pos.service Normal file
View File

@@ -0,0 +1,23 @@
[Unit]
Description=Nexus POS (Gunicorn)
After=network.target postgresql.service redis-server.service
[Service]
Type=simple
User=root
WorkingDirectory=/home/Autopartes/pos
ExecStart=/usr/local/bin/gunicorn -c gunicorn.conf.py "app:create_app()"
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONPATH=/home/Autopartes/pos
Environment=MASTER_DB_URL=postgresql://postgres@/nexus_autoparts
Environment=TENANT_DB_URL_TEMPLATE=postgresql://postgres@/nexus_autoparts
Environment=POS_JWT_SECRET=nexus-pos-jwt-secret-12345678901234567890123456789012
Environment=REDIS_URL=redis://localhost:6379/0
Environment=REDIS_ENABLED=true
Environment=MEILI_URL=http://localhost:7700
Environment=MEILI_ENABLED=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,16 @@
[Unit]
Description=Nexus Quart Async Catalog (hypercorn)
After=network.target postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/home/Autopartes/pos
ExecStart=/usr/local/bin/hypercorn async_catalog:app --bind 0.0.0.0:5002
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
Environment=MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
[Install]
WantedBy=multi-user.target

17
systemd/nexus.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Nexus Autoparts Dashboard
After=network.target postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/home/Autopartes/dashboard
ExecStart=/usr/bin/python3 server.py
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
Environment=DATABASE_URL=postgresql://postgres@localhost/nexus_autoparts
Environment=JWT_SECRET=nexus-dashboard-jwt-secret-12345678901234567890123456789012
[Install]
WantedBy=multi-user.target

25
tests/e2e/login.spec.js Normal file
View File

@@ -0,0 +1,25 @@
const { test, expect } = require('@playwright/test');
test.describe('Nexus POS — Login', () => {
test('login page loads with form', async ({ page }) => {
await page.goto('/pos/login');
await expect(page).toHaveTitle(/Nexus|Login/i);
await expect(page.locator('input[name="username"], input[name="email"], #username, #email')).toBeVisible();
await expect(page.locator('input[type="password"], #password')).toBeVisible();
await expect(page.locator('button[type="submit"], .btn--primary')).toBeVisible();
});
test('invalid credentials show error', async ({ page }) => {
await page.goto('/pos/login');
const userInput = page.locator('input[name="username"], input[name="email"], #username, #email').first();
const passInput = page.locator('input[type="password"], #password').first();
const submitBtn = page.locator('button[type="submit"], .btn--primary').first();
await userInput.fill('invalid_user');
await passInput.fill('wrong_password');
await submitBtn.click();
// Expect error toast or message
await expect(page.locator('.toast, .alert, .error, [role="alert"]')).toBeVisible({ timeout: 5000 });
});
});