FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica

FASE 4:
- Redis cache de stock con fallback graceful
- Multi-moneda (MXN/USD) con contabilidad en MXN
- Proveedores y ordenes de compra completo
- Meilisearch 1.5M+ partes indexadas
- Metabase KPIs con dashboard auto-generado

FASE 5:
- CRM mejorado: activities, tags, loyalty program, analytics
- Imagenes de partes: upload, resize, thumbnails WebP
- Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered
- Garantias/RMA, alertas de reorden, multi-sucursal
- Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi)

FASE 6:
- Notificaciones automaticas: push/WhatsApp/email/in-app
- Reportes de ahorro vs retail_price
- Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber
- API Publica: API keys, rate limiting, catalog search

Migraciones: v1.9-v3.0
Tests: 93/93 pasando
Backup: nexus_backup_20260427_045859.tar.gz
This commit is contained in:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View File

@@ -1068,3 +1068,179 @@ def api_generate_barcode():
barcode = generate_barcode(conn, db_name)
conn.close()
return jsonify({'barcode': barcode})
# ─── Multi-branch sync ──────────────────────────────────────────────────────
@inventory_bp.route('/stock-by-branch', methods=['GET'])
@require_auth('inventory.view')
def api_stock_by_branch():
"""Get stock for a specific inventory item across all branches."""
inventory_id = request.args.get('inventory_id', type=int)
if not inventory_id:
return jsonify({'error': 'inventory_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT b.id, b.name, b.address,
COALESCE(SUM(io.quantity), 0) as stock
FROM branches b
LEFT JOIN inventory_operations io
ON io.branch_id = b.id AND io.inventory_id = %s
WHERE b.is_active = true
GROUP BY b.id, b.name, b.address
ORDER BY b.name
""", (inventory_id,))
data = []
for r in cur.fetchall():
data.append({
'branch_id': r[0], 'branch_name': r[1], 'address': r[2],
'stock': r[3],
})
cur.close(); conn.close()
return jsonify({'data': data})
@inventory_bp.route('/transfers', methods=['GET'])
@require_auth('inventory.view')
def api_transfers():
"""List stock transfer operations."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_id = request.args.get('branch_id', g.branch_id)
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
cur.execute("""
SELECT io.id, io.inventory_id, i.part_number, i.name,
io.branch_id, io.quantity, io.notes, io.created_at,
e.name as employee_name
FROM inventory_operations io
JOIN inventory i ON io.inventory_id = i.id
LEFT JOIN employees e ON io.employee_id = e.id
WHERE io.operation_type = 'TRANSFER'
AND (%s IS NULL OR io.branch_id = %s)
ORDER BY io.created_at DESC
LIMIT %s OFFSET %s
""", (branch_id, branch_id, limit, offset))
data = []
for r in cur.fetchall():
data.append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
'branch_id': r[4], 'quantity': r[5], 'notes': r[6],
'created_at': str(r[7]), 'employee': r[8],
})
cur.close(); conn.close()
return jsonify({'data': data})
@inventory_bp.route('/sync-prices', methods=['POST'])
@require_auth('inventory.edit')
def api_sync_prices():
"""Sync prices from one inventory item to others with the same part_number."""
data = request.get_json() or {}
source_id = data.get('source_inventory_id')
if not source_id:
return jsonify({'error': 'source_inventory_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT part_number, price_1, price_2, price_3, cost FROM inventory WHERE id = %s", (source_id,))
source = cur.fetchone()
if not source:
cur.close(); conn.close()
return jsonify({'error': 'Source item not found'}), 404
part_number, p1, p2, p3, cost = source
cur.execute("""
UPDATE inventory
SET price_1 = %s, price_2 = %s, price_3 = %s, cost = %s, updated_at = NOW()
WHERE part_number = %s AND id != %s
""", (p1, p2, p3, cost, part_number, source_id))
updated = cur.rowcount
conn.commit()
cur.close(); conn.close()
return jsonify({'message': f'Synced prices to {updated} items', 'updated': updated})
# ─── Reorder alerts ─────────────────────────────────────────────────────────
@inventory_bp.route('/generate-alerts', methods=['POST'])
@require_auth('inventory.view')
def api_generate_alerts():
"""Scan inventory and generate reorder alerts."""
conn = get_tenant_conn(g.tenant_id)
try:
result = generate_alerts(conn, branch_id=g.branch_id, auto_notify=True)
conn.commit()
conn.close()
return jsonify(result)
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@inventory_bp.route('/reorder-alerts', methods=['GET'])
@require_auth('inventory.view')
def api_reorder_alerts():
"""List reorder alerts."""
conn = get_tenant_conn(g.tenant_id)
status = request.args.get('status')
branch_id = request.args.get('branch_id', g.branch_id)
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
data = list_alerts(conn, status=status, branch_id=branch_id, limit=limit, offset=offset)
conn.close()
return jsonify({'data': data, 'count': len(data)})
@inventory_bp.route('/reorder-alerts/<int:alert_id>/acknowledge', methods=['PUT'])
@require_auth('inventory.edit')
def api_ack_alert(alert_id):
"""Acknowledge a reorder alert."""
conn = get_tenant_conn(g.tenant_id)
data = request.get_json() or {}
try:
ok = acknowledge_alert(conn, alert_id, employee_id=g.employee_id, notes=data.get('notes'))
conn.commit()
conn.close()
if not ok:
return jsonify({'error': 'Alert not found or already acknowledged'}), 404
return jsonify({'message': 'Alert acknowledged'})
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@inventory_bp.route('/reorder-alerts/<int:alert_id>/resolve', methods=['PUT'])
@require_auth('inventory.edit')
def api_resolve_alert(alert_id):
"""Resolve a reorder alert."""
conn = get_tenant_conn(g.tenant_id)
data = request.get_json() or {}
try:
ok = resolve_alert(conn, alert_id, po_id=data.get('po_id'), notes=data.get('notes'))
conn.commit()
conn.close()
if not ok:
return jsonify({'error': 'Alert not found'}), 404
return jsonify({'message': 'Alert resolved'})
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@inventory_bp.route('/reorder-suggest-po', methods=['GET'])
@require_auth('inventory.edit')
def api_reorder_suggest_po():
"""Suggest a purchase order based on open low/zero stock alerts."""
conn = get_tenant_conn(g.tenant_id)
supplier_id = request.args.get('supplier_id', type=int)
branch_id = request.args.get('branch_id', g.branch_id)
suggestion = suggest_po_from_alerts(conn, supplier_id=supplier_id, branch_id=branch_id)
conn.close()
return jsonify(suggestion)