diff --git a/README.md b/README.md index 41b6b23..04c3e4d 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,12 @@ Backend проверяет подпись Telegram, создает/обновл - `POST /api/ocr/license-plate` - `POST /api/ocr/vin` - `POST /api/ocr/service-document` +- `GET /api/me/achievements` +- `GET /api/my/vehicles/{vehicle_id}/score` +- `GET /api/my/vehicles/{vehicle_id}/timeline` +- `GET /api/service-centers/{service_center_id}/trust-score` + +CarPass quality and trust scores are backend-owned. The scoring engine in `app/services/scoring.py` calculates vehicle profile completeness, verified maintenance history, maintenance health, service-center trust, evidence-style achievements, and cooldown-protected engagement events. Frontend only displays the result. Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру. diff --git a/app/api/gamification.py b/app/api/gamification.py index 57acacc..3fec878 100644 --- a/app/api/gamification.py +++ b/app/api/gamification.py @@ -22,6 +22,7 @@ from app.services.scoring import ( compute_service_center_score, compute_vehicle_score, evaluate_garage_achievements, + record_engagement_event, ) router = APIRouter(tags=["gamification"]) @@ -64,6 +65,13 @@ async def vehicle_score( ): car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user) score = await compute_vehicle_score(session, car) + await record_engagement_event( + session, + event_type="vehicle_score_viewed", + user_id=current_user.id, + vehicle_id=vehicle_id, + metadata={"completeness_score": score.completeness_score}, + ) await session.commit() await session.refresh(score) return score @@ -82,6 +90,12 @@ async def vehicle_timeline( limit = min(limit, 200) offset = max(offset, 0) car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user) + await record_engagement_event( + session, + event_type="vehicle_timeline_viewed", + user_id=current_user.id, + vehicle_id=vehicle_id, + ) 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()) @@ -179,6 +193,13 @@ async def service_center_trust_score( 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 record_engagement_event( + session, + event_type="service_trust_score_viewed", + user_id=current_user.id, + service_center_id=service_center_id, + metadata={"trust_score": score.trust_score, "trust_level": score.trust_level}, + ) await session.commit() await session.refresh(score) return score diff --git a/app/services/scoring.py b/app/services/scoring.py index 3ca4aad..9ce326d 100644 --- a/app/services/scoring.py +++ b/app/services/scoring.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import date +from datetime import UTC, date, datetime, timedelta from decimal import Decimal from sqlalchemy import func, select @@ -327,6 +327,41 @@ async def unlock_achievement( ) +async def record_engagement_event( + session: AsyncSession, + *, + event_type: str, + user_id: int | None = None, + vehicle_id: int | None = None, + service_center_id: int | None = None, + metadata: dict | None = None, + cooldown_minutes: int = 360, +) -> None: + since = datetime.now(UTC) - timedelta(minutes=cooldown_minutes) + result = await session.execute( + select(EngagementEvent).where( + EngagementEvent.event_type == event_type, + EngagementEvent.user_id.is_(None) if user_id is None else EngagementEvent.user_id == user_id, + EngagementEvent.vehicle_id.is_(None) if vehicle_id is None else EngagementEvent.vehicle_id == vehicle_id, + EngagementEvent.service_center_id.is_(None) + if service_center_id is None + else EngagementEvent.service_center_id == service_center_id, + EngagementEvent.created_at >= since, + ) + ) + if result.scalar_one_or_none() is not None: + return + session.add( + EngagementEvent( + user_id=user_id, + vehicle_id=vehicle_id, + service_center_id=service_center_id, + event_type=event_type, + metadata_json=metadata, + ) + ) + + async def evaluate_vehicle_achievements( session: AsyncSession, car: Car, diff --git a/web/static/app.js b/web/static/app.js index 0fda144..595a862 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -803,7 +803,16 @@ function openCarProfile() { } async function loadServiceCenters() { - state.serviceCenters = await api("/service-centers/my"); + const centers = await api("/service-centers/my"); + state.serviceCenters = await Promise.all( + centers.map(async (center) => { + try { + return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) }; + } catch (_) { + return center; + } + }), + ); renderServiceCenters(); } @@ -821,12 +830,23 @@ function renderServiceCenters() { ${center.display_name || center.name} ${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"} Статус: ${center.verification_status} + ${center.trust_score ? `${trustLabel(center.trust_score.trust_level)} · ${center.trust_score.trust_score}/100` : ""} `, ) .join(""); } +function trustLabel(level) { + const labels = { + new_service: "Новый сервис", + verified_service: "Проверенный сервис", + reliable_service: "Надежный сервис", + high_confidence_service: "Высокое доверие", + }; + return labels[level] || "Новый сервис"; +} + function renderPlaceholderList(selector, message) { const root = document.querySelector(selector); if (root) root.innerHTML = `