Add CarPass gamification scoring foundation

This commit is contained in:
VPN SaaS Dev
2026-05-12 20:06:25 +09:00
parent 34035a27cb
commit 8ef59a6446
10 changed files with 1430 additions and 2 deletions

184
app/api/gamification.py Normal file
View File

@@ -0,0 +1,184 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import (
ensure_service_employee,
ensure_vehicle_owner_or_access,
get_current_telegram_user,
)
from app.db.session import get_session
from app.models.car import ServiceCenter, ServiceVisit
from app.models.expense import FuelEntry, ServiceEntry
from app.models.gamification import Achievement, EngagementEvent, UserAchievement
from app.models.user import User
from app.schemas.gamification import (
AchievementRead,
ServiceCenterScoreRead,
TimelineItem,
VehicleScoreRead,
)
from app.services.scoring import (
compute_service_center_score,
compute_vehicle_score,
evaluate_garage_achievements,
)
router = APIRouter(tags=["gamification"])
@router.get("/me/achievements", response_model=list[AchievementRead])
async def my_achievements(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[AchievementRead]:
await evaluate_garage_achievements(session, current_user.id)
result = await session.execute(
select(UserAchievement, Achievement)
.join(Achievement, Achievement.id == UserAchievement.achievement_id)
.where(UserAchievement.user_id == current_user.id)
.order_by(UserAchievement.unlocked_at.desc(), UserAchievement.id.desc())
)
await session.commit()
return [
AchievementRead(
code=achievement.code,
scope=achievement.scope,
title=achievement.title,
description=achievement.description,
icon=achievement.icon,
category=achievement.category,
unlocked_at=user_achievement.unlocked_at,
vehicle_id=user_achievement.vehicle_id,
service_center_id=user_achievement.service_center_id,
)
for user_achievement, achievement in result.all()
]
@router.get("/my/vehicles/{vehicle_id}/score", response_model=VehicleScoreRead)
async def vehicle_score(
vehicle_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
):
car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user)
score = await compute_vehicle_score(session, car)
await session.commit()
await session.refresh(score)
return score
@router.get("/my/vehicles/{vehicle_id}/timeline", response_model=list[TimelineItem])
async def vehicle_timeline(
vehicle_id: int,
limit: int = 80,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[TimelineItem]:
if limit < 1:
limit = 1
limit = min(limit, 200)
offset = max(offset, 0)
car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user)
fuel_entries = list((await session.execute(select(FuelEntry).where(FuelEntry.car_id == car.id))).scalars())
service_entries = list((await session.execute(select(ServiceEntry).where(ServiceEntry.car_id == car.id))).scalars())
visits = list((await session.execute(select(ServiceVisit).where(ServiceVisit.vehicle_id == car.id))).scalars())
achievements = list(
(
await session.execute(
select(UserAchievement, Achievement)
.join(Achievement, Achievement.id == UserAchievement.achievement_id)
.where(UserAchievement.user_id == current_user.id, UserAchievement.vehicle_id == car.id)
)
).all()
)
events = list(
(
await session.execute(
select(EngagementEvent).where(EngagementEvent.vehicle_id == car.id).order_by(EngagementEvent.created_at.desc())
)
).scalars()
)
items: list[TimelineItem] = []
for entry in fuel_entries:
items.append(
TimelineItem(
id=f"fuel:{entry.id}",
date=entry.entry_date.isoformat(),
type="fuel",
title=f"Fuel refill {float(entry.liters):.1f} L",
description=entry.station,
amount=entry.total_cost,
metadata={"odometer": entry.odometer, "full_tank": entry.is_full_tank},
)
)
for entry in service_entries:
items.append(
TimelineItem(
id=f"service:{entry.id}",
date=entry.entry_date.isoformat(),
type="service",
title=entry.title,
status="self_reported",
description=entry.vendor,
amount=entry.total_cost,
metadata={"odometer": entry.odometer, "service_type": entry.service_type.value},
)
)
for visit in visits:
items.append(
TimelineItem(
id=f"visit:{visit.id}",
date=visit.visit_date.isoformat(),
type="service_visit",
title="Service-center visit",
status=visit.status,
description=visit.notes,
amount=visit.total_cost,
metadata={"odometer": visit.odometer, "service_center_id": visit.service_center_id},
)
)
for user_achievement, achievement in achievements:
items.append(
TimelineItem(
id=f"achievement:{user_achievement.id}",
date=user_achievement.unlocked_at,
type="achievement",
title=achievement.title,
status="unlocked",
description=achievement.description,
metadata={"code": achievement.code, "category": achievement.category},
)
)
for event in events:
items.append(
TimelineItem(
id=f"event:{event.id}",
date=event.created_at,
type="event",
title=event.event_type.replace("_", " ").title(),
metadata=event.metadata_json,
)
)
items.sort(key=lambda item: str(item.date), reverse=True)
return items[offset : offset + limit]
@router.get("/service-centers/{service_center_id}/trust-score", response_model=ServiceCenterScoreRead)
async def service_center_trust_score(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
):
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
await ensure_service_employee(session, service_center_id, current_user)
score = await compute_service_center_score(session, center)
await session.commit()
await session.refresh(score)
return score

View File

@@ -8,6 +8,7 @@ from app.api import (
catalog,
change_requests,
entries,
gamification,
my,
ocr,
service_centers,
@@ -34,6 +35,7 @@ app.include_router(my.router, prefix="/api")
app.include_router(catalog.router, prefix="/api")
app.include_router(cars.router, prefix="/api")
app.include_router(entries.router, prefix="/api")
app.include_router(gamification.router, prefix="/api")
app.include_router(ocr.router, prefix="/api")
app.include_router(service_centers.router, prefix="/api")
app.include_router(service_visits.router, prefix="/api")

View File

@@ -0,0 +1,94 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Achievement(Base):
__tablename__ = "achievements"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(80), unique=True, index=True)
scope: Mapped[str] = mapped_column(String(24), default="user", server_default="user", index=True)
title: Mapped[str] = mapped_column(String(120))
description: Mapped[str] = mapped_column(String(260))
icon: Mapped[str | None] = mapped_column(String(40))
category: Mapped[str | None] = mapped_column(String(40), index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class UserAchievement(Base):
__tablename__ = "user_achievements"
__table_args__ = (
UniqueConstraint(
"user_id",
"achievement_id",
"vehicle_id",
"service_center_id",
name="uq_user_achievement_scope",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
achievement_id: Mapped[int] = mapped_column(ForeignKey("achievements.id", ondelete="CASCADE"), index=True)
vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
unlocked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON)
class VehicleScore(Base):
__tablename__ = "vehicle_scores"
id: Mapped[int] = mapped_column(primary_key=True)
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), unique=True, index=True)
completeness_score: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
verified_history_score: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
maintenance_health_score: Mapped[int] = mapped_column(Integer, default=100, server_default="100")
maintenance_status: Mapped[str] = mapped_column(String(24), default="unknown", server_default="unknown", index=True)
profile_quality: Mapped[str] = mapped_column(String(40), default="basic", server_default="basic", index=True)
verified_history_status: Mapped[str] = mapped_column(String(40), default="self_reported", server_default="self_reported", index=True)
missing_items: Mapped[list | None] = mapped_column(JSON)
computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ServiceCenterScore(Base):
__tablename__ = "service_center_scores"
id: Mapped[int] = mapped_column(primary_key=True)
service_center_id: Mapped[int] = mapped_column(
ForeignKey("service_centers.id", ondelete="CASCADE"), unique=True, index=True
)
trust_score: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
trust_level: Mapped[str] = mapped_column(String(40), default="new_service", server_default="new_service", index=True)
confirmed_visits_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
confirmation_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0, server_default="0")
dispute_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0, server_default="0")
computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class EngagementEvent(Base):
__tablename__ = "engagement_events"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)

View File

@@ -0,0 +1,60 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class MissingItem(BaseModel):
code: str
title: str
description: str
weight: int
class VehicleScoreRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
vehicle_id: int
completeness_score: int
verified_history_score: int
maintenance_health_score: int
maintenance_status: str
profile_quality: str
verified_history_status: str
missing_items: list[MissingItem] = []
computed_at: datetime
class AchievementRead(BaseModel):
code: str
scope: str
title: str
description: str
icon: str | None = None
category: str | None = None
unlocked_at: datetime | None = None
vehicle_id: int | None = None
service_center_id: int | None = None
class TimelineItem(BaseModel):
id: str
date: datetime | str
type: str
title: str
status: str | None = None
description: str | None = None
amount: Decimal | float | int | None = None
metadata: dict | None = None
class ServiceCenterScoreRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
service_center_id: int
trust_score: int
trust_level: str
confirmed_visits_count: int
confirmation_rate: Decimal | float
dispute_rate: Decimal | float
computed_at: datetime

456
app/services/scoring.py Normal file
View File

@@ -0,0 +1,456 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.car import Car, ServiceCenter, ServiceVisit
from app.models.expense import FuelEntry, ServiceEntry
from app.models.gamification import (
Achievement,
EngagementEvent,
ServiceCenterScore,
UserAchievement,
VehicleScore,
)
@dataclass(frozen=True)
class MissingItem:
code: str
title: str
description: str
weight: int
def as_dict(self) -> dict:
return {
"code": self.code,
"title": self.title,
"description": self.description,
"weight": self.weight,
}
DEFAULT_ACHIEVEMENTS = [
{
"code": "first_service_record",
"scope": "vehicle",
"title": "First Service Record",
"description": "The vehicle passport has its first service record.",
"icon": "service",
"category": "maintenance",
},
{
"code": "first_verified_service",
"scope": "vehicle",
"title": "First Verified Service",
"description": "A service-center visit was confirmed by the owner.",
"icon": "verified",
"category": "trust",
},
{
"code": "full_vehicle_profile",
"scope": "vehicle",
"title": "Full Vehicle Profile",
"description": "The digital vehicle passport has high-quality identity and maintenance data.",
"icon": "passport",
"category": "profile",
},
{
"code": "fuel_tracking_active",
"scope": "vehicle",
"title": "Fuel Tracking Active",
"description": "Fuel records are consistent enough to improve running-cost analytics.",
"icon": "fuel",
"category": "tracking",
},
{
"code": "trusted_history",
"scope": "vehicle",
"title": "Trusted History",
"description": "Most maintenance records are confirmed and suitable for a trusted vehicle history.",
"icon": "shield",
"category": "trust",
},
{
"code": "complete_garage",
"scope": "user",
"title": "Complete Garage",
"description": "Every active vehicle in the garage has a strong digital passport.",
"icon": "garage",
"category": "garage",
},
]
def profile_quality(score: int) -> str:
if score >= 86:
return "high_confidence"
if score >= 61:
return "strong"
if score >= 31:
return "useful"
return "basic"
def verified_history_status(score: int, confirmed_visits: int) -> str:
if score >= 80 and confirmed_visits > 0:
return "verified"
if score >= 30 or confirmed_visits > 0:
return "partially_verified"
return "self_reported"
def score_maintenance(car: Car, service_entries: list[ServiceEntry], visits: list[ServiceVisit]) -> tuple[int, str]:
today = date.today()
current_odometer = car.current_odometer
red = False
yellow = False
has_baseline = bool(service_entries or visits)
for entry in service_entries:
if entry.next_due_date and entry.next_due_date < today:
red = True
elif entry.next_due_date and (entry.next_due_date - today).days <= 30:
yellow = True
if current_odometer and entry.next_due_odometer:
overdue_km = current_odometer - entry.next_due_odometer
if overdue_km > 0:
red = True
elif overdue_km >= -1000:
yellow = True
for visit in visits:
for item in getattr(visit, "work_items", []) or []:
if item.next_due_date and item.next_due_date < today:
red = True
elif item.next_due_date and (item.next_due_date - today).days <= 30:
yellow = True
if current_odometer and item.next_due_odometer:
overdue_km = current_odometer - item.next_due_odometer
if overdue_km > 0:
red = True
elif overdue_km >= -1000:
yellow = True
if red:
return 30, "red"
if yellow:
return 70, "yellow"
if has_baseline:
return 100, "green"
return 55, "unknown"
async def compute_vehicle_score(session: AsyncSession, car: Car) -> VehicleScore:
fuel_entries = list(
(
await session.execute(
select(FuelEntry).where(FuelEntry.car_id == car.id).order_by(FuelEntry.entry_date.desc())
)
).scalars()
)
service_entries = list(
(
await session.execute(
select(ServiceEntry).where(ServiceEntry.car_id == car.id).order_by(ServiceEntry.entry_date.desc())
)
).scalars()
)
visits = list(
(
await session.execute(
select(ServiceVisit)
.options(selectinload(ServiceVisit.work_items))
.where(ServiceVisit.vehicle_id == car.id)
.order_by(ServiceVisit.visit_date.desc())
)
).scalars()
)
score = 5
missing: list[MissingItem] = []
baseline_fields = [car.make, car.model, car.year, car.name]
baseline_points = int(round(15 * sum(1 for value in baseline_fields if value) / len(baseline_fields)))
score += baseline_points
if baseline_points < 15:
missing.append(
MissingItem(
"vehicle_baseline",
"Complete make, model and year",
"These fields make the passport easier to identify and export.",
15 - baseline_points,
)
)
if car.vin_normalized:
score += 15
else:
missing.append(MissingItem("vin", "Add VIN", "VIN raises identity confidence for the vehicle passport.", 15))
if car.license_plate_normalized and car.license_plate_country:
score += 10
else:
missing.append(
MissingItem("license_plate", "Add license plate", "Plate and country help service centers request access safely.", 10)
)
if car.current_odometer:
score += 10
else:
missing.append(MissingItem("odometer", "Add current mileage", "Mileage improves reminders and maintenance health.", 10))
if car.fuel_type and (fuel_entries or car.target_consumption_l_per_100km):
score += 10
else:
missing.append(
MissingItem("fuel_baseline", "Add fuel baseline", "Fuel type and records improve consumption analytics.", 10)
)
if car.engine_oil_type and car.engine_oil_volume_l:
score += 10
else:
missing.append(MissingItem("engine_oil", "Add oil specification", "Oil type and volume make service reminders useful.", 10))
fluid_fields = [car.transmission_fluid_type, car.coolant_type, car.brake_fluid_type]
fluid_points = min(5, sum(1 for value in fluid_fields if value) * 2)
score += fluid_points
if fluid_points < 5:
missing.append(MissingItem("fluids", "Add fluid details", "Transmission, coolant and brake fluid data improve service readiness.", 5 - fluid_points))
if fuel_entries:
score += 5
else:
missing.append(MissingItem("fuel_entry", "Add first fuel record", "Fuel records unlock more accurate ownership cost analytics.", 5))
if service_entries or visits:
score += 5
else:
missing.append(MissingItem("service_entry", "Add service history", "Service records build the maintenance timeline.", 5))
confirmed_visits = [visit for visit in visits if visit.status == "confirmed"]
if confirmed_visits:
score += 10
else:
missing.append(
MissingItem(
"confirmed_service",
"Confirm a service-center visit",
"Owner-confirmed service visits make the history more trustworthy.",
10,
)
)
total_service_records = len(service_entries) + len([visit for visit in visits if visit.status != "draft"])
trusted_records = len(confirmed_visits)
history_score = int(round((trusted_records / total_service_records) * 100)) if total_service_records else 0
maintenance_score, maintenance_status = score_maintenance(car, service_entries, visits)
score = min(100, score)
result = await session.execute(select(VehicleScore).where(VehicleScore.vehicle_id == car.id))
vehicle_score = result.scalar_one_or_none()
payload = {
"completeness_score": score,
"verified_history_score": history_score,
"maintenance_health_score": maintenance_score,
"maintenance_status": maintenance_status,
"profile_quality": profile_quality(score),
"verified_history_status": verified_history_status(history_score, trusted_records),
"missing_items": [item.as_dict() for item in missing],
}
if vehicle_score is None:
vehicle_score = VehicleScore(vehicle_id=car.id, **payload)
session.add(vehicle_score)
else:
for key, value in payload.items():
setattr(vehicle_score, key, value)
await session.flush()
await evaluate_vehicle_achievements(session, car, vehicle_score, fuel_entries, service_entries, visits)
return vehicle_score
async def ensure_default_achievements(session: AsyncSession) -> dict[str, Achievement]:
result = await session.execute(select(Achievement))
existing = {achievement.code: achievement for achievement in result.scalars()}
for item in DEFAULT_ACHIEVEMENTS:
if item["code"] not in existing:
achievement = Achievement(**item)
session.add(achievement)
existing[item["code"]] = achievement
await session.flush()
return existing
async def unlock_achievement(
session: AsyncSession,
*,
user_id: int,
achievement: Achievement,
vehicle_id: int | None = None,
service_center_id: int | None = None,
metadata: dict | None = None,
) -> None:
result = await session.execute(
select(UserAchievement).where(
UserAchievement.user_id == user_id,
UserAchievement.achievement_id == achievement.id,
UserAchievement.vehicle_id.is_(None) if vehicle_id is None else UserAchievement.vehicle_id == vehicle_id,
UserAchievement.service_center_id.is_(None)
if service_center_id is None
else UserAchievement.service_center_id == service_center_id,
)
)
if result.scalar_one_or_none() is not None:
return
session.add(
UserAchievement(
user_id=user_id,
achievement_id=achievement.id,
vehicle_id=vehicle_id,
service_center_id=service_center_id,
metadata_json=metadata,
)
)
session.add(
EngagementEvent(
user_id=user_id,
vehicle_id=vehicle_id,
service_center_id=service_center_id,
event_type="achievement_unlocked",
metadata_json={"code": achievement.code},
)
)
async def evaluate_vehicle_achievements(
session: AsyncSession,
car: Car,
vehicle_score: VehicleScore,
fuel_entries: list[FuelEntry],
service_entries: list[ServiceEntry],
visits: list[ServiceVisit],
) -> None:
achievements = await ensure_default_achievements(session)
if service_entries or visits:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["first_service_record"],
)
if any(visit.status == "confirmed" for visit in visits):
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["first_verified_service"],
)
if vehicle_score.completeness_score >= 90:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["full_vehicle_profile"],
)
if len(fuel_entries) >= 3:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["fuel_tracking_active"],
)
if vehicle_score.verified_history_status == "verified":
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["trusted_history"],
)
async def evaluate_garage_achievements(session: AsyncSession, user_id: int) -> None:
result = await session.execute(select(Car).where(Car.owner_id == user_id))
cars = list(result.scalars())
if not cars:
return
scores = []
for car in cars:
scores.append(await compute_vehicle_score(session, car))
if all(score.completeness_score >= 80 for score in scores):
achievements = await ensure_default_achievements(session)
await unlock_achievement(session, user_id=user_id, achievement=achievements["complete_garage"])
async def compute_service_center_score(session: AsyncSession, center: ServiceCenter) -> ServiceCenterScore:
visits = list(
(
await session.execute(select(ServiceVisit).where(ServiceVisit.service_center_id == center.id))
).scalars()
)
relevant = [visit for visit in visits if visit.status not in {"draft", "cancelled"}]
confirmed = [visit for visit in relevant if visit.status == "confirmed"]
disputed = [visit for visit in relevant if visit.status == "disputed"]
confirmation_rate = Decimal("0")
dispute_rate = Decimal("0")
if relevant:
confirmation_rate = Decimal(len(confirmed) * 100) / Decimal(len(relevant))
dispute_rate = Decimal(len(disputed) * 100) / Decimal(len(relevant))
score = 20 if center.verification_status == "verified" else 5
score += min(30, len(confirmed) * 5)
score += int(min(30, confirmation_rate * Decimal("0.3")))
score -= int(min(25, dispute_rate * Decimal("0.5")))
score = max(0, min(100, score))
if center.verification_status != "verified":
level = "new_service"
elif score >= 85:
level = "high_confidence_service"
elif score >= 65:
level = "reliable_service"
else:
level = "verified_service"
result = await session.execute(select(ServiceCenterScore).where(ServiceCenterScore.service_center_id == center.id))
center_score = result.scalar_one_or_none()
payload = {
"trust_score": score,
"trust_level": level,
"confirmed_visits_count": len(confirmed),
"confirmation_rate": confirmation_rate.quantize(Decimal("0.01")),
"dispute_rate": dispute_rate.quantize(Decimal("0.01")),
}
if center_score is None:
center_score = ServiceCenterScore(service_center_id=center.id, **payload)
session.add(center_score)
else:
for key, value in payload.items():
setattr(center_score, key, value)
await session.flush()
return center_score
async def garage_quality(session: AsyncSession, user_id: int) -> dict:
result = await session.execute(select(Car).where(Car.owner_id == user_id))
cars = list(result.scalars())
if not cars:
return {"vehicles_count": 0, "average_completeness": 0, "verified_records": 0}
scores = [await compute_vehicle_score(session, car) for car in cars]
confirmed_count = (
await session.execute(
select(func.count(ServiceVisit.id)).where(
ServiceVisit.vehicle_id.in_([car.id for car in cars]),
ServiceVisit.status == "confirmed",
)
)
).scalar_one()
return {
"vehicles_count": len(cars),
"average_completeness": int(round(sum(score.completeness_score for score in scores) / len(scores))),
"verified_records": confirmed_count,
}