Add CarPass gamification scoring foundation
This commit is contained in:
184
app/api/gamification.py
Normal file
184
app/api/gamification.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
94
app/models/gamification.py
Normal file
94
app/models/gamification.py
Normal 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)
|
||||
60
app/schemas/gamification.py
Normal file
60
app/schemas/gamification.py
Normal 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
456
app/services/scoring.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user