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

389
GAMIFICATION_PLAN.md Normal file
View File

@@ -0,0 +1,389 @@
# CarPass Gamification Plan
## 1. Goals
CarPass gamification is a trust and data-quality system, not a game layer. The product goal is to turn a casual vehicle diary into a premium digital vehicle passport that keeps owners returning because the data becomes more useful over time.
Primary goals:
- Increase retention by showing owners how each verified record improves the vehicle passport.
- Improve data quality for VIN, license plate, mileage, oil specs, service intervals, fuel consumption, and service history.
- Motivate owner confirmation of service visits and sensitive vehicle data changes.
- Encourage structured interaction between owners and verified service centers.
- Make the vehicle more resale-ready by building a reliable, private maintenance history.
- Give service centers a trust signal based on confirmed work, low dispute rates, and good operational hygiene.
Non-goals:
- No public leaderboards.
- No speed, harsh driving, racing, or mileage competition.
- No coins, gambling, loot boxes, streak pressure, or daily spam loops.
- No public comparison of users, vehicles, or service centers.
## 2. UX Philosophy
The experience should feel like a premium automotive SaaS product: calm, precise, and useful. The visual language should resemble a digital inspection report, ownership dashboard, and service-grade vehicle passport.
Principles:
- Use "quality", "verification", "health", and "trust" language instead of "points" or "levels".
- Show progress as professional indicators: rings, bars, badges, timelines, and status chips.
- Explain why each missing item matters: VIN improves identity confidence, full-tank fuel records improve consumption accuracy, confirmed service visits improve resale trust.
- Avoid shame. Use neutral states like "Needs data", "Partially verified", "Attention needed".
- Keep notifications sparse and high-signal.
- Make the owner feel in control. Service centers can propose, but owner confirmation decides trust.
## 3. Reward Types
Rewards are evidence markers, not currency.
Vehicle rewards:
- Profile Quality: percentage of important fields completed.
- Verified History Status: self-reported, partially verified, verified maintenance history.
- Maintenance Health: green, yellow, red based on overdue service and reminders.
- Passport Badges: durable achievements such as Full Vehicle Profile or First Verified Service.
- Timeline Milestones: service, fuel, verification, OBD, and ownership events.
Owner rewards:
- Garage Quality: average completion across active vehicles.
- Complete Garage: every active vehicle has identity, mileage, and maintenance baseline.
- Maintenance Consistency: regular data capture without aggressive streak language.
Service center rewards:
- Verified Service Center badge.
- Trust Level: new, verified, reliable, high-confidence.
- Confirmation Quality: owner confirmation rate, low dispute rate, completed visit quality.
## 4. Progression System
Progression is based on increasing confidence in the digital passport.
Vehicle Profile Completion:
- 0-30: Basic profile.
- 31-60: Useful ownership profile.
- 61-85: Strong maintenance profile.
- 86-100: High-confidence vehicle passport.
Verified History:
- Self-reported: owner-only records.
- Partially verified: at least one confirmed service-center visit or a mixed history.
- Verified maintenance history: most service history is confirmed by verified service centers.
Maintenance Health:
- Green: no overdue critical service reminders.
- Yellow: service due soon or incomplete maintenance baseline.
- Red: overdue service by date or mileage.
Service Trust:
- New service: verified account but low confirmed work volume.
- Verified service: documents checked.
- Reliable service: good confirmation rate and low dispute rate.
- High-confidence service: consistent confirmed work and low unresolved disputes.
## 5. User Engagement Loops
Owner loop:
1. Add vehicle.
2. See passport quality and missing items.
3. Add VIN/license plate/oil/mileage/fuel/service data.
4. Receive a clearer score and timeline.
5. Confirm service-center visits.
6. Build a trustworthy maintenance history.
Service loop:
1. Register service center.
2. Pass verification.
3. Invite employees.
4. Create service visits.
5. Owner confirms work.
6. Trust score improves through confirmed, low-dispute work.
OCR loop:
1. Scan VIN, plate, or document.
2. System extracts candidates.
3. User or employee confirms suggested values.
4. Sensitive changes become pending requests until owner approval.
Notification loop:
- Notify only for due maintenance, owner confirmation, important data quality milestones, and access requests.
- Never send daily streak pressure.
## 6. Vehicle Profile Completion
Each vehicle gets `completeness_score` from 0 to 100.
Suggested scoring weights:
- Vehicle exists and has owner access: 5.
- Make/model/year/name baseline: 15.
- VIN: 15.
- License plate with country: 10.
- Current odometer: 10.
- Fuel type and consumption baseline: 10.
- Engine oil type and volume: 10.
- Transmission/coolant/brake fluid fields: 5.
- At least one fuel entry: 5.
- At least one service entry or service visit: 5.
- At least one confirmed service-center visit: 10.
- Future OBD connection: 10.
The score is capped at 100. The API should also return `missing_items` with practical next actions.
## 7. Verified Maintenance History
Verified history score measures how trustworthy the service history is.
Inputs:
- Total service records.
- Confirmed service-center visits.
- Whether the service center is verified.
- Disputed/rejected/cancelled visits.
- Owner-created self-reported records.
Statuses:
- `self_reported`: no confirmed service-center visits.
- `partially_verified`: at least one confirmed service visit or 30-79% trusted records.
- `verified`: 80%+ trusted records and no unresolved disputes.
Sensitive rule:
- OCR or service-center input can suggest values, but it cannot automatically make history trusted without owner confirmation.
## 8. Service Center Trust Mechanics
Trust score is operational quality, not a public star rating.
Inputs:
- Verification status.
- Confirmed visit count.
- Owner confirmation rate.
- Dispute rate.
- Rejected/cancelled visit rate.
- Employee hygiene: disabled employees, unresolved disputes, suspicious lookup patterns.
Display:
- New service.
- Verified service.
- Reliable service.
- High-confidence service.
Avoid:
- Public numeric ranking.
- Punitive public labels.
- One-click toxic ratings.
## 9. Anti-Abuse Rules
Achievements:
- Unlock once per user or once per vehicle depending on scope.
- Use unique constraints to prevent duplicate unlocks.
- Do not unlock from deleted or disputed records.
Scoring:
- Ignore duplicate low-quality records created in a short time window.
- Require valid owner access for vehicle score APIs.
- Require confirmed visits for verified-history credit.
- Require verified service-center status for high trust credit.
Search and OCR:
- Rate-limit VIN/license plate search and OCR later.
- Return masked/minimal search results.
- Log lookups in `AuditLog` or `EngagementEvent`.
Deletion:
- Owner cannot erase service-center history without trace.
- Use dispute, rejected, hidden, or cancelled status plus audit trail.
## 10. Notification Strategy
Notification types:
- Maintenance due soon.
- Maintenance overdue.
- Service visit pending owner confirmation.
- Vehicle data change request pending.
- Profile completion milestone: 50%, 80%, 100%.
- First verified service confirmed.
Rules:
- No daily streak reminders.
- No more than one non-critical nudge per vehicle per week.
- Critical maintenance alerts can repeat with conservative cooldown.
- Owner controls notification preferences.
- Notifications must be actionable and quiet.
## 11. Privacy Considerations
- No public leaderboard.
- No sharing of Telegram ID, phone, VIN, full plate, or service history without consent.
- Service-center search returns masked VIN/plate and minimal vehicle identity.
- Owner controls access duration and revocation.
- Scores are private by default.
- Service trust shown to owners should be aggregate and non-invasive.
## 12. Database Changes
New tables:
- `achievements`
- `id`
- `code`
- `scope`: user / vehicle / service_center
- `title`
- `description`
- `icon`
- `category`
- `is_active`
- `created_at`
- `user_achievements`
- `id`
- `user_id`
- `achievement_id`
- `vehicle_id`
- `service_center_id`
- `unlocked_at`
- `metadata_json`
- `vehicle_scores`
- `id`
- `vehicle_id`
- `completeness_score`
- `verified_history_score`
- `maintenance_health_score`
- `maintenance_status`
- `profile_quality`
- `verified_history_status`
- `missing_items`
- `computed_at`
- `service_center_scores`
- `id`
- `service_center_id`
- `trust_score`
- `trust_level`
- `confirmed_visits_count`
- `confirmation_rate`
- `dispute_rate`
- `computed_at`
- `engagement_events`
- `id`
- `user_id`
- `vehicle_id`
- `service_center_id`
- `event_type`
- `metadata_json`
- `created_at`
Indexes:
- `achievements.code` unique.
- `user_achievements(user_id, achievement_id, vehicle_id, service_center_id)` unique.
- `vehicle_scores.vehicle_id` unique.
- `service_center_scores.service_center_id` unique.
- `engagement_events(user_id, event_type, created_at)`.
- `engagement_events(vehicle_id, event_type, created_at)`.
## 13. API Changes
Owner APIs:
- `GET /api/me/achievements`
- `GET /api/my/vehicles/{vehicle_id}/score`
- `GET /api/my/vehicles/{vehicle_id}/timeline`
Service APIs:
- `GET /api/service-centers/{service_center_id}/trust-score`
Internal scoring APIs, optional later:
- `POST /api/internal/scoring/recompute`
- `POST /api/internal/achievements/evaluate`
Response principles:
- Return computed scores from backend.
- Return missing items and human-readable next actions.
- Never trust `user_id` from request body.
- Every vehicle endpoint uses `current_user` and vehicle access checks.
## 14. Frontend UI Changes
Replace the simple "Профиль учета" strip with a premium passport dashboard:
- Vehicle Passport Quality card with progress ring.
- Verified History card with status and trusted record ratio.
- Maintenance Health widget with green/yellow/red status.
- "Что улучшить" list with 2-4 next actions.
- Achievement cards in calm SaaS style.
- Vehicle timeline with fuel, service, confirmation, OCR, OBD, and achievements.
- Service trust badge in service-center screens.
- Garage overview with average completion and active alerts.
Style:
- Dark premium automotive UI.
- Calm contrast, thin borders, glass/dark panels used sparingly.
- No arcade language, no coin counters, no animated confetti.
## 15. Future Extensibility
Future modules should plug into the scoring engine without rewriting frontend:
- OBD2: connected vehicle badge, automatic mileage, battery monitoring.
- Insurance: verified maintenance packet for insurance offers.
- Resale: exportable CarPass report.
- Predictive maintenance: service recommendations based on mileage/time/fuel/OBD.
- AI assistant: explain score changes and suggest next best action.
- Service recommendations: based on vehicle type, overdue work, and trusted service access.
## Implementation Phases
Phase 1:
- Add backend models and migration.
- Add scoring engine.
- Add achievement engine.
- Add score, achievements, timeline, and trust-score APIs.
- Add focused tests.
Phase 2:
- Replace frontend progress strip with backend-driven widgets.
- Add achievements and timeline UI.
- Keep old MVP data flows intact.
Phase 3:
- Add smart notification events and cooldown-ready schema.
- Expand trust score for service centers.
- Add service-center-facing gamification signals.
Phase 4:
- Add engagement analytics and anti-abuse refinements.
- Optimize queries and add recomputation jobs.
- Prepare production deployment checklist.

View File

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import settings from app.core.config import settings
from app.db.base import Base from app.db.base import Base
from app.models import car, expense, push, user # noqa: F401 from app.models import car, expense, gamification, push, user # noqa: F401
config = context.config config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)

View File

@@ -0,0 +1,157 @@
"""gamification scores
Revision ID: 202605120006
Revises: 202605120005
Create Date: 2026-05-12 20:10:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "202605120006"
down_revision: str | None = "202605120005"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"achievements",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=80), nullable=False),
sa.Column("scope", sa.String(length=24), server_default="user", nullable=False),
sa.Column("title", sa.String(length=120), nullable=False),
sa.Column("description", sa.String(length=260), nullable=False),
sa.Column("icon", sa.String(length=40), nullable=True),
sa.Column("category", sa.String(length=40), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_achievements_category", "achievements", ["category"])
op.create_index("ix_achievements_code", "achievements", ["code"], unique=True)
op.create_index("ix_achievements_is_active", "achievements", ["is_active"])
op.create_index("ix_achievements_scope", "achievements", ["scope"])
op.create_table(
"vehicle_scores",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vehicle_id", sa.Integer(), nullable=False),
sa.Column("completeness_score", sa.Integer(), server_default="0", nullable=False),
sa.Column("verified_history_score", sa.Integer(), server_default="0", nullable=False),
sa.Column("maintenance_health_score", sa.Integer(), server_default="100", nullable=False),
sa.Column("maintenance_status", sa.String(length=24), server_default="unknown", nullable=False),
sa.Column("profile_quality", sa.String(length=40), server_default="basic", nullable=False),
sa.Column("verified_history_status", sa.String(length=40), server_default="self_reported", nullable=False),
sa.Column("missing_items", postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column("computed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_vehicle_scores_maintenance_status", "vehicle_scores", ["maintenance_status"])
op.create_index("ix_vehicle_scores_profile_quality", "vehicle_scores", ["profile_quality"])
op.create_index("ix_vehicle_scores_vehicle_id", "vehicle_scores", ["vehicle_id"], unique=True)
op.create_index("ix_vehicle_scores_verified_history_status", "vehicle_scores", ["verified_history_status"])
op.create_table(
"service_center_scores",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_center_id", sa.Integer(), nullable=False),
sa.Column("trust_score", sa.Integer(), server_default="0", nullable=False),
sa.Column("trust_level", sa.String(length=40), server_default="new_service", nullable=False),
sa.Column("confirmed_visits_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("confirmation_rate", sa.Numeric(5, 2), server_default="0", nullable=False),
sa.Column("dispute_rate", sa.Numeric(5, 2), server_default="0", nullable=False),
sa.Column("computed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_service_center_scores_service_center_id", "service_center_scores", ["service_center_id"], unique=True)
op.create_index("ix_service_center_scores_trust_level", "service_center_scores", ["trust_level"])
op.create_table(
"user_achievements",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("achievement_id", sa.Integer(), nullable=False),
sa.Column("vehicle_id", sa.Integer(), nullable=True),
sa.Column("service_center_id", sa.Integer(), nullable=True),
sa.Column("unlocked_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("metadata_json", postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(["achievement_id"], ["achievements.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id",
"achievement_id",
"vehicle_id",
"service_center_id",
name="uq_user_achievement_scope",
),
)
op.create_index("ix_user_achievements_achievement_id", "user_achievements", ["achievement_id"])
op.create_index("ix_user_achievements_service_center_id", "user_achievements", ["service_center_id"])
op.create_index("ix_user_achievements_unlocked_at", "user_achievements", ["unlocked_at"])
op.create_index("ix_user_achievements_user_id", "user_achievements", ["user_id"])
op.create_index("ix_user_achievements_vehicle_id", "user_achievements", ["vehicle_id"])
op.create_table(
"engagement_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("vehicle_id", sa.Integer(), nullable=True),
sa.Column("service_center_id", sa.Integer(), nullable=True),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("metadata_json", postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_engagement_events_created_at", "engagement_events", ["created_at"])
op.create_index("ix_engagement_events_event_type", "engagement_events", ["event_type"])
op.create_index("ix_engagement_events_service_center_id", "engagement_events", ["service_center_id"])
op.create_index("ix_engagement_events_user_id", "engagement_events", ["user_id"])
op.create_index("ix_engagement_events_vehicle_id", "engagement_events", ["vehicle_id"])
op.create_index("ix_engagement_events_user_type_created", "engagement_events", ["user_id", "event_type", "created_at"])
op.create_index("ix_engagement_events_vehicle_type_created", "engagement_events", ["vehicle_id", "event_type", "created_at"])
def downgrade() -> None:
op.drop_index("ix_engagement_events_vehicle_type_created", table_name="engagement_events")
op.drop_index("ix_engagement_events_user_type_created", table_name="engagement_events")
op.drop_index("ix_engagement_events_vehicle_id", table_name="engagement_events")
op.drop_index("ix_engagement_events_user_id", table_name="engagement_events")
op.drop_index("ix_engagement_events_service_center_id", table_name="engagement_events")
op.drop_index("ix_engagement_events_event_type", table_name="engagement_events")
op.drop_index("ix_engagement_events_created_at", table_name="engagement_events")
op.drop_table("engagement_events")
op.drop_index("ix_user_achievements_vehicle_id", table_name="user_achievements")
op.drop_index("ix_user_achievements_user_id", table_name="user_achievements")
op.drop_index("ix_user_achievements_unlocked_at", table_name="user_achievements")
op.drop_index("ix_user_achievements_service_center_id", table_name="user_achievements")
op.drop_index("ix_user_achievements_achievement_id", table_name="user_achievements")
op.drop_table("user_achievements")
op.drop_index("ix_service_center_scores_trust_level", table_name="service_center_scores")
op.drop_index("ix_service_center_scores_service_center_id", table_name="service_center_scores")
op.drop_table("service_center_scores")
op.drop_index("ix_vehicle_scores_verified_history_status", table_name="vehicle_scores")
op.drop_index("ix_vehicle_scores_vehicle_id", table_name="vehicle_scores")
op.drop_index("ix_vehicle_scores_profile_quality", table_name="vehicle_scores")
op.drop_index("ix_vehicle_scores_maintenance_status", table_name="vehicle_scores")
op.drop_table("vehicle_scores")
op.drop_index("ix_achievements_scope", table_name="achievements")
op.drop_index("ix_achievements_is_active", table_name="achievements")
op.drop_index("ix_achievements_code", table_name="achievements")
op.drop_index("ix_achievements_category", table_name="achievements")
op.drop_table("achievements")

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, catalog,
change_requests, change_requests,
entries, entries,
gamification,
my, my,
ocr, ocr,
service_centers, service_centers,
@@ -34,6 +35,7 @@ app.include_router(my.router, prefix="/api")
app.include_router(catalog.router, prefix="/api") app.include_router(catalog.router, prefix="/api")
app.include_router(cars.router, prefix="/api") app.include_router(cars.router, prefix="/api")
app.include_router(entries.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(ocr.router, prefix="/api")
app.include_router(service_centers.router, prefix="/api") app.include_router(service_centers.router, prefix="/api")
app.include_router(service_visits.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,
}

View File

@@ -14,7 +14,7 @@ from app.core.config import settings
from app.db.base import Base from app.db.base import Base
from app.db.session import get_session from app.db.session import get_session
from app.main import app from app.main import app
from app.models import car, expense, push, user # noqa: F401 from app.models import car, expense, gamification, push, user # noqa: F401
TEST_BOT_TOKEN = "123456:test-token" TEST_BOT_TOKEN = "123456:test-token"
TEST_INTERNAL_TOKEN = "internal-test-token" TEST_INTERNAL_TOKEN = "internal-test-token"

View File

@@ -0,0 +1,86 @@
import pytest
@pytest.mark.asyncio
async def test_vehicle_score_is_backend_driven(client, auth_headers) -> None:
vehicle = (
await client.post(
"/api/my/vehicles",
headers=auth_headers,
json={
"name": "Passport car",
"make": "Hyundai",
"model": "Avante",
"year": 2020,
"license_plate": "12 가 3456",
"license_plate_country": "KR",
"vin": "KMHCT41BAHU123456",
"fuel_type": "gasoline",
"current_odometer": 12000,
"engine_oil_type": "5W-30",
"engine_oil_volume_l": 4.2,
},
)
).json()
await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": vehicle["id"],
"entry_date": "2026-05-12",
"odometer": 12000,
"liters": 35,
"price_per_liter": 2,
},
)
response = await client.get(f"/api/my/vehicles/{vehicle['id']}/score", headers=auth_headers)
assert response.status_code == 200
payload = response.json()
assert payload["vehicle_id"] == vehicle["id"]
assert payload["completeness_score"] >= 70
assert payload["profile_quality"] in {"strong", "high_confidence"}
assert isinstance(payload["missing_items"], list)
@pytest.mark.asyncio
async def test_timeline_contains_fuel_service_and_achievements(client, auth_headers) -> None:
vehicle = (await client.post("/api/my/vehicles", headers=auth_headers, json={"name": "Timeline car"})).json()
await client.post(
"/api/service",
headers=auth_headers,
json={
"car_id": vehicle["id"],
"entry_date": "2026-05-12",
"service_type": "maintenance",
"title": "Oil service",
"total_cost": 100,
},
)
await client.get(f"/api/my/vehicles/{vehicle['id']}/score", headers=auth_headers)
response = await client.get(f"/api/my/vehicles/{vehicle['id']}/timeline", headers=auth_headers)
assert response.status_code == 200
item_types = {item["type"] for item in response.json()}
assert "service" in item_types
assert "achievement" in item_types
@pytest.mark.asyncio
async def test_service_center_trust_score_requires_employee_access(client, auth_headers, other_auth_headers) -> None:
center = (
await client.post(
"/api/service-centers",
headers=auth_headers,
json={"display_name": "Trust Service", "country": "KR", "city": "Seoul"},
)
).json()
forbidden = await client.get(f"/api/service-centers/{center['id']}/trust-score", headers=other_auth_headers)
allowed = await client.get(f"/api/service-centers/{center['id']}/trust-score", headers=auth_headers)
assert forbidden.status_code == 403
assert allowed.status_code == 200
assert allowed.json()["trust_level"] == "new_service"