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,217 @@
/**
* Sales Bot - Service Worker for PWA Offline Support
*/
const CACHE_NAME = 'salesbot-v1';
const RUNTIME_CACHE = 'salesbot-runtime-v1';
// Assets to cache on install
const PRECACHE_ASSETS = [
'/dashboard',
'/dashboard/analytics',
'/dashboard/executive',
'/static/css/main.css',
'/static/js/app.js',
'/static/js/pwa.js',
'/static/js/camera.js',
'/static/js/charts.js',
'/static/manifest.json'
];
// API endpoints to cache with network-first strategy
const API_ROUTES = [
'/api/dashboard/resumen',
'/api/dashboard/ranking',
'/api/dashboard/ventas-recientes',
'/api/analytics/trends',
'/api/analytics/predictions'
];
// Install event - cache core assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('[SW] Precaching assets');
return cache.addAll(PRECACHE_ASSETS);
})
.then(() => self.skipWaiting())
.catch(err => console.error('[SW] Precache failed:', err))
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME && name !== RUNTIME_CACHE)
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - handle requests
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip external requests
if (url.origin !== location.origin) {
return;
}
// API requests - Network first, fall back to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets - Cache first, fall back to network
if (url.pathname.startsWith('/static/')) {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages - Network first with cache fallback
if (request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// Default - Network first
event.respondWith(networkFirst(request));
});
// Cache first strategy
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache first failed:', error);
return new Response('Offline', { status: 503 });
}
}
// Network first strategy
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(RUNTIME_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Network failed, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[SW] Serving from cache:', request.url);
return cachedResponse;
}
// No cache available
if (request.headers.get('Accept')?.includes('text/html')) {
return caches.match('/dashboard'); // Fallback to main page
}
if (request.headers.get('Accept')?.includes('application/json')) {
return new Response(JSON.stringify({
error: 'offline',
message: 'Sin conexion. Mostrando datos en cache.'
}), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Offline', { status: 503 });
}
}
// Handle messages from clients
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Background sync for offline actions (if supported)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-sales') {
event.waitUntil(syncSales());
}
});
async function syncSales() {
// Sync any pending sales when back online
console.log('[SW] Syncing pending sales...');
// Implementation would go here
}
// Push notifications (if implemented)
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || 'Nueva notificacion de Sales Bot',
icon: '/static/icons/icon-192x192.png',
badge: '/static/icons/icon-72x72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/dashboard'
}
};
event.waitUntil(
self.registration.showNotification(data.title || 'Sales Bot', options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window' }).then(clientList => {
// Focus existing window if available
for (const client of clientList) {
if (client.url.includes('/dashboard') && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(event.notification.data?.url || '/dashboard');
}
})
);
});