OPCIÓN A: A2 Virtual Scroll + A3 Celery + A4 asyncpg PoC + A5 particionamiento

A2 — Virtual scroll en tablas grandes:
- Nuevo helper VirtualScroll en pos/static/js/virtual-scroll.js
- inventory.js: tabla de productos con virtual scroll
- customers.js: tabla de clientes con virtual scroll
- fleet.js: renderMaintenance() y renderHistory() con virtual scroll
- Templates envueltos en .vs-container para scroll

A3 — Celery worker queue:
- pos/celery_app.py + pos/tasks.py (warm cache, bulk import, reports)
- Blueprint tasks_bp.py con endpoints /pos/api/tasks/*
- Script scripts/start_celery.sh

A4 — asyncpg + Quart PoC:
- pos/async_catalog.py: endpoint /pos/api/catalog/async-search
- scripts/benchmark_async_catalog.py: benchmark Flask vs Quart

A5 — Particionar vehicle_parts:
- scripts/partition_vehicle_parts.py: migración segura por hash (16 particiones)
- Soporta --dry-run, --skip-swap, --skip-drop

Tests: 36/36 pasando
This commit is contained in:
2026-04-27 09:53:36 +00:00
parent 042acd6207
commit a1be8dd0ea
15 changed files with 998 additions and 145 deletions

93
pos/async_catalog.py Normal file
View File

@@ -0,0 +1,93 @@
"""Async catalog search PoC using Quart + asyncpg.
Run:
hypercorn async_catalog:app --bind 0.0.0.0:5002
Endpoint:
GET /pos/api/catalog/async-search?q=filtro&limit=50&tenant_id=1
This demonstrates I/O non-blocking search using asyncpg.
"""
import os
import asyncio
import asyncpg
from quart import Quart, request, jsonify, g
app = Quart(__name__)
MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
# Shared connection pool
_pool = None
async def _get_pool():
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(MASTER_DB_URL, min_size=2, max_size=10)
return _pool
@app.before_serving
async def startup():
await _get_pool()
@app.after_serving
async def shutdown():
global _pool
if _pool:
await _pool.close()
_pool = None
@app.route('/pos/api/catalog/async-search')
async def async_search():
q = request.args.get('q', '').strip()
if not q or len(q) < 2:
return jsonify({'data': []})
limit = min(request.args.get('limit', 50, type=int), 100)
pool = await _get_pool()
async with pool.acquire() as conn:
# Simple text search (PoC scope)
clean_q = q.upper().replace(' ', '')
rows = await conn.fetch("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE $1
OR p.name_part ILIKE $2
OR p.name_es ILIKE $2
ORDER BY p.oem_part_number
LIMIT $3
""", f'%{clean_q}%', f'%{q}%', limit)
part_ids = [r['id_part'] for r in rows]
vehicle_map = {}
if part_ids:
vrows = await conn.fetch("""
SELECT part_id, name_brand, name_model, year_car
FROM part_vehicle_preview
WHERE part_id = ANY($1)
""", part_ids)
for vr in vrows:
vehicle_map[vr['part_id']] = f"{vr['name_brand']} {vr['name_model']} {vr['year_car']}"
results = []
for r in rows:
results.append({
'id': r['id_part'],
'oem_part_number': r['oem_part_number'],
'name': r['name_part'],
'name_es': r['name_es'],
'image_url': r['image_url'],
'group_id': r['group_id'],
'vehicle_info': vehicle_map.get(r['id_part'], ''),
})
return jsonify({'data': results})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5002)

View File

@@ -0,0 +1,46 @@
"""Blueprint for background task management (Celery)."""
from flask import Blueprint, jsonify, request
from auth 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
def enqueue_warm_cache():
"""Enqueue vehicle cache warming task."""
task = warm_vehicle_cache_task.apply_async()
return jsonify({'task_id': task.id, 'status': 'queued'})
@tasks_bp.route('/report', methods=['POST'])
@require_auth
def enqueue_report():
"""Enqueue report generation task."""
data = request.get_json() or {}
report_type = data.get('report_type', 'sales')
params = data.get('params', {})
tenant_id = getattr(request, 'tenant_id', None)
task = generate_report_task.apply_async(args=[report_type, params, tenant_id])
return jsonify({'task_id': task.id, 'status': 'queued'})
@tasks_bp.route('/<task_id>/status', methods=['GET'])
@require_auth
def task_status(task_id):
"""Get status of a background task."""
from celery_app import celery
result = celery.AsyncResult(task_id)
response = {
'task_id': task_id,
'status': result.status,
}
if result.status == 'PROGRESS':
response['meta'] = result.info
elif result.successful():
response['result'] = result.result
elif result.failed():
response['error'] = str(result.result)
return jsonify(response)

29
pos/celery_app.py Normal file
View File

@@ -0,0 +1,29 @@
"""Celery application configuration for Nexus POS background tasks."""
import os
from celery import Celery
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
# Use Redis DB 1 for Celery to avoid clashing with app cache (DB 0)
BROKER_URL = os.environ.get('CELERY_BROKER_URL', REDIS_URL.replace('/0', '/1'))
BACKEND_URL = os.environ.get('CELERY_RESULT_BACKEND', REDIS_URL.replace('/0', '/1'))
celery = Celery(
'nexus',
broker=BROKER_URL,
backend=BACKEND_URL,
include=['tasks'],
)
celery.conf.update(
task_serializer='json',
accept_content=['json'],
result_serializer='json',
timezone='America/Mexico_City',
enable_utc=True,
task_track_started=True,
task_time_limit=3600, # 1 hour hard limit
task_soft_time_limit=3300, # 55 min soft limit
worker_prefetch_multiplier=1,
result_expires=86400, # Results expire after 24h
)

View File

@@ -94,45 +94,53 @@ const Customers = (() => {
}
}
function renderCustomerRow(c) {
const tier = tierMap[c.price_tier] || 'Mostrador';
const tClass = tierClass[c.price_tier] || 'mostrador';
const limit = parseFloat(c.credit_limit || 0);
const balance = parseFloat(c.credit_balance || 0);
const available = Math.max(0, limit - balance);
const usedPct = limit > 0 ? Math.round((balance / limit) * 100) : 0;
const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : '';
const num = String(c.id).padStart(5, '0');
const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : '';
return '<tr class="' + selClass + '" onclick="selectCustomer(' + c.id + ')">' +
'<td class="cell-num">' + num + '</td>' +
'<td>' +
'<div class="cell-name">' + (c.name || '') + '</div>' +
'<div class="cell-name-sub hide-mobile">' + (c.email || '') + '</div>' +
'</td>' +
'<td class="cell-rfc hide-mobile">' + (c.rfc || '-') + '</td>' +
'<td class="hide-mobile">' + (c.phone || '-') + '</td>' +
'<td class="hide-mobile" style="font-size:var(--text-caption);color:var(--color-text-secondary);">' + (c.email || '-') + '</td>' +
'<td><span class="tipo-chip tipo-chip--' + tClass + '">' + tier + '</span></td>' +
'<td class="cell-credit ' + creditClass + '">' + fmt(available) + '</td>' +
'<td class="cell-date hide-mobile">' + formatDate(c.last_purchase || c.created_at) + '</td>' +
'<td>' + statusBadge(c) + '</td>' +
'</tr>';
}
var customersVS = null;
function renderTable(customers) {
const tbody = document.getElementById('customersBody');
if (!tbody) return;
tbody.innerHTML = '';
if (!customers || customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>';
return;
}
customers.forEach((c, idx) => {
const tier = tierMap[c.price_tier] || 'Mostrador';
const tClass = tierClass[c.price_tier] || 'mostrador';
const limit = parseFloat(c.credit_limit || 0);
const balance = parseFloat(c.credit_balance || 0);
const available = Math.max(0, limit - balance);
const usedPct = limit > 0 ? Math.round((balance / limit) * 100) : 0;
const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : '';
const num = String(c.id).padStart(5, '0');
const tr = document.createElement('tr');
if (currentCustomer && currentCustomer.id === c.id) tr.className = 'selected';
tr.onclick = () => selectCustomer(c.id);
tr.innerHTML = `
<td class="cell-num">${num}</td>
<td>
<div class="cell-name">${c.name || ''}</div>
<div class="cell-name-sub hide-mobile">${c.email || ''}</div>
</td>
<td class="cell-rfc hide-mobile">${c.rfc || '-'}</td>
<td class="hide-mobile">${c.phone || '-'}</td>
<td class="hide-mobile" style="font-size:var(--text-caption);color:var(--color-text-secondary);">${c.email || '-'}</td>
<td><span class="tipo-chip tipo-chip--${tClass}">${tier}</span></td>
<td class="cell-credit ${creditClass}">${fmt(available)}</td>
<td class="cell-date hide-mobile">${formatDate(c.last_purchase || c.created_at)}</td>
<td>${statusBadge(c)}</td>
`;
tbody.appendChild(tr);
});
if (!customersVS) {
customersVS = new VirtualScroll({
container: tbody,
rowHeight: 52,
buffer: 3,
renderRow: renderCustomerRow,
emptyHtml: '<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>'
});
}
customersVS.setData(customers);
}
function renderPagination(pag) {

View File

@@ -298,52 +298,66 @@ var Fleet = (function() {
});
}
function renderMaintRow(item) {
var v = item.vehicle;
var s = item.schedule;
var now = new Date();
var isOverdue = false;
if (s.next_due_at && new Date(s.next_due_at) < now) isOverdue = true;
if (s.next_due_km && s.next_due_km <= v.current_mileage) isOverdue = true;
var interval = '';
if (s.interval_km) interval += fmt(s.interval_km) + ' km';
if (s.interval_km && s.interval_months) interval += ' / ';
if (s.interval_months) interval += s.interval_months + ' meses';
var next = '';
if (s.next_due_at) next += fmtDate(s.next_due_at);
if (s.next_due_at && s.next_due_km) next += ' / ';
if (s.next_due_km) next += fmt(s.next_due_km) + ' km';
return '<tr>' +
'<td><strong>' + esc(v.plate || 'S/P') + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' +
esc((v.make || '') + ' ' + (v.model || '')) + '</span></td>' +
'<td>' + esc(s.maintenance_type) + '</td>' +
'<td class="mono">' + (interval || '—') + '</td>' +
'<td>' + fmtDate(s.last_done_at) + (s.last_done_km ? '<br><span class="mono" style="font-size:var(--text-caption);">' + fmt(s.last_done_km) + ' km</span>' : '') + '</td>' +
'<td>' + (next || '—') + '</td>' +
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
'</tr>';
}
var maintVS = null;
function renderMaintenance(results) {
var body = document.getElementById('maintBody');
var rows = [];
var items = [];
results.forEach(function(r) {
var v = r.vehicle;
r.schedules.forEach(function(s) {
var now = new Date();
var isOverdue = false;
if (s.next_due_at && new Date(s.next_due_at) < now) isOverdue = true;
if (s.next_due_km && s.next_due_km <= v.current_mileage) isOverdue = true;
var interval = '';
if (s.interval_km) interval += fmt(s.interval_km) + ' km';
if (s.interval_km && s.interval_months) interval += ' / ';
if (s.interval_months) interval += s.interval_months + ' meses';
var next = '';
if (s.next_due_at) next += fmtDate(s.next_due_at);
if (s.next_due_at && s.next_due_km) next += ' / ';
if (s.next_due_km) next += fmt(s.next_due_km) + ' km';
rows.push(
'<tr>' +
'<td><strong>' + esc(v.plate || 'S/P') + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' +
esc((v.make || '') + ' ' + (v.model || '')) + '</span></td>' +
'<td>' + esc(s.maintenance_type) + '</td>' +
'<td class="mono">' + (interval || '—') + '</td>' +
'<td>' + fmtDate(s.last_done_at) + (s.last_done_km ? '<br><span class="mono" style="font-size:var(--text-caption);">' + fmt(s.last_done_km) + ' km</span>' : '') + '</td>' +
'<td>' + (next || '—') + '</td>' +
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
'</tr>'
);
items.push({vehicle: r.vehicle, schedule: s});
});
});
if (!rows.length) {
if (!items.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
'No hay programas de mantenimiento.<br><button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button></td></tr>';
return;
}
body.innerHTML = rows.join('');
if (!maintVS) {
maintVS = new VirtualScroll({
container: body,
rowHeight: 64,
buffer: 3,
renderRow: renderMaintRow,
emptyHtml: '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay programas de mantenimiento.</td></tr>'
});
}
maintVS.setData(items);
}
// ─── History Tab ───
@@ -371,6 +385,21 @@ var Fleet = (function() {
});
}
function renderHistoryRow(l) {
return '<tr>' +
'<td>' + fmtDate(l.created_at) + '</td>' +
'<td><strong>' + esc(l._plate) + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + esc(l._make) + '</span></td>' +
'<td>' + esc(l.maintenance_type) + '</td>' +
'<td class="mono">' + fmt(l.mileage_at) + '</td>' +
'<td class="mono">' + fmtMoney(l.cost) + '</td>' +
'<td>' + esc(l.employee_name || '—') + '</td>' +
'<td style="max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + esc(l.notes || '—') + '</td>' +
'</tr>';
}
var historyVS = null;
function renderHistory(results) {
var body = document.getElementById('historyBody');
var allLogs = [];
@@ -393,20 +422,16 @@ var Fleet = (function() {
return;
}
var html = '';
allLogs.forEach(function(l) {
html += '<tr>' +
'<td>' + fmtDate(l.created_at) + '</td>' +
'<td><strong>' + esc(l._plate) + '</strong><br>' +
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + esc(l._make) + '</span></td>' +
'<td>' + esc(l.maintenance_type) + '</td>' +
'<td class="mono">' + fmt(l.mileage_at) + '</td>' +
'<td class="mono">' + fmtMoney(l.cost) + '</td>' +
'<td>' + esc(l.employee_name || '—') + '</td>' +
'<td style="max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + esc(l.notes || '—') + '</td>' +
'</tr>';
});
body.innerHTML = html;
if (!historyVS) {
historyVS = new VirtualScroll({
container: body,
rowHeight: 48,
buffer: 3,
renderRow: renderHistoryRow,
emptyHtml: '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay registros de mantenimiento</td></tr>'
});
}
historyVS.setData(allLogs);
}
// ─── Alerts Tab ───

View File

@@ -13,6 +13,7 @@
var currentPage = 1;
var currentSearch = '';
var draftCountId = null;
var inventoryVS = null;
// --- API helper ---
function apiFetch(url, opts) {
@@ -52,6 +53,24 @@
// STOCK / PRODUCTS (panel-stock)
// =====================================================================
function renderInventoryRow(it) {
return '<tr style="cursor:pointer;" onclick="viewProductDetail(' + it.id + ')">' +
'<td class="td--mono">' + esc(it.barcode) + '</td>' +
'<td class="td--mono">' + esc(it.part_number) + '</td>' +
'<td class="td--primary">' + esc(it.name) + '</td>' +
'<td>' + esc(it.brand) + '</td>' +
'<td style="text-align:right" class="td--primary">' + it.stock + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.cost) + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_1) + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_2) + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_3) + '</td>' +
'<td>' + esc(it.location) + '</td>' +
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button>' +
'</td></tr>';
}
function loadItems(page, search) {
currentPage = page || 1;
currentSearch = search !== undefined ? search : currentSearch;
@@ -69,23 +88,16 @@
return;
}
tbody.innerHTML = items.map(function (it) {
return '<tr style="cursor:pointer;" onclick="viewProductDetail(' + it.id + ')">' +
'<td class="td--mono">' + esc(it.barcode) + '</td>' +
'<td class="td--mono">' + esc(it.part_number) + '</td>' +
'<td class="td--primary">' + esc(it.name) + '</td>' +
'<td>' + esc(it.brand) + '</td>' +
'<td style="text-align:right" class="td--primary">' + it.stock + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.cost) + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_1) + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_2) + '</td>' +
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_3) + '</td>' +
'<td>' + esc(it.location) + '</td>' +
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button>' +
'</td></tr>';
}).join('');
if (!inventoryVS) {
inventoryVS = new VirtualScroll({
container: tbody,
rowHeight: 48,
buffer: 3,
renderRow: renderInventoryRow,
emptyHtml: '<tr><td colspan="11" style="text-align:center;padding:30px;color:var(--color-text-muted);">Sin productos</td></tr>'
});
}
inventoryVS.setData(items);
// Pagination
var pg = data.pagination || {};

View File

@@ -0,0 +1,155 @@
/**
* virtual-scroll.js — Lightweight vanilla-JS virtual scroll helper.
* Supports <div> containers and <tbody> tables.
*
* Usage:
* var vs = new VirtualScroll({
* container: document.getElementById('myTableBody'),
* rowHeight: 48,
* buffer: 5,
* renderRow: function(item, index) { return '<tr>...</tr>'; }
* });
* vs.setData(arrayOfItems);
*/
(function(window) {
'use strict';
function VirtualScroll(opts) {
this.container = opts.container;
this.rowHeight = opts.rowHeight || 48;
this.buffer = opts.buffer || 5;
this.renderRow = opts.renderRow || function() { return ''; };
this.emptyHtml = opts.emptyHtml || '';
this.data = [];
this._scrollHandler = this._onScroll.bind(this);
this._resizeHandler = this._onResize.bind(this);
this._isTbody = this.container.tagName === 'TBODY';
this._init();
}
VirtualScroll.prototype._init = function() {
var c = this.container;
if (!this._isTbody) {
c.style.overflowY = 'auto';
c.style.position = 'relative';
if (!c.style.maxHeight && !c.style.height) {
c.style.maxHeight = '60vh';
}
} else {
// For tbody, scroll is on the parent element (table wrapper)
var table = c.closest('table');
if (table) {
var wrapper = table.parentElement;
if (wrapper && wrapper.classList.contains('vs-container')) {
wrapper.addEventListener('scroll', this._scrollHandler, { passive: true });
}
}
}
window.addEventListener('resize', this._resizeHandler, { passive: true });
};
VirtualScroll.prototype.setData = function(data) {
this.data = data || [];
this._render();
};
VirtualScroll.prototype.refresh = function() {
this._render();
};
VirtualScroll.prototype._onScroll = function() {
this._render();
};
VirtualScroll.prototype._onResize = function() {
this._render();
};
VirtualScroll.prototype._getScrollTop = function() {
if (this._isTbody) {
var table = this.container.closest('table');
if (table) {
var wrapper = table.parentElement;
if (wrapper && wrapper.classList.contains('vs-container')) {
return wrapper.scrollTop;
}
}
return 0;
}
return this.container.scrollTop;
};
VirtualScroll.prototype._getContainerHeight = function() {
if (this._isTbody) {
var table = this.container.closest('table');
if (table) {
var wrapper = table.parentElement;
if (wrapper && wrapper.classList.contains('vs-container')) {
return wrapper.clientHeight;
}
}
return 600;
}
return this.container.clientHeight;
};
VirtualScroll.prototype._render = function() {
var data = this.data;
var rowH = this.rowHeight;
var buffer = this.buffer;
if (!data.length) {
if (this._isTbody) {
this.container.innerHTML = this.emptyHtml;
} else {
this.container.innerHTML = this.emptyHtml;
}
return;
}
var scrollTop = this._getScrollTop();
var containerHeight = this._getContainerHeight();
var startIdx = Math.max(0, Math.floor(scrollTop / rowH) - buffer);
var endIdx = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowH) + buffer);
var html = '';
if (this._isTbody) {
// Top spacer row
var topSpacerHeight = startIdx * rowH;
if (topSpacerHeight > 0) {
html += '<tr style="height:' + topSpacerHeight + 'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>';
}
for (var i = startIdx; i < endIdx; i++) {
html += this.renderRow(data[i], i);
}
// Bottom spacer row
var bottomSpacerHeight = (data.length - endIdx) * rowH;
if (bottomSpacerHeight > 0) {
html += '<tr style="height:' + bottomSpacerHeight + 'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>';
}
} else {
for (var j = startIdx; j < endIdx; j++) {
html += this.renderRow(data[j], j);
}
}
this.container.innerHTML = html;
};
VirtualScroll.prototype.destroy = function() {
if (this._isTbody) {
var table = this.container.closest('table');
if (table) {
var wrapper = table.parentElement;
if (wrapper && wrapper.classList.contains('vs-container')) {
wrapper.removeEventListener('scroll', this._scrollHandler);
}
}
} else {
this.container.removeEventListener('scroll', this._scrollHandler);
}
window.removeEventListener('resize', this._resizeHandler);
};
window.VirtualScroll = VirtualScroll;
})(window);

98
pos/tasks.py Normal file
View File

@@ -0,0 +1,98 @@
"""Celery tasks for Nexus POS background jobs."""
import os
import sys
import time
# Ensure pos/ is on path for imports when Celery worker runs standalone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from celery_app import celery
import psycopg2
import redis as redis_lib
MASTER_DB_URL = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
def _get_db():
return psycopg2.connect(MASTER_DB_URL)
def _get_redis():
return redis_lib.from_url(REDIS_URL, decode_responses=True)
@celery.task(bind=True, max_retries=3)
def warm_vehicle_cache_task(self, batch_size=5000, ttl=3600):
"""Warm Redis cache for vehicle info from part_vehicle_preview."""
conn = _get_db()
cur = conn.cursor()
r = _get_redis()
r.ping()
cur.execute("SELECT id_part FROM parts WHERE oem_part_number IS NOT NULL ORDER BY id_part")
all_ids = [row[0] for row in cur.fetchall()]
total = len(all_ids)
processed = 0
cached = 0
start = time.time()
for i in range(0, total, batch_size):
batch = all_ids[i:i + batch_size]
cur.execute("""
SELECT part_id, name_brand, name_model, year_car
FROM part_vehicle_preview
WHERE part_id = ANY(%s)
""", (batch,))
pipe = r.pipeline()
batch_cached = 0
for row in cur.fetchall():
info = f"{row[1]} {row[2]} {row[3]}"
pipe.setex(f'nexus:vehicle:{row[0]}', ttl, info)
batch_cached += 1
pipe.execute()
processed += len(batch)
cached += batch_cached
self.update_state(
state='PROGRESS',
meta={'current': processed, 'total': total, 'cached': cached}
)
cur.close()
conn.close()
elapsed = time.time() - start
return {'total': total, 'cached': cached, 'elapsed': int(elapsed)}
@celery.task(bind=True, max_retries=2)
def bulk_import_inventory_task(self, csv_path, tenant_id, branch_id=None):
"""Bulk import inventory from CSV in background."""
from services.inventory_engine import bulk_import_csv
conn = _get_db()
try:
result = bulk_import_csv(conn, csv_path, tenant_id, branch_id)
conn.commit()
return result
except Exception as exc:
conn.rollback()
raise self.retry(exc=exc)
finally:
conn.close()
@celery.task(bind=True, max_retries=1)
def generate_report_task(self, report_type, params, tenant_id):
"""Generate heavy reports asynchronously."""
# Placeholder: implement actual report generation per type
self.update_state(state='PROGRESS', meta={'step': 'collecting_data'})
time.sleep(2) # Simulate work
return {
'report_type': report_type,
'tenant_id': tenant_id,
'status': 'completed',
'url': f'/pos/static/reports/{report_type}_{tenant_id}.pdf',
}

View File

@@ -618,6 +618,7 @@
<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/virtual-scroll.js" defer></script>
<script src="/pos/static/js/customers.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>

View File

@@ -78,42 +78,46 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-4);">
<h2 style="font-family:var(--font-heading);font-size:var(--text-h4);font-weight:var(--font-weight-bold);letter-spacing:var(--tracking-wide);text-transform:uppercase;">Programas de Mantenimiento</h2>
</div>
<table class="data-table" id="maintTable">
<thead>
<tr>
<th>Vehiculo</th>
<th>Tipo</th>
<th>Intervalo</th>
<th>Ultimo</th>
<th>Proximo</th>
<th>Estado</th>
<th></th>
</tr>
</thead>
<tbody id="maintBody">
<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">Cargando...</td></tr>
</tbody>
</table>
<div class="vs-container" style="max-height:60vh;overflow-y:auto;">
<table class="data-table" id="maintTable">
<thead>
<tr>
<th>Vehiculo</th>
<th>Tipo</th>
<th>Intervalo</th>
<th>Ultimo</th>
<th>Proximo</th>
<th>Estado</th>
<th></th>
</tr>
</thead>
<tbody id="maintBody">
<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Tab: History -->
<div class="tab-panel" id="tab-history">
<table class="data-table">
<thead>
<tr>
<th>Fecha</th>
<th>Vehiculo</th>
<th>Tipo</th>
<th>Km</th>
<th>Costo</th>
<th>Empleado</th>
<th>Notas</th>
</tr>
</thead>
<tbody id="historyBody">
<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">Cargando...</td></tr>
</tbody>
</table>
<div class="vs-container" style="max-height:60vh;overflow-y:auto;">
<table class="data-table">
<thead>
<tr>
<th>Fecha</th>
<th>Vehiculo</th>
<th>Tipo</th>
<th>Km</th>
<th>Costo</th>
<th>Empleado</th>
<th>Notas</th>
</tr>
</thead>
<tbody id="historyBody">
<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Tab: Alerts -->

View File

@@ -312,25 +312,27 @@
</div>
<div class="table-wrapper">
<table class="data-table" id="stockTable">
<thead>
<tr>
<th>Barcode</th>
<th>No. Parte</th>
<th>Nombre</th>
<th>Marca</th>
<th style="text-align:right">Stock</th>
<th style="text-align:right">Costo</th>
<th style="text-align:right">Precio 1</th>
<th style="text-align:right">Precio 2</th>
<th style="text-align:right">Precio 3</th>
<th>Ubicación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="productTableBody">
</tbody>
</table>
<div class="vs-container" style="max-height:60vh;overflow-y:auto;">
<table class="data-table" id="stockTable">
<thead>
<tr>
<th>Barcode</th>
<th>No. Parte</th>
<th>Nombre</th>
<th>Marca</th>
<th style="text-align:right">Stock</th>
<th style="text-align:right">Costo</th>
<th style="text-align:right">Precio 1</th>
<th style="text-align:right">Precio 2</th>
<th style="text-align:right">Precio 3</th>
<th>Ubicación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="productTableBody">
</tbody>
</table>
</div>
<div class="table-footer">
<div id="productPagination"></div>
</div>
@@ -811,6 +813,7 @@
<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/virtual-scroll.js" defer></script>
<script src="/pos/static/js/inventory.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>