C1: Materialized view part_vehicle_preview (creación en progreso) - Migración v3.3_materialized_view.sql - catalog_service.py y dashboard/server.py ahora usan la MV - Script refresh_part_vehicle_preview.py + warm_vehicle_cache.py actualizado C2: Fix cache warming script (autónomo) - Auto-re-ejecuta con sudo -u postgres si peer auth falla - Args CLI: --dsn, --batch-size, --ttl, --dry-run C3: CSS dinámico residual extraído - sidebar.js → sidebar.css (nuevo) - pos-utils.js → common.css (nuevo) - Links agregados a 14 templates POS C4: Script de load testing básico - scripts/load_test.py: métricas p50/p95/p99, throughput, errores C5: Documentación actualizada - FASES_IMPLEMENTADAS.md: test count real, FASE 7 completa - performance_audit_2026.md: anexo post-FASE 7, métricas actualizadas A1: Serialización orjson - pos/json_provider.py: DefaultJSONProvider con orjson.dumps/loads - Aplicado a POS app y Dashboard server - Fix indentation error en pos_bp.py Tests: 73/73 pasando
253 lines
8.2 KiB
Python
253 lines
8.2 KiB
Python
from flask import Flask
|
|
from json_provider import OrjsonProvider
|
|
|
|
def create_app():
|
|
app = Flask(__name__)
|
|
app.json = OrjsonProvider(app)
|
|
|
|
# Tenant subdomain resolver (before every request)
|
|
from middleware_tenant import resolve_tenant
|
|
app.before_request(resolve_tenant)
|
|
|
|
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
|
@app.route('/pos/sw.js')
|
|
def pos_sw():
|
|
from flask import send_from_directory
|
|
return send_from_directory('static/pwa', 'sw.js',
|
|
mimetype='application/javascript')
|
|
|
|
# Register blueprints
|
|
from blueprints.auth_bp import auth_bp
|
|
app.register_blueprint(auth_bp)
|
|
|
|
from blueprints.config_bp import config_bp
|
|
app.register_blueprint(config_bp)
|
|
|
|
from blueprints.inventory_bp import inventory_bp
|
|
app.register_blueprint(inventory_bp)
|
|
|
|
from blueprints.catalog_bp import catalog_bp
|
|
app.register_blueprint(catalog_bp)
|
|
|
|
from blueprints.pos_bp import pos_bp
|
|
app.register_blueprint(pos_bp)
|
|
|
|
from blueprints.customers_bp import customers_bp
|
|
app.register_blueprint(customers_bp)
|
|
|
|
from blueprints.cashregister_bp import cashregister_bp
|
|
app.register_blueprint(cashregister_bp)
|
|
|
|
from blueprints.invoicing_bp import invoicing_bp
|
|
app.register_blueprint(invoicing_bp)
|
|
|
|
from blueprints.accounting_bp import accounting_bp
|
|
app.register_blueprint(accounting_bp)
|
|
|
|
from blueprints.chat_bp import chat_bp
|
|
app.register_blueprint(chat_bp)
|
|
|
|
from blueprints.fleet_bp import fleet_bp
|
|
app.register_blueprint(fleet_bp)
|
|
|
|
from blueprints.whatsapp_bp import whatsapp_bp
|
|
app.register_blueprint(whatsapp_bp)
|
|
|
|
from blueprints.marketplace_bp import marketplace_bp
|
|
app.register_blueprint(marketplace_bp)
|
|
|
|
from blueprints.peer_bp import peer_bp
|
|
app.register_blueprint(peer_bp)
|
|
|
|
from blueprints.supplier_bp import supplier_bp
|
|
app.register_blueprint(supplier_bp)
|
|
|
|
from blueprints.warranty_bp import warranty_bp
|
|
app.register_blueprint(warranty_bp)
|
|
|
|
from blueprints.crm_bp import crm_bp
|
|
app.register_blueprint(crm_bp)
|
|
|
|
from blueprints.service_order_bp import service_order_bp
|
|
app.register_blueprint(service_order_bp)
|
|
|
|
from blueprints.image_bp import image_bp
|
|
app.register_blueprint(image_bp)
|
|
|
|
from blueprints.notification_bp import notification_bp
|
|
app.register_blueprint(notification_bp)
|
|
|
|
from blueprints.savings_bp import savings_bp
|
|
app.register_blueprint(savings_bp)
|
|
|
|
from blueprints.logistics_bp import logistics_bp
|
|
app.register_blueprint(logistics_bp)
|
|
|
|
from blueprints.public_api_bp import public_api_bp
|
|
app.register_blueprint(public_api_bp)
|
|
|
|
# Health check
|
|
@app.route('/pos/health')
|
|
def health():
|
|
return {'status': 'ok'}
|
|
|
|
from flask import render_template, send_from_directory, jsonify, g
|
|
|
|
@app.route('/favicon.ico')
|
|
def favicon():
|
|
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
|
|
|
|
@app.route('/pos/login')
|
|
def pos_login():
|
|
return render_template('login.html',
|
|
tenant_id=getattr(g, 'tenant_id', None),
|
|
tenant_name=getattr(g, 'tenant_name', None),
|
|
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
|
|
|
|
@app.route('/pos/catalog')
|
|
def pos_catalog():
|
|
return render_template('catalog.html')
|
|
|
|
@app.route('/pos/inventory')
|
|
def pos_inventory():
|
|
return render_template('inventory.html')
|
|
|
|
@app.route('/pos/sale')
|
|
def pos_sale():
|
|
return render_template('pos.html')
|
|
|
|
@app.route('/pos/customers')
|
|
def pos_customers():
|
|
return render_template('customers.html')
|
|
|
|
@app.route('/pos/invoicing')
|
|
def pos_invoicing():
|
|
return render_template('invoicing.html')
|
|
|
|
@app.route('/pos/accounting')
|
|
def pos_accounting():
|
|
return render_template('accounting.html')
|
|
|
|
@app.route('/pos/dashboard')
|
|
def pos_dashboard():
|
|
return render_template('dashboard.html')
|
|
|
|
@app.route('/pos/config')
|
|
def pos_config():
|
|
return render_template('config.html')
|
|
|
|
@app.route('/pos/reports')
|
|
def pos_reports():
|
|
return render_template('reports.html')
|
|
|
|
@app.route('/pos/fleet')
|
|
def pos_fleet():
|
|
return render_template('fleet.html')
|
|
|
|
@app.route('/pos/quotations')
|
|
def pos_quotations():
|
|
return render_template('quotations.html')
|
|
|
|
@app.route('/pos/whatsapp')
|
|
def pos_whatsapp():
|
|
return render_template('whatsapp.html')
|
|
|
|
@app.route('/pos/marketplace')
|
|
def pos_marketplace():
|
|
return render_template('marketplace.html')
|
|
|
|
@app.route('/pos/static/<path:filename>')
|
|
def pos_static(filename):
|
|
return send_from_directory('static', filename)
|
|
|
|
# ─── Sync: full inventory for offline cache ───────────────────
|
|
from middleware import require_auth as _require_auth
|
|
|
|
@app.route('/pos/api/sync/inventory', methods=['GET'])
|
|
@_require_auth()
|
|
def sync_full_inventory():
|
|
"""Download full inventory for offline cache."""
|
|
from tenant_db import get_tenant_conn
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
branch_id = g.branch_id
|
|
|
|
cur.execute("""
|
|
SELECT i.id, i.part_number, i.barcode, i.name, i.brand,
|
|
i.unit, i.price_1, i.price_2, i.price_3, i.tax_rate,
|
|
COALESCE(s.stock, 0) AS stock
|
|
FROM inventory i
|
|
LEFT JOIN (
|
|
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
|
FROM inventory_operations GROUP BY inventory_id
|
|
) s ON s.inventory_id = i.id
|
|
WHERE i.is_active = true AND i.branch_id = %s
|
|
ORDER BY i.name
|
|
""", [branch_id])
|
|
|
|
items = []
|
|
for r in cur.fetchall():
|
|
items.append({
|
|
'item_id': r[0], 'sku': r[1], 'barcode': r[2],
|
|
'name': r[3], 'brand': r[4], 'unit': r[5],
|
|
'price_1': float(r[6]) if r[6] else 0,
|
|
'price_2': float(r[7]) if r[7] else 0,
|
|
'price_3': float(r[8]) if r[8] else 0,
|
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
|
'stock': r[10]
|
|
})
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'items': items, 'count': len(items)})
|
|
|
|
@app.route('/pos/api/sync/top-parts', methods=['GET'])
|
|
@_require_auth()
|
|
def sync_top_parts():
|
|
"""Get top 500 most-sold parts for offline catalog cache."""
|
|
from tenant_db import get_tenant_conn
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
branch_id = g.branch_id
|
|
|
|
cur.execute("""
|
|
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
|
|
i.category, COALESCE(s.stock, 0) AS stock,
|
|
COALESCE(sv.total_sold, 0) AS total_sold
|
|
FROM inventory i
|
|
LEFT JOIN (
|
|
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
|
FROM inventory_operations GROUP BY inventory_id
|
|
) s ON s.inventory_id = i.id
|
|
LEFT JOIN (
|
|
SELECT si.inventory_id, SUM(si.quantity) AS total_sold
|
|
FROM sale_items si
|
|
JOIN sales sa ON si.sale_id = sa.id
|
|
WHERE sa.status IN ('completed', 'partially_returned')
|
|
GROUP BY si.inventory_id
|
|
) sv ON sv.inventory_id = i.id
|
|
WHERE i.is_active = true AND i.branch_id = %s
|
|
ORDER BY COALESCE(sv.total_sold, 0) DESC
|
|
LIMIT 500
|
|
""", [branch_id])
|
|
|
|
parts = []
|
|
for r in cur.fetchall():
|
|
parts.append({
|
|
'part_number': r[0], 'name': r[1], 'brand': r[2],
|
|
'price': float(r[3]) if r[3] else 0,
|
|
'tax_rate': float(r[4]) if r[4] else 0.16,
|
|
'category': r[5] or '',
|
|
'stock': r[6], 'total_sold': r[7]
|
|
})
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'parts': parts, 'count': len(parts)})
|
|
|
|
return app
|
|
|
|
# Expose at module level for Gunicorn: gunicorn -w 2 -b 0.0.0.0:5001 app:app
|
|
app = create_app()
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=5001, debug=True)
|