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:
93
pos/async_catalog.py
Normal file
93
pos/async_catalog.py
Normal 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)
|
||||||
46
pos/blueprints/tasks_bp.py
Normal file
46
pos/blueprints/tasks_bp.py
Normal 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
29
pos/celery_app.py
Normal 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
|
||||||
|
)
|
||||||
@@ -94,17 +94,7 @@ const Customers = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(customers) {
|
function renderCustomerRow(c) {
|
||||||
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 tier = tierMap[c.price_tier] || 'Mostrador';
|
||||||
const tClass = tierClass[c.price_tier] || 'mostrador';
|
const tClass = tierClass[c.price_tier] || 'mostrador';
|
||||||
const limit = parseFloat(c.credit_limit || 0);
|
const limit = parseFloat(c.credit_limit || 0);
|
||||||
@@ -113,27 +103,45 @@ const Customers = (() => {
|
|||||||
const usedPct = limit > 0 ? Math.round((balance / limit) * 100) : 0;
|
const usedPct = limit > 0 ? Math.round((balance / limit) * 100) : 0;
|
||||||
const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : '';
|
const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : '';
|
||||||
const num = String(c.id).padStart(5, '0');
|
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>';
|
||||||
|
}
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
var customersVS = null;
|
||||||
if (currentCustomer && currentCustomer.id === c.id) tr.className = 'selected';
|
|
||||||
tr.onclick = () => selectCustomer(c.id);
|
function renderTable(customers) {
|
||||||
tr.innerHTML = `
|
const tbody = document.getElementById('customersBody');
|
||||||
<td class="cell-num">${num}</td>
|
if (!tbody) return;
|
||||||
<td>
|
|
||||||
<div class="cell-name">${c.name || ''}</div>
|
if (!customers || customers.length === 0) {
|
||||||
<div class="cell-name-sub hide-mobile">${c.email || ''}</div>
|
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>';
|
||||||
</td>
|
return;
|
||||||
<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>
|
if (!customersVS) {
|
||||||
<td><span class="tipo-chip tipo-chip--${tClass}">${tier}</span></td>
|
customersVS = new VirtualScroll({
|
||||||
<td class="cell-credit ${creditClass}">${fmt(available)}</td>
|
container: tbody,
|
||||||
<td class="cell-date hide-mobile">${formatDate(c.last_purchase || c.created_at)}</td>
|
rowHeight: 52,
|
||||||
<td>${statusBadge(c)}</td>
|
buffer: 3,
|
||||||
`;
|
renderRow: renderCustomerRow,
|
||||||
tbody.appendChild(tr);
|
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) {
|
function renderPagination(pag) {
|
||||||
const container = document.querySelector('.pagination');
|
const container = document.querySelector('.pagination');
|
||||||
|
|||||||
@@ -298,13 +298,9 @@ var Fleet = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMaintenance(results) {
|
function renderMaintRow(item) {
|
||||||
var body = document.getElementById('maintBody');
|
var v = item.vehicle;
|
||||||
var rows = [];
|
var s = item.schedule;
|
||||||
|
|
||||||
results.forEach(function(r) {
|
|
||||||
var v = r.vehicle;
|
|
||||||
r.schedules.forEach(function(s) {
|
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var isOverdue = false;
|
var isOverdue = false;
|
||||||
if (s.next_due_at && new Date(s.next_due_at) < now) isOverdue = true;
|
if (s.next_due_at && new Date(s.next_due_at) < now) isOverdue = true;
|
||||||
@@ -320,8 +316,7 @@ var Fleet = (function() {
|
|||||||
if (s.next_due_at && s.next_due_km) next += ' / ';
|
if (s.next_due_at && s.next_due_km) next += ' / ';
|
||||||
if (s.next_due_km) next += fmt(s.next_due_km) + ' km';
|
if (s.next_due_km) next += fmt(s.next_due_km) + ' km';
|
||||||
|
|
||||||
rows.push(
|
return '<tr>' +
|
||||||
'<tr>' +
|
|
||||||
'<td><strong>' + esc(v.plate || 'S/P') + '</strong><br>' +
|
'<td><strong>' + esc(v.plate || 'S/P') + '</strong><br>' +
|
||||||
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' +
|
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' +
|
||||||
esc((v.make || '') + ' ' + (v.model || '')) + '</span></td>' +
|
esc((v.make || '') + ' ' + (v.model || '')) + '</span></td>' +
|
||||||
@@ -332,18 +327,37 @@ var Fleet = (function() {
|
|||||||
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
|
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
|
||||||
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
|
(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>' +
|
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
|
||||||
'</tr>'
|
'</tr>';
|
||||||
);
|
}
|
||||||
|
|
||||||
|
var maintVS = null;
|
||||||
|
|
||||||
|
function renderMaintenance(results) {
|
||||||
|
var body = document.getElementById('maintBody');
|
||||||
|
var items = [];
|
||||||
|
|
||||||
|
results.forEach(function(r) {
|
||||||
|
r.schedules.forEach(function(s) {
|
||||||
|
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);">' +
|
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>';
|
'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;
|
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 ───
|
// ─── 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) {
|
function renderHistory(results) {
|
||||||
var body = document.getElementById('historyBody');
|
var body = document.getElementById('historyBody');
|
||||||
var allLogs = [];
|
var allLogs = [];
|
||||||
@@ -393,20 +422,16 @@ var Fleet = (function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '';
|
if (!historyVS) {
|
||||||
allLogs.forEach(function(l) {
|
historyVS = new VirtualScroll({
|
||||||
html += '<tr>' +
|
container: body,
|
||||||
'<td>' + fmtDate(l.created_at) + '</td>' +
|
rowHeight: 48,
|
||||||
'<td><strong>' + esc(l._plate) + '</strong><br>' +
|
buffer: 3,
|
||||||
'<span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + esc(l._make) + '</span></td>' +
|
renderRow: renderHistoryRow,
|
||||||
'<td>' + esc(l.maintenance_type) + '</td>' +
|
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>'
|
||||||
'<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;
|
}
|
||||||
|
historyVS.setData(allLogs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Alerts Tab ───
|
// ─── Alerts Tab ───
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
var currentPage = 1;
|
var currentPage = 1;
|
||||||
var currentSearch = '';
|
var currentSearch = '';
|
||||||
var draftCountId = null;
|
var draftCountId = null;
|
||||||
|
var inventoryVS = null;
|
||||||
|
|
||||||
// --- API helper ---
|
// --- API helper ---
|
||||||
function apiFetch(url, opts) {
|
function apiFetch(url, opts) {
|
||||||
@@ -52,6 +53,24 @@
|
|||||||
// STOCK / PRODUCTS (panel-stock)
|
// 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) {
|
function loadItems(page, search) {
|
||||||
currentPage = page || 1;
|
currentPage = page || 1;
|
||||||
currentSearch = search !== undefined ? search : currentSearch;
|
currentSearch = search !== undefined ? search : currentSearch;
|
||||||
@@ -69,23 +88,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = items.map(function (it) {
|
if (!inventoryVS) {
|
||||||
return '<tr style="cursor:pointer;" onclick="viewProductDetail(' + it.id + ')">' +
|
inventoryVS = new VirtualScroll({
|
||||||
'<td class="td--mono">' + esc(it.barcode) + '</td>' +
|
container: tbody,
|
||||||
'<td class="td--mono">' + esc(it.part_number) + '</td>' +
|
rowHeight: 48,
|
||||||
'<td class="td--primary">' + esc(it.name) + '</td>' +
|
buffer: 3,
|
||||||
'<td>' + esc(it.brand) + '</td>' +
|
renderRow: renderInventoryRow,
|
||||||
'<td style="text-align:right" class="td--primary">' + it.stock + '</td>' +
|
emptyHtml: '<tr><td colspan="11" style="text-align:center;padding:30px;color:var(--color-text-muted);">Sin productos</td></tr>'
|
||||||
'<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>' +
|
inventoryVS.setData(items);
|
||||||
'<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('');
|
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
var pg = data.pagination || {};
|
var pg = data.pagination || {};
|
||||||
|
|||||||
155
pos/static/js/virtual-scroll.js
Normal file
155
pos/static/js/virtual-scroll.js
Normal 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
98
pos/tasks.py
Normal 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',
|
||||||
|
}
|
||||||
@@ -618,6 +618,7 @@
|
|||||||
<script src="/pos/static/js/app-init.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/pos-utils.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.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/customers.js" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-4);">
|
<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>
|
<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>
|
</div>
|
||||||
|
<div class="vs-container" style="max-height:60vh;overflow-y:auto;">
|
||||||
<table class="data-table" id="maintTable">
|
<table class="data-table" id="maintTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -95,9 +96,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab: History -->
|
<!-- Tab: History -->
|
||||||
<div class="tab-panel" id="tab-history">
|
<div class="tab-panel" id="tab-history">
|
||||||
|
<div class="vs-container" style="max-height:60vh;overflow-y:auto;">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -115,6 +118,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Alerts -->
|
<!-- Tab: Alerts -->
|
||||||
<div class="tab-panel" id="tab-alerts">
|
<div class="tab-panel" id="tab-alerts">
|
||||||
|
|||||||
@@ -312,6 +312,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
|
<div class="vs-container" style="max-height:60vh;overflow-y:auto;">
|
||||||
<table class="data-table" id="stockTable">
|
<table class="data-table" id="stockTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -331,6 +332,7 @@
|
|||||||
<tbody id="productTableBody">
|
<tbody id="productTableBody">
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<div id="productPagination"></div>
|
<div id="productPagination"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -811,6 +813,7 @@
|
|||||||
<script src="/pos/static/js/app-init.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/pos-utils.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.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/inventory.js" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
|
|||||||
@@ -9,3 +9,6 @@ PyJWT>=2.8
|
|||||||
bcrypt>=4.0
|
bcrypt>=4.0
|
||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
orjson
|
orjson
|
||||||
|
quart
|
||||||
|
asyncpg
|
||||||
|
aiohttp
|
||||||
|
|||||||
137
scripts/benchmark_async_catalog.py
Normal file
137
scripts/benchmark_async_catalog.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Benchmark: compare Flask sync vs Quart async catalog search.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Flask POS server running on http://localhost:5001
|
||||||
|
- Quart async server running on http://localhost:5002
|
||||||
|
(start with: cd pos && hypercorn async_catalog:app --bind 0.0.0.0:5002)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 benchmark_async_catalog.py --workers 20 --requests 200
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
|
||||||
|
def sync_request(url):
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
_ = resp.read()
|
||||||
|
return (time.perf_counter() - start) * 1000, resp.status, None
|
||||||
|
except Exception as e:
|
||||||
|
return (time.perf_counter() - start) * 1000, 0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_request(session, url):
|
||||||
|
import aiohttp
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
_ = await resp.read()
|
||||||
|
return (time.perf_counter() - start) * 1000, resp.status, None
|
||||||
|
except Exception as e:
|
||||||
|
return (time.perf_counter() - start) * 1000, 0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def benchmark_sync(url, workers, requests_total):
|
||||||
|
latencies = []
|
||||||
|
errors = []
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futures = [ex.submit(sync_request, url) for _ in range(requests_total)]
|
||||||
|
for f in as_completed(futures):
|
||||||
|
latency, status, err = f.result()
|
||||||
|
if err:
|
||||||
|
errors.append((status, err))
|
||||||
|
else:
|
||||||
|
latencies.append(latency)
|
||||||
|
return latencies, errors
|
||||||
|
|
||||||
|
|
||||||
|
async def benchmark_async(url, workers, requests_total):
|
||||||
|
import aiohttp
|
||||||
|
latencies = []
|
||||||
|
errors = []
|
||||||
|
connector = aiohttp.TCPConnector(limit=workers * 2)
|
||||||
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
|
sem = asyncio.Semaphore(workers)
|
||||||
|
|
||||||
|
async def task():
|
||||||
|
async with sem:
|
||||||
|
return await async_request(session, url)
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[task() for _ in range(requests_total)])
|
||||||
|
for latency, status, err in results:
|
||||||
|
if err:
|
||||||
|
errors.append((status, err))
|
||||||
|
else:
|
||||||
|
latencies.append(latency)
|
||||||
|
return latencies, errors
|
||||||
|
|
||||||
|
|
||||||
|
def report(label, latencies, errors, duration):
|
||||||
|
if not latencies:
|
||||||
|
print(f"{label}: NO successful requests")
|
||||||
|
return
|
||||||
|
lat = sorted(latencies)
|
||||||
|
p50 = lat[int(len(lat) * 0.5)]
|
||||||
|
p95 = lat[int(len(lat) * 0.95)]
|
||||||
|
p99 = lat[int(len(lat) * 0.99)]
|
||||||
|
mean = statistics.mean(lat)
|
||||||
|
rps = len(lat) / duration if duration > 0 else 0
|
||||||
|
print(f"{label:20s} | mean={mean:7.1f}ms | p50={p50:7.1f}ms | p95={p95:7.1f}ms | p99={p99:7.1f}ms | OK={len(lat)} | Err={len(errors)} | RPS={rps:.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--flask-url', default='http://localhost:5001/pos/api/catalog/search?q=filtro%20aire&limit=20')
|
||||||
|
parser.add_argument('--quart-url', default='http://localhost:5002/pos/api/catalog/async-search?q=filtro%20aire&limit=20')
|
||||||
|
parser.add_argument('--workers', '-w', type=int, default=20)
|
||||||
|
parser.add_argument('--requests', '-n', type=int, default=200)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("=" * 100)
|
||||||
|
print(f"Benchmark: {args.requests} requests, {args.workers} concurrent workers")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
# Sync (Flask)
|
||||||
|
print("\n[1/2] Warming up Flask...")
|
||||||
|
sync_request(args.flask_url)
|
||||||
|
print("[1/2] Benchmarking Flask (sync)...")
|
||||||
|
start = time.time()
|
||||||
|
lat_sync, err_sync = benchmark_sync(args.flask_url, args.workers, args.requests)
|
||||||
|
dur_sync = time.time() - start
|
||||||
|
report("Flask sync", lat_sync, err_sync, dur_sync)
|
||||||
|
|
||||||
|
# Async (Quart)
|
||||||
|
print("\n[2/2] Warming up Quart...")
|
||||||
|
asyncio.run(benchmark_async(args.quart_url, 5, 1))
|
||||||
|
print("[2/2] Benchmarking Quart (async)...")
|
||||||
|
start = time.time()
|
||||||
|
lat_async, err_async = asyncio.run(benchmark_async(args.quart_url, args.workers, args.requests))
|
||||||
|
dur_async = time.time() - start
|
||||||
|
report("Quart async", lat_async, err_async, dur_async)
|
||||||
|
|
||||||
|
print("\n" + "=" * 100)
|
||||||
|
if lat_sync and lat_async:
|
||||||
|
improvement = (statistics.mean(lat_sync) - statistics.mean(lat_async)) / statistics.mean(lat_sync) * 100
|
||||||
|
print(f"Mean latency improvement: {improvement:+.1f}%")
|
||||||
|
print("=" * 100)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: aiohttp is required for async benchmark.")
|
||||||
|
print("Install: pip install aiohttp --break-system-packages")
|
||||||
|
sys.exit(1)
|
||||||
|
main()
|
||||||
231
scripts/partition_vehicle_parts.py
Normal file
231
scripts/partition_vehicle_parts.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Partition vehicle_parts by HASH(part_id) into 16 partitions.
|
||||||
|
|
||||||
|
This is a HIGH-RISK operation on a 254 GB table. Run ONLY during maintenance window.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Create partitioned table vehicle_parts_new with 16 hash partitions.
|
||||||
|
2. Migrate data in batches of 500K rows (checkpoint-friendly).
|
||||||
|
3. Atomically swap: rename old -> _old, new -> vehicle_parts.
|
||||||
|
4. Validate counts and indexes.
|
||||||
|
5. Drop old table after validation (or keep for rollback).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
export MASTER_DB_URL="postgresql://postgres@/nexus_autoparts"
|
||||||
|
python3 partition_vehicle_parts.py --dry-run
|
||||||
|
python3 partition_vehicle_parts.py
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- PostgreSQL 11+ (partitioning support)
|
||||||
|
- ~300 GB free disk space during migration
|
||||||
|
- Maintenance window (table is locked briefly during swap)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
DSN = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
|
||||||
|
BATCH_SIZE = 500_000
|
||||||
|
PARTITIONS = 16
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
return psycopg2.connect(DSN)
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(cur, name):
|
||||||
|
cur.execute("SELECT 1 FROM pg_tables WHERE tablename = %s", (name,))
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_row_count(cur, name):
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM {name}")
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_id(cur, name):
|
||||||
|
cur.execute(f"SELECT MAX(id_vehicle_part) FROM {name}")
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] or 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_partitioned_table(cur):
|
||||||
|
log("Creating vehicle_parts_new (partitioned)...")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE vehicle_parts_new (
|
||||||
|
id_vehicle_part BIGSERIAL,
|
||||||
|
part_id INTEGER NOT NULL,
|
||||||
|
model_year_engine_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
) PARTITION BY HASH (part_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
for i in range(PARTITIONS):
|
||||||
|
cur.execute(f"""
|
||||||
|
CREATE TABLE vehicle_parts_p{i}
|
||||||
|
PARTITION OF vehicle_parts_new
|
||||||
|
FOR VALUES WITH (MODULUS {PARTITIONS}, REMAINDER {i});
|
||||||
|
""")
|
||||||
|
log(f"Created {PARTITIONS} partitions.")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_data(cur, dry_run=False):
|
||||||
|
max_id = get_max_id(cur, 'vehicle_parts')
|
||||||
|
log(f"Max id_vehicle_part in source: {max_id}")
|
||||||
|
if max_id == 0:
|
||||||
|
log("Source table appears empty. Nothing to migrate.")
|
||||||
|
return
|
||||||
|
|
||||||
|
start = 1
|
||||||
|
total_inserted = 0
|
||||||
|
batch_num = 0
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
while start <= max_id:
|
||||||
|
end = start + BATCH_SIZE - 1
|
||||||
|
if dry_run:
|
||||||
|
log(f"[DRY-RUN] Would migrate id_vehicle_part {start}..{end}")
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO vehicle_parts_new (id_vehicle_part, part_id, model_year_engine_id, created_at)
|
||||||
|
SELECT id_vehicle_part, part_id, model_year_engine_id, created_at
|
||||||
|
FROM vehicle_parts
|
||||||
|
WHERE id_vehicle_part BETWEEN %s AND %s
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
""", (start, end))
|
||||||
|
inserted = cur.rowcount
|
||||||
|
total_inserted += inserted
|
||||||
|
batch_num += 1
|
||||||
|
if batch_num % 10 == 0:
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
rate = total_inserted / elapsed if elapsed > 0 else 0
|
||||||
|
log(f" Batch {batch_num}: {start}..{end} | inserted={total_inserted} | {rate:.0f} rows/s")
|
||||||
|
start = end + 1
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
log(f"Migration complete. Total inserted: {total_inserted}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_indexes(cur):
|
||||||
|
log("Creating indexes on vehicle_parts_new...")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX idx_vp_new_part ON vehicle_parts_new(part_id);
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
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);
|
||||||
|
""")
|
||||||
|
log("Indexes created.")
|
||||||
|
|
||||||
|
|
||||||
|
def swap_tables(cur):
|
||||||
|
log("Swapping tables (exclusive lock)...")
|
||||||
|
# Brief exclusive lock on the old table
|
||||||
|
cur.execute("LOCK TABLE vehicle_parts IN ACCESS EXCLUSIVE MODE;")
|
||||||
|
cur.execute("ALTER TABLE vehicle_parts RENAME TO vehicle_parts_old;")
|
||||||
|
cur.execute("ALTER TABLE vehicle_parts_new RENAME TO vehicle_parts;")
|
||||||
|
log("Swap complete.")
|
||||||
|
|
||||||
|
|
||||||
|
def validate(cur):
|
||||||
|
old_count = get_row_count(cur, 'vehicle_parts_old')
|
||||||
|
new_count = get_row_count(cur, 'vehicle_parts')
|
||||||
|
log(f"Validation: old={old_count} | new={new_count}")
|
||||||
|
if old_count != new_count:
|
||||||
|
log(f"WARNING: Row count mismatch! diff={old_count - new_count}")
|
||||||
|
return False
|
||||||
|
log("Validation PASSED.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--dry-run', action='store_true',
|
||||||
|
help='Show what would be done without executing')
|
||||||
|
parser.add_argument('--skip-swap', action='store_true',
|
||||||
|
help='Create new table and migrate, but do not swap')
|
||||||
|
parser.add_argument('--skip-drop', action='store_true',
|
||||||
|
help='Keep old table after swap (for rollback)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
log("=== DRY RUN ===")
|
||||||
|
|
||||||
|
log("Connecting to database...")
|
||||||
|
conn = get_conn()
|
||||||
|
conn.autocommit = False
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check prerequisites
|
||||||
|
if not table_exists(cur, 'vehicle_parts'):
|
||||||
|
log("ERROR: vehicle_parts does not exist.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if table_exists(cur, 'vehicle_parts_new'):
|
||||||
|
log("WARNING: vehicle_parts_new already exists. Dropping...")
|
||||||
|
if not args.dry_run:
|
||||||
|
cur.execute("DROP TABLE IF EXISTS vehicle_parts_new CASCADE;")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Step 1: Create partitioned table
|
||||||
|
if not args.dry_run:
|
||||||
|
create_partitioned_table(cur)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Step 2: Migrate data
|
||||||
|
migrate_data(cur, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Step 3: Create indexes
|
||||||
|
if not args.dry_run:
|
||||||
|
create_indexes(cur)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Step 4: Swap
|
||||||
|
if not args.dry_run and not args.skip_swap:
|
||||||
|
swap_tables(cur)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Step 5: Validate
|
||||||
|
if validate(cur):
|
||||||
|
if not args.skip_drop:
|
||||||
|
log("Dropping old table...")
|
||||||
|
cur.execute("DROP TABLE vehicle_parts_old CASCADE;")
|
||||||
|
conn.commit()
|
||||||
|
log("Old table dropped.")
|
||||||
|
else:
|
||||||
|
log("Old table kept as vehicle_parts_old.")
|
||||||
|
else:
|
||||||
|
log("VALIDATION FAILED. Rolling back swap...")
|
||||||
|
cur.execute("ALTER TABLE vehicle_parts RENAME TO vehicle_parts_new;")
|
||||||
|
cur.execute("ALTER TABLE vehicle_parts_old RENAME TO vehicle_parts;")
|
||||||
|
conn.commit()
|
||||||
|
log("Rollback complete.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log("Done.")
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
log(f"ERROR: {exc}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
8
scripts/start_celery.sh
Executable file
8
scripts/start_celery.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start Celery worker for Nexus POS background tasks
|
||||||
|
|
||||||
|
cd /home/Autopartes/pos
|
||||||
|
export MASTER_DB_URL="${MASTER_DB_URL:-postgresql://postgres@/nexus_autoparts}"
|
||||||
|
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
|
||||||
|
|
||||||
|
exec celery -A celery_app worker --loglevel=info --concurrency=4 -n nexus-worker@%h
|
||||||
Reference in New Issue
Block a user