Files
Autoparts-DB/pos/blueprints/fleet_bp.py
consultoria-as db5bbf6718 feat(pos): fleet management module — vehicles, maintenance schedules, alerts
Add full fleet management (Feature 13): database migration for fleet_vehicles,
fleet_maintenance_schedules, and fleet_maintenance_logs tables; REST API blueprint
with CRUD, scheduling, logging, alerts, and stats endpoints; frontend with tabbed
UI (vehicles grid, maintenance schedules, history, overdue alerts); sidebar nav entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:43:26 +00:00

541 lines
19 KiB
Python

# /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/<int:vehicle_id>', 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/<int:vehicle_id>', 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/<int:vehicle_id>', 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/<int:vehicle_id>/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/<int:vehicle_id>/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/<int:vehicle_id>/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,
})