Add gamification trust analytics polish
This commit is contained in:
@@ -158,6 +158,12 @@ Backend проверяет подпись Telegram, создает/обновл
|
|||||||
- `POST /api/ocr/license-plate`
|
- `POST /api/ocr/license-plate`
|
||||||
- `POST /api/ocr/vin`
|
- `POST /api/ocr/vin`
|
||||||
- `POST /api/ocr/service-document`
|
- `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`, а не выдуманную цифру.
|
Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.services.scoring import (
|
|||||||
compute_service_center_score,
|
compute_service_center_score,
|
||||||
compute_vehicle_score,
|
compute_vehicle_score,
|
||||||
evaluate_garage_achievements,
|
evaluate_garage_achievements,
|
||||||
|
record_engagement_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["gamification"])
|
router = APIRouter(tags=["gamification"])
|
||||||
@@ -64,6 +65,13 @@ async def vehicle_score(
|
|||||||
):
|
):
|
||||||
car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user)
|
car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user)
|
||||||
score = await compute_vehicle_score(session, car)
|
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.commit()
|
||||||
await session.refresh(score)
|
await session.refresh(score)
|
||||||
return score
|
return score
|
||||||
@@ -82,6 +90,12 @@ async def vehicle_timeline(
|
|||||||
limit = min(limit, 200)
|
limit = min(limit, 200)
|
||||||
offset = max(offset, 0)
|
offset = max(offset, 0)
|
||||||
car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user)
|
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())
|
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())
|
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())
|
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")
|
raise HTTPException(status_code=404, detail="Service center not found")
|
||||||
await ensure_service_employee(session, service_center_id, current_user)
|
await ensure_service_employee(session, service_center_id, current_user)
|
||||||
score = await compute_service_center_score(session, center)
|
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.commit()
|
||||||
await session.refresh(score)
|
await session.refresh(score)
|
||||||
return score
|
return score
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
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(
|
async def evaluate_vehicle_achievements(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
car: Car,
|
car: Car,
|
||||||
|
|||||||
@@ -803,7 +803,16 @@ function openCarProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadServiceCenters() {
|
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();
|
renderServiceCenters();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,12 +830,23 @@ function renderServiceCenters() {
|
|||||||
<strong>${center.display_name || center.name}</strong>
|
<strong>${center.display_name || center.name}</strong>
|
||||||
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
|
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
|
||||||
<small>Статус: ${center.verification_status}</small>
|
<small>Статус: ${center.verification_status}</small>
|
||||||
|
${center.trust_score ? `<span class="trust-badge">${trustLabel(center.trust_score.trust_level)} · ${center.trust_score.trust_score}/100</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trustLabel(level) {
|
||||||
|
const labels = {
|
||||||
|
new_service: "Новый сервис",
|
||||||
|
verified_service: "Проверенный сервис",
|
||||||
|
reliable_service: "Надежный сервис",
|
||||||
|
high_confidence_service: "Высокое доверие",
|
||||||
|
};
|
||||||
|
return labels[level] || "Новый сервис";
|
||||||
|
}
|
||||||
|
|
||||||
function renderPlaceholderList(selector, message) {
|
function renderPlaceholderList(selector, message) {
|
||||||
const root = document.querySelector(selector);
|
const root = document.querySelector(selector);
|
||||||
if (root) root.innerHTML = `<div class="empty">${message}</div>`;
|
if (root) root.innerHTML = `<div class="empty">${message}</div>`;
|
||||||
|
|||||||
@@ -1373,6 +1373,17 @@ select {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trust-badge {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 5px 8px;
|
||||||
|
color: #17362f;
|
||||||
|
border: 1px solid rgba(22, 128, 106, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(94, 224, 189, 0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user