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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user