feat: Implementar PWA, Analytics, Reportes PDF y mejoras OCR

FASE 1 - PWA y Frontend:
- Crear templates/base.html, dashboard.html, analytics.html, executive.html
- Crear static/css/main.css con diseño responsivo
- Agregar static/js/app.js, pwa.js, camera.js, charts.js
- Implementar manifest.json y service-worker.js para PWA
- Soporte para captura de tickets desde cámara móvil

FASE 2 - Analytics:
- Crear módulo analytics/ con predictions.py, trends.py, comparisons.py
- Implementar predicción básica con promedio móvil + tendencia lineal
- Agregar endpoints /api/analytics/trends, predictions, comparisons
- Integrar Chart.js para gráficas interactivas

FASE 3 - Reportes PDF:
- Crear módulo reports/ con pdf_generator.py
- Implementar SalesReportPDF con generar_reporte_diario y ejecutivo
- Agregar comando /reporte [diario|semanal|ejecutivo]
- Agregar endpoints /api/reports/generate y /api/reports/download

FASE 4 - Mejoras OCR:
- Crear módulo ocr/ con processor.py, preprocessor.py, patterns.py
- Implementar AmountDetector con patrones múltiples de montos
- Agregar preprocesador adaptativo con pipelines para diferentes condiciones
- Soporte para corrección de rotación (deskew) y threshold Otsu

Dependencias agregadas:
- reportlab, matplotlib (PDF)
- scipy, pandas (analytics)
- imutils, deskew, cachetools (OCR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 03:26:16 +00:00
parent ed1658eb2b
commit 9936deaa90
25 changed files with 5501 additions and 282 deletions

View File

@@ -0,0 +1,236 @@
/**
* Sales Bot - Camera Capture for Ticket Processing
*/
let cameraStream = null;
async function abrirCamara() {
const modal = document.getElementById('camera-modal');
const video = document.getElementById('camera-video');
if (!modal || !video) {
console.error('Camera elements not found');
return;
}
try {
// Request camera access
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
video.srcObject = cameraStream;
modal.classList.add('active');
} catch (error) {
console.error('Error accessing camera:', error);
if (error.name === 'NotAllowedError') {
alert('Permiso de camara denegado. Por favor, permite el acceso a la camara en la configuracion del navegador.');
} else if (error.name === 'NotFoundError') {
alert('No se encontro una camara en este dispositivo.');
} else {
alert('Error al acceder a la camara: ' + error.message);
}
}
}
function cerrarCamara() {
const modal = document.getElementById('camera-modal');
const video = document.getElementById('camera-video');
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
if (video) {
video.srcObject = null;
}
if (modal) {
modal.classList.remove('active');
}
}
async function capturarFoto() {
const video = document.getElementById('camera-video');
const canvas = document.getElementById('camera-canvas');
if (!video || !canvas) {
console.error('Video or canvas not found');
return;
}
// Set canvas size to video size
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw video frame to canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// Get base64 image
const imageData = canvas.toDataURL('image/jpeg', 0.9);
// Close camera
cerrarCamara();
// Show loading
if (window.Utils) {
window.Utils.showNotification('Procesando imagen...', 'info');
}
try {
// Send to server for OCR processing
const response = await fetch('/api/capture/ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: imageData
})
});
const result = await response.json();
if (result.success) {
if (window.Utils) {
window.Utils.showNotification('Ticket procesado correctamente', 'success');
}
// Show detected data
mostrarResultadoOCR(result);
} else {
if (window.Utils) {
window.Utils.showNotification('Error procesando ticket: ' + (result.error || 'Error desconocido'), 'error');
}
}
} catch (error) {
console.error('Error sending image:', error);
if (window.Utils) {
window.Utils.showNotification('Error enviando imagen al servidor', 'error');
}
}
}
function mostrarResultadoOCR(result) {
// Create modal to show OCR results
const modal = document.createElement('div');
modal.className = 'camera-modal active';
modal.style.cssText = 'display: flex; align-items: center; justify-content: center;';
const monto = result.monto ? window.Utils.formatMoney(result.monto) : 'No detectado';
const productos = result.productos || [];
const tubos = productos.filter(p =>
p.nombre && p.nombre.toLowerCase().includes('tinte')
).length;
modal.innerHTML = `
<div style="background: #1a1a2e; padding: 30px; border-radius: 16px; max-width: 400px; width: 90%;">
<h2 style="margin-bottom: 20px; color: #00d4ff;">Resultado del Ticket</h2>
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Monto Detectado</label>
<div style="font-size: 32px; font-weight: bold; color: #00ff88;">${monto}</div>
</div>
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Tubos de Tinte</label>
<div style="font-size: 24px; font-weight: bold; color: #00d4ff;">${tubos}</div>
</div>
${productos.length > 0 ? `
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Productos (${productos.length})</label>
<ul style="list-style: none; margin-top: 10px; max-height: 150px; overflow-y: auto;">
${productos.slice(0, 5).map(p => `
<li style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
${p.nombre || 'Producto'} - ${window.Utils.formatMoney(p.importe || 0)}
</li>
`).join('')}
</ul>
</div>
` : ''}
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-secondary" onclick="this.closest('.camera-modal').remove()" style="flex: 1;">
Cerrar
</button>
<button class="btn btn-primary" onclick="confirmarVenta(${result.monto || 0}, ${tubos}); this.closest('.camera-modal').remove();" style="flex: 1;">
Registrar Venta
</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
async function confirmarVenta(monto, tubos) {
// This would integrate with the main sales flow
// For now, just show a notification
if (window.Utils) {
window.Utils.showNotification(`Venta de ${window.Utils.formatMoney(monto)} lista para confirmar`, 'info');
}
// Here you could redirect to Mattermost or show a form
// to complete the sale registration
}
// File input fallback for devices without camera API
function createFileInput() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.style.display = 'none';
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const imageData = event.target.result;
if (window.Utils) {
window.Utils.showNotification('Procesando imagen...', 'info');
}
try {
const response = await fetch('/api/capture/ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageData })
});
const result = await response.json();
if (result.success) {
mostrarResultadoOCR(result);
} else {
if (window.Utils) {
window.Utils.showNotification('Error procesando imagen', 'error');
}
}
} catch (error) {
console.error('Error:', error);
}
};
reader.readAsDataURL(file);
});
return input;
}
// Export functions
window.abrirCamara = abrirCamara;
window.cerrarCamara = cerrarCamara;
window.capturarFoto = capturarFoto;