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>
541 lines
19 KiB
Python
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,
|
|
})
|