Compare commits
16 Commits
1f909f4c42
...
fb591c7de6
| Author | SHA1 | Date | |
|---|---|---|---|
| fb591c7de6 | |||
| b803950fae | |||
| bd2cf307f7 | |||
| 9b02005116 | |||
| 2cfe4b3913 | |||
| 12989e30be | |||
| c4db5e7550 | |||
| 3b8224d15e | |||
| 4b3b0f8313 | |||
| c766571b7d | |||
| 44c3a6c910 | |||
| f24f25e74e | |||
| b829e4f026 | |||
| c75e2a75c9 | |||
| 27cb4ee683 | |||
| afb3b2405c |
64
.env.example
Normal file
64
.env.example
Normal 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
7
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,21 +22,24 @@
|
||||
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>' +
|
||||
'<div class="chat-header-actions">' +
|
||||
(hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">🔊</button>' : '') +
|
||||
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</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>' +
|
||||
'<div class="chat-typing" id="chatTyping"><span></span><span></span><span></span></div>' +
|
||||
'</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">🎤</button>' : '') +
|
||||
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">▶</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;
|
||||
|
||||
95
dashboard/chat-public.min.css
vendored
95
dashboard/chat-public.min.css
vendored
@@ -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);
|
||||
|
||||
2
dashboard/chat-public.min.js
vendored
2
dashboard/chat-public.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
60
docker/docker-compose.monitoring.yml
Normal file
60
docker/docker-compose.monitoring.yml
Normal 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:
|
||||
9
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
9
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
30
docker/prometheus/prometheus.yml
Normal file
30
docker/prometheus/prometheus.yml
Normal 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
|
||||
@@ -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
40
docs/POSTGRESQL_TUNING.md
Normal 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
35
docs/SYSTEMD_SERVICES.md
Normal 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
|
||||
```
|
||||
@@ -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
23
package.json
Normal 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
21
playwright.config.js
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
15
pos/app.py
15
pos/app.py
@@ -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
90
pos/blueprints/bnpl_bp.py
Normal 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
|
||||
107
pos/blueprints/dashboard_stats_bp.py
Normal file
107
pos/blueprints/dashboard_stats_bp.py
Normal 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
79
pos/blueprints/erp_bp.py
Normal 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)
|
||||
105
pos/blueprints/supplier_portal_bp.py
Normal file
105
pos/blueprints/supplier_portal_bp.py
Normal 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)
|
||||
@@ -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 {}
|
||||
|
||||
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
86
pos/blueprints/whatsapp_cloud_bp.py
Normal 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',
|
||||
})
|
||||
187
pos/seed/initial_catalog.sql
Normal file
187
pos/seed/initial_catalog.sql
Normal 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 $$;
|
||||
@@ -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 {
|
||||
|
||||
21
pos/static/css/chat.min.css
vendored
21
pos/static/css/chat.min.css
vendored
@@ -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
20
pos/static/js/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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,8 +32,11 @@
|
||||
panel.innerHTML = `
|
||||
<div class="chat-header">
|
||||
<h3>Asistente IA — Buscar partes</h3>
|
||||
<div class="chat-header-actions">
|
||||
${hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">🔊</button>' : ''}
|
||||
<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</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>
|
||||
<div class="chat-typing" id="chatTyping">
|
||||
@@ -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) {
|
||||
|
||||
2
pos/static/js/chat.min.js
vendored
2
pos/static/js/chat.min.js
vendored
File diff suppressed because one or more lines are too long
2
pos/static/js/customers.min.js
vendored
2
pos/static/js/customers.min.js
vendored
File diff suppressed because one or more lines are too long
99
pos/static/js/dashboard-stats.js
Normal file
99
pos/static/js/dashboard-stats.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
2
pos/static/js/fleet.min.js
vendored
2
pos/static/js/fleet.min.js
vendored
File diff suppressed because one or more lines are too long
2
pos/static/js/inventory.min.js
vendored
2
pos/static/js/inventory.min.js
vendored
File diff suppressed because one or more lines are too long
2
pos/static/js/pos-utils.min.js
vendored
2
pos/static/js/pos-utils.min.js
vendored
File diff suppressed because one or more lines are too long
101
pos/static/js/pwa-install.js
Normal file
101
pos/static/js/pwa-install.js
Normal 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">×</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();
|
||||
});
|
||||
})();
|
||||
2
pos/static/js/sidebar.min.js
vendored
2
pos/static/js/sidebar.min.js
vendored
File diff suppressed because one or more lines are too long
1
pos/static/js/virtual-scroll.min.js
vendored
Normal file
1
pos/static/js/virtual-scroll.min.js
vendored
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
11
systemd/nexus-cache-warm.service
Normal file
11
systemd/nexus-cache-warm.service
Normal 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
|
||||
9
systemd/nexus-cache-warm.timer
Normal file
9
systemd/nexus-cache-warm.timer
Normal 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
|
||||
19
systemd/nexus-celery.service
Normal file
19
systemd/nexus-celery.service
Normal 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
|
||||
10
systemd/nexus-mv-refresh.service
Normal file
10
systemd/nexus-mv-refresh.service
Normal 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
|
||||
9
systemd/nexus-mv-refresh.timer
Normal file
9
systemd/nexus-mv-refresh.timer
Normal 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
23
systemd/nexus-pos.service
Normal 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
|
||||
16
systemd/nexus-quart.service
Normal file
16
systemd/nexus-quart.service
Normal 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
17
systemd/nexus.service
Normal 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
25
tests/e2e/login.spec.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user