Add CarPass gamification scoring foundation
This commit is contained in:
389
GAMIFICATION_PLAN.md
Normal file
389
GAMIFICATION_PLAN.md
Normal 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.
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.core.config import settings
|
||||
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.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
157
alembic/versions/202605120006_gamification_scores.py
Normal file
157
alembic/versions/202605120006_gamification_scores.py
Normal 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
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,
|
||||
}
|
||||
@@ -14,7 +14,7 @@ from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.session import get_session
|
||||
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_INTERNAL_TOKEN = "internal-test-token"
|
||||
|
||||
86
tests/test_gamification.py
Normal file
86
tests/test_gamification.py
Normal 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"
|
||||
Reference in New Issue
Block a user