# /home/Autopartes/pos/blueprints/fleet_bp.py """Fleet management blueprint: vehicles, maintenance schedules, logs, alerts.""" from datetime import datetime, timedelta from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn from services.audit import log_action fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet') # ─── Vehicles CRUD ───────────────────────────── @fleet_bp.route('/vehicles', methods=['GET']) @require_auth() def list_vehicles(): """List fleet vehicles with pagination and search. Query params: q: search string (matches plate, make, model, owner_name) page: page number (default 1) per_page: items per page (default 50, max 200) active_only: filter active only (default true) """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 200) search = request.args.get('q', '').strip() active_only = request.args.get('active_only', 'true').lower() != 'false' where_clauses = [] params = [] if active_only: where_clauses.append("v.is_active = true") if g.branch_id: where_clauses.append("v.branch_id = %s") params.append(g.branch_id) if search: where_clauses.append( "(v.plate ILIKE %s OR v.make ILIKE %s OR v.model ILIKE %s OR v.owner_name ILIKE %s OR v.vin ILIKE %s)" ) params.extend([f'%{search}%'] * 5) where = " AND ".join(where_clauses) if where_clauses else "true" # Count cur.execute(f"SELECT count(*) FROM fleet_vehicles v WHERE {where}", params) total = cur.fetchone()[0] # Fetch cur.execute(f""" SELECT v.id, v.branch_id, v.plate, v.vin, v.make, v.model, v.year, v.current_mileage, v.fuel_type, v.color, v.owner_name, v.notes, v.is_active, v.created_at FROM fleet_vehicles v WHERE {where} ORDER BY v.created_at DESC LIMIT %s OFFSET %s """, params + [per_page, (page - 1) * per_page]) vehicles = [] for r in cur.fetchall(): vehicles.append({ 'id': r[0], 'branch_id': r[1], 'plate': r[2], 'vin': r[3], 'make': r[4], 'model': r[5], 'year': r[6], 'current_mileage': r[7], 'fuel_type': r[8], 'color': r[9], 'owner_name': r[10], 'notes': r[11], 'is_active': r[12], 'created_at': str(r[13]) if r[13] else None, }) cur.close() conn.close() total_pages = (total + per_page - 1) // per_page return jsonify({ 'data': vehicles, 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} }) @fleet_bp.route('/vehicles/', methods=['GET']) @require_auth() def get_vehicle(vehicle_id): """Vehicle detail with maintenance schedules and recent logs.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT id, branch_id, plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes, is_active, created_at FROM fleet_vehicles WHERE id = %s """, (vehicle_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Vehicle not found'}), 404 vehicle = { 'id': row[0], 'branch_id': row[1], 'plate': row[2], 'vin': row[3], 'make': row[4], 'model': row[5], 'year': row[6], 'current_mileage': row[7], 'fuel_type': row[8], 'color': row[9], 'owner_name': row[10], 'notes': row[11], 'is_active': row[12], 'created_at': str(row[13]) if row[13] else None, } # Maintenance schedules cur.execute(""" SELECT id, maintenance_type, interval_km, interval_months, last_done_at, last_done_km, next_due_at, next_due_km, notes, is_active FROM fleet_maintenance_schedules WHERE vehicle_id = %s AND is_active = true ORDER BY next_due_at NULLS LAST, next_due_km NULLS LAST """, (vehicle_id,)) vehicle['schedules'] = [] for r in cur.fetchall(): vehicle['schedules'].append({ 'id': r[0], 'maintenance_type': r[1], 'interval_km': r[2], 'interval_months': r[3], 'last_done_at': str(r[4]) if r[4] else None, 'last_done_km': r[5], 'next_due_at': str(r[6]) if r[6] else None, 'next_due_km': r[7], 'notes': r[8], 'is_active': r[9], }) # Recent logs (last 20) cur.execute(""" SELECT l.id, l.schedule_id, l.maintenance_type, l.mileage_at, l.cost, l.parts_used, l.employee_id, l.notes, l.created_at, e.name as employee_name FROM fleet_maintenance_logs l LEFT JOIN employees e ON l.employee_id = e.id WHERE l.vehicle_id = %s ORDER BY l.created_at DESC LIMIT 20 """, (vehicle_id,)) vehicle['recent_logs'] = [] for r in cur.fetchall(): vehicle['recent_logs'].append({ 'id': r[0], 'schedule_id': r[1], 'maintenance_type': r[2], 'mileage_at': r[3], 'cost': float(r[4]) if r[4] else 0, 'parts_used': r[5], 'employee_id': r[6], 'notes': r[7], 'created_at': str(r[8]) if r[8] else None, 'employee_name': r[9], }) cur.close() conn.close() return jsonify(vehicle) @fleet_bp.route('/vehicles', methods=['POST']) @require_auth() def create_vehicle(): """Create a fleet vehicle. Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes} """ data = request.get_json() or {} if not data.get('plate') and not data.get('vin'): return jsonify({'error': 'plate or vin is required'}), 400 branch_id = data.get('branch_id', g.branch_id) conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: cur.execute(""" INSERT INTO fleet_vehicles (branch_id, plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( branch_id, data.get('plate'), data.get('vin'), data.get('make'), data.get('model'), data.get('year'), data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'), data.get('color'), data.get('owner_name'), data.get('notes'), )) vehicle_id = cur.fetchone()[0] log_action(conn, 'FLEET_VEHICLE_CREATE', 'fleet_vehicle', vehicle_id, new_value={'plate': data.get('plate'), 'make': data.get('make'), 'model': data.get('model')}) conn.commit() cur.close(); conn.close() return jsonify({'id': vehicle_id, 'message': 'Vehicle created'}), 201 except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 @fleet_bp.route('/vehicles/', methods=['PUT']) @require_auth() def update_vehicle(vehicle_id): """Update vehicle fields including mileage. Body: any subset of {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes, is_active} """ data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT id FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) if not cur.fetchone(): cur.close(); conn.close() return jsonify({'error': 'Vehicle not found'}), 404 allowed = ['plate', 'vin', 'make', 'model', 'year', 'current_mileage', 'fuel_type', 'color', 'owner_name', 'notes', 'is_active', 'branch_id'] sets = [] vals = [] for field in allowed: if field in data: sets.append(f"{field} = %s") vals.append(data[field]) if not sets: cur.close(); conn.close() return jsonify({'error': 'No fields to update'}), 400 vals.append(vehicle_id) try: cur.execute(f"UPDATE fleet_vehicles SET {', '.join(sets)} WHERE id = %s", vals) log_action(conn, 'FLEET_VEHICLE_UPDATE', 'fleet_vehicle', vehicle_id, new_value=data) conn.commit() cur.close(); conn.close() return jsonify({'message': 'Vehicle updated'}) except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 @fleet_bp.route('/vehicles/', methods=['DELETE']) @require_auth() def deactivate_vehicle(vehicle_id): """Soft-delete: set is_active = false.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT id FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) if not cur.fetchone(): cur.close(); conn.close() return jsonify({'error': 'Vehicle not found'}), 404 cur.execute("UPDATE fleet_vehicles SET is_active = false WHERE id = %s", (vehicle_id,)) log_action(conn, 'FLEET_VEHICLE_DEACTIVATE', 'fleet_vehicle', vehicle_id) conn.commit() cur.close(); conn.close() return jsonify({'message': 'Vehicle deactivated'}) # ─── Maintenance Schedules ───────────────────────────── @fleet_bp.route('/vehicles//schedules', methods=['GET']) @require_auth() def list_schedules(vehicle_id): """Maintenance schedules for a vehicle.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT id, maintenance_type, interval_km, interval_months, last_done_at, last_done_km, next_due_at, next_due_km, notes, is_active FROM fleet_maintenance_schedules WHERE vehicle_id = %s AND is_active = true ORDER BY next_due_at NULLS LAST, next_due_km NULLS LAST """, (vehicle_id,)) schedules = [] for r in cur.fetchall(): schedules.append({ 'id': r[0], 'maintenance_type': r[1], 'interval_km': r[2], 'interval_months': r[3], 'last_done_at': str(r[4]) if r[4] else None, 'last_done_km': r[5], 'next_due_at': str(r[6]) if r[6] else None, 'next_due_km': r[7], 'notes': r[8], 'is_active': r[9], }) cur.close() conn.close() return jsonify({'data': schedules}) @fleet_bp.route('/vehicles//schedules', methods=['POST']) @require_auth() def create_schedule(vehicle_id): """Create maintenance schedule for a vehicle. Body: {maintenance_type, interval_km, interval_months, next_due_at, next_due_km, notes} """ data = request.get_json() or {} if not data.get('maintenance_type'): return jsonify({'error': 'maintenance_type is required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Verify vehicle exists cur.execute("SELECT id FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) if not cur.fetchone(): cur.close(); conn.close() return jsonify({'error': 'Vehicle not found'}), 404 try: cur.execute(""" INSERT INTO fleet_maintenance_schedules (vehicle_id, maintenance_type, interval_km, interval_months, next_due_at, next_due_km, notes) VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( vehicle_id, data['maintenance_type'], data.get('interval_km'), data.get('interval_months'), data.get('next_due_at'), data.get('next_due_km'), data.get('notes'), )) schedule_id = cur.fetchone()[0] conn.commit() cur.close(); conn.close() return jsonify({'id': schedule_id, 'message': 'Schedule created'}), 201 except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 # ─── Maintenance Logs ───────────────────────────── @fleet_bp.route('/vehicles//log', methods=['POST']) @require_auth() def record_maintenance(vehicle_id): """Record maintenance done. Updates schedule next_due if schedule_id provided. Body: {schedule_id, maintenance_type, mileage_at, cost, parts_used, notes} """ data = request.get_json() or {} if not data.get('maintenance_type'): return jsonify({'error': 'maintenance_type is required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Verify vehicle exists cur.execute("SELECT id, current_mileage FROM fleet_vehicles WHERE id = %s", (vehicle_id,)) veh = cur.fetchone() if not veh: cur.close(); conn.close() return jsonify({'error': 'Vehicle not found'}), 404 mileage_at = data.get('mileage_at', veh[1] or 0) schedule_id = data.get('schedule_id') try: # Insert log cur.execute(""" INSERT INTO fleet_maintenance_logs (vehicle_id, schedule_id, maintenance_type, mileage_at, cost, parts_used, employee_id, notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( vehicle_id, schedule_id, data['maintenance_type'], mileage_at, data.get('cost', 0), data.get('parts_used'), getattr(g, 'employee_id', None), data.get('notes'), )) log_id = cur.fetchone()[0] # Update vehicle mileage if new mileage is higher if mileage_at > (veh[1] or 0): cur.execute("UPDATE fleet_vehicles SET current_mileage = %s WHERE id = %s", (mileage_at, vehicle_id)) # Update schedule next_due if schedule_id provided if schedule_id: cur.execute(""" SELECT interval_km, interval_months FROM fleet_maintenance_schedules WHERE id = %s """, (schedule_id,)) sched = cur.fetchone() if sched: interval_km, interval_months = sched now = datetime.utcnow() next_due_km = (mileage_at + interval_km) if interval_km else None next_due_at = None if interval_months: next_due_at = now + timedelta(days=interval_months * 30) cur.execute(""" UPDATE fleet_maintenance_schedules SET last_done_at = %s, last_done_km = %s, next_due_at = %s, next_due_km = %s WHERE id = %s """, (now, mileage_at, next_due_at, next_due_km, schedule_id)) log_action(conn, 'FLEET_MAINTENANCE_LOG', 'fleet_vehicle', vehicle_id, new_value={'maintenance_type': data['maintenance_type'], 'mileage_at': mileage_at, 'cost': data.get('cost', 0)}) conn.commit() cur.close(); conn.close() return jsonify({'id': log_id, 'message': 'Maintenance recorded'}), 201 except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 # ─── Alerts ───────────────────────────── @fleet_bp.route('/alerts', methods=['GET']) @require_auth() def fleet_alerts(): """Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage).""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_filter = "" params = [] if g.branch_id: branch_filter = "AND v.branch_id = %s" params.append(g.branch_id) cur.execute(f""" SELECT v.id, v.plate, v.make, v.model, v.year, v.current_mileage, s.id as schedule_id, s.maintenance_type, s.next_due_at, s.next_due_km FROM fleet_maintenance_schedules s JOIN fleet_vehicles v ON s.vehicle_id = v.id WHERE s.is_active = true AND v.is_active = true {branch_filter} AND ( (s.next_due_at IS NOT NULL AND s.next_due_at < NOW()) OR (s.next_due_km IS NOT NULL AND s.next_due_km <= v.current_mileage) ) ORDER BY s.next_due_at NULLS LAST """, params) alerts = [] for r in cur.fetchall(): alerts.append({ 'vehicle_id': r[0], 'plate': r[1], 'make': r[2], 'model': r[3], 'year': r[4], 'current_mileage': r[5], 'schedule_id': r[6], 'maintenance_type': r[7], 'next_due_at': str(r[8]) if r[8] else None, 'next_due_km': r[9], }) cur.close() conn.close() return jsonify({'data': alerts, 'total': len(alerts)}) # ─── Stats ───────────────────────────── @fleet_bp.route('/stats', methods=['GET']) @require_auth() def fleet_stats(): """Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_filter = "" params = [] if g.branch_id: branch_filter = "AND v.branch_id = %s" params.append(g.branch_id) # Total active vehicles cur.execute(f""" SELECT count(*) FROM fleet_vehicles v WHERE v.is_active = true {branch_filter} """, params) total_vehicles = cur.fetchone()[0] # Overdue count cur.execute(f""" SELECT count(DISTINCT v.id) FROM fleet_maintenance_schedules s JOIN fleet_vehicles v ON s.vehicle_id = v.id WHERE s.is_active = true AND v.is_active = true {branch_filter} AND ( (s.next_due_at IS NOT NULL AND s.next_due_at < NOW()) OR (s.next_due_km IS NOT NULL AND s.next_due_km <= v.current_mileage) ) """, params) overdue_count = cur.fetchone()[0] # Upcoming this month cur.execute(f""" SELECT count(*) FROM fleet_maintenance_schedules s JOIN fleet_vehicles v ON s.vehicle_id = v.id WHERE s.is_active = true AND v.is_active = true {branch_filter} AND s.next_due_at IS NOT NULL AND s.next_due_at >= NOW() AND s.next_due_at < date_trunc('month', NOW()) + interval '1 month' """, params) upcoming_this_month = cur.fetchone()[0] # Total cost this month cur.execute(f""" SELECT COALESCE(SUM(l.cost), 0) FROM fleet_maintenance_logs l JOIN fleet_vehicles v ON l.vehicle_id = v.id WHERE v.is_active = true {branch_filter} AND l.created_at >= date_trunc('month', NOW()) """, params) total_cost_month = float(cur.fetchone()[0]) cur.close() conn.close() return jsonify({ 'total_vehicles': total_vehicles, 'overdue_count': overdue_count, 'upcoming_this_month': upcoming_this_month, 'total_cost_month': total_cost_month, })