Improve CarPass product UX and service flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 19:33:25 +09:00
parent b85db333d8
commit caa5f6d3db
36 changed files with 1836 additions and 366 deletions

View File

@@ -15,3 +15,7 @@ ALLOW_DEV_AUTH=false
APP_HOST=0.0.0.0
APP_PORT=8000
VAPID_PUBLIC_KEY=
OCR_PROVIDER=tesseract
OCR_LANGUAGES=eng+rus+kor
LLM_BASE_URL=
LLM_MODEL=

View File

@@ -6,7 +6,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& apt-get install -y --no-install-recommends gcc libpq-dev tesseract-ocr tesseract-ocr-eng tesseract-ocr-rus tesseract-ocr-kor \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./

View File

@@ -5,7 +5,7 @@
- FastAPI serves JSON API and static Telegram Mini App files from `web/`.
- aiogram bot opens the Mini App with `WEBAPP_URL` and talks to the API through `INTERNAL_API_TOKEN`.
- Users are Telegram-backed; user API access is based on verified Telegram WebApp `initData`.
- Current domain model covers personal garage use: `users`, `cars`, `fuel_entries`, `service_entries`, catalog tables, push subscriptions, service center stubs, and inbox messages.
- Current domain model covers personal garage use: `users`, `cars`, `fuel_entries`, `service_entries`, catalog tables, push subscriptions, verified service center flows, and inbox messages.
- Existing MVP must remain compatible: personal cars, fuel logs, service logs, reminders, stats, catalog, OCR text parsing, bot start/cars/add_car flows.
## 2. Target Product Model

218
README.md
View File

@@ -1,205 +1,35 @@
# Drivers Bot
# CarPass
Telegram bot + Telegram Mini App для учета автомобилей, заправок, сервиса, жидкостей, напоминаний и стоимости владения.
CarPass — цифровой паспорт автомобиля в Telegram. Он помогает владельцу видеть реальную стоимость владения, вести сервисную историю и аккуратно собирать данные, которые повышают доверие к автомобилю.
## Состав
## Для автовладельца
- `app/` - FastAPI API, статика Mini App, бизнес-логика и Alembic.
- `bot/` - aiogram 3 бот, который открывает Mini App и работает с API через внутренний токен.
- `web/` - статический frontend Telegram WebApp.
- `alembic/` - миграции PostgreSQL.
- `tests/` - базовые security/API тесты.
- Все автомобили в одном гараже.
- Заправки, ТО, ремонт, страховка, налоги, штрафы, парковки, мойки и другие расходы.
- Стоимость владения за период, стоимость 1 км и прогноз ближайших расходов.
- Расход топлива по корректным полным бакам.
- Мягкий прогресс заполнения профиля авто: VIN, госномер, пробег, масло, параметры обслуживания.
- Бейджи качества истории без игровых очков и токсичных рейтингов.
- Напоминания о ТО, страховке и важных событиях.
- OCR чеков: фото или файл распознается, затем пользователь проверяет данные перед сохранением.
## Production Mini App
## Для СТО
Для production Mini App должен открываться только по публичному HTTPS-домену. Для текущего проекта:
- Регистрация автосервиса через Mini App.
- Заявка на проверку и статус модерации.
- Публичная карточка СТО после подтверждения.
- Отзывы, рейтинг и ответы сервиса.
- Запрос доступа к конкретному автомобилю только с подтверждением владельца.
- Добавление визитов, работ и рекомендаций с аудитом действий.
```text
https://drivers.smartsoltech.kr
```
## Безопасность данных
В BotFather нужно выполнить:
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
```text
/setdomain
@your_bot_username
drivers.smartsoltech.kr
```
## Telegram Mini App
Важно:
Mini App открывается через кнопку внутри Telegram-бота. Так Telegram передает защищенную авторизацию, а гараж привязывается к аккаунту пользователя. Если страницу открыть напрямую в браузере, CarPass покажет понятное приглашение открыть приложение через Telegram.
- в BotFather указывается домен без `https://`;
- `WEBAPP_URL` или `PUBLIC_WEBAPP_URL` в `.env` должны быть `https://drivers.smartsoltech.kr`;
- нельзя использовать `localhost`, `127.0.0.1`, внутренний IP или `http://` для Telegram Mini App в production;
- если появляется `Bot domain invalid`, сначала проверь `/setdomain` и значение `WEBAPP_URL` в контейнере бота.
## Почему это полезно
## Production .env
```dotenv
POSTGRES_DB=drivers
POSTGRES_USER=drivers
POSTGRES_PASSWORD=change-this-db-password
POSTGRES_PORT=5433
DATABASE_URL=postgresql+asyncpg://drivers:change-this-db-password@db:5432/drivers
BOT_TOKEN=123456:telegram-token
BOT_USERNAME=your_bot_username
WEBAPP_URL=https://drivers.smartsoltech.kr
PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr
API_BASE_URL=http://api:8000
CORS_ORIGINS=https://drivers.smartsoltech.kr,https://t.me
INTERNAL_API_TOKEN=change-this-long-random-token
APP_ENV=production
ALLOW_DEV_AUTH=false
VAPID_PUBLIC_KEY=
```
`BOT_TOKEN`, `DATABASE_URL`, `WEBAPP_URL`, `API_BASE_URL`, `CORS_ORIGINS`, `INTERNAL_API_TOKEN` читаются только из env. Секреты не хранятся в коде.
## Nginx
Пример reverse proxy:
```nginx
server {
listen 80;
server_name drivers.smartsoltech.kr;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name drivers.smartsoltech.kr;
ssl_certificate /etc/letsencrypt/live/drivers.smartsoltech.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/drivers.smartsoltech.kr/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
```
## Запуск
```bash
cp .env.example .env
docker compose up -d --build
```
API локально: `http://localhost:8000`.
Локальные проверки:
```bash
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest -q
.venv/bin/ruff check app bot tests
docker compose build
docker compose up -d db api
curl http://127.0.0.1:8000/health
docker compose down
```
## Авторизация API
Пользовательские endpoint-ы требуют Telegram WebApp `initData` в заголовке:
```http
X-Telegram-Init-Data: query_id=...&user=...&auth_date=...&hash=...
```
Backend проверяет подпись Telegram, создает/обновляет пользователя и разрешает операции только с объектами владельца. Бот использует `INTERNAL_API_TOKEN` и `X-Telegram-User-Id`.
Публичное `/api/users` закрыто внутренним токеном. Для Mini App создание пользователя выполняется через `/api/users/webapp-auth`.
## Основные endpoint-ы
- `GET /api/users/me`
- `GET /api/me`
- `GET /api/my/vehicles`
- `POST /api/my/vehicles`
- `PATCH /api/my/vehicles/{vehicle_id}`
- `GET /api/my/vehicles/{vehicle_id}/service-history`
- `POST /api/my/vehicles/{vehicle_id}/grant-service-access`
- `POST /api/cars`, `GET /api/cars`, `GET/PATCH/DELETE /api/cars/{id}`
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel?limit=50&offset=0`
- `PATCH /api/fuel/{id}`, `DELETE /api/fuel/{id}`
- `POST /api/service`, `GET /api/cars/{car_id}/service?limit=50&offset=0`
- `PATCH /api/service/{id}`, `DELETE /api/service/{id}`
- `GET /api/cars/{car_id}/stats`
- `GET /api/users/{user_id}/reminders?limit=50&offset=0`
- `POST /api/service-centers`
- `GET /api/service-centers/my`
- `POST /api/service-centers/{id}/verification`
- `POST /api/service-centers/{id}/employees/invite`
- `GET /api/service-centers/{id}/visits`
- `POST /api/service-centers/{id}/visits`
- `POST /api/service-visits/{id}/work-items`
- `POST /api/service-visits/{id}/complete`
- `POST /api/service-visits/{id}/confirm`
- `POST /api/service-visits/{id}/dispute`
- `POST /api/service-visits/{id}/vehicle-change-requests`
- `POST /api/vehicle-change-requests/{id}/approve`
- `POST /api/vehicle-change-requests/{id}/reject`
- `GET /api/admin/service-centers/pending`
- `POST /api/admin/service-centers/{id}/verify`
- `POST /api/admin/service-centers/{id}/reject`
- `POST /api/admin/service-centers/{id}/suspend`
- `GET /api/admin/audit-log`
- `GET /api/admin/disputes`
- `POST /api/ocr/parse-text-receipt`
- `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`, а не выдуманную цифру.
## OCR
Настоящий OCR по фото/PDF пока не подключен. Endpoint `POST /api/ocr/parse-text-receipt` честно разбирает только текстовый чек. Старый `/api/ocr/fuel-receipt` оставлен как deprecated-совместимость.
Новая OCR-архитектура использует заменяемый provider:
- `OCRProvider`
- `StubOCRProvider`
- будущие `TesseractOCRProvider`, cloud OCR или VLM provider
OCR возвращает кандидаты и не меняет данные автомобиля напрямую:
```json
{
"recognized_text": "VIN KMHCT41BAHU123456",
"candidates": [
{"type": "vin", "value": "KMHCT41BAHU123456", "confidence": 0.84}
]
}
```
## Platform Roadmap
Проектное расширение в сторону владельцев авто и СТО описано в `PROJECT_PLAN.md`. В код добавлен первый совместимый слой платформы:
- расширенный `ServiceCenter`;
- верификация СТО;
- сотрудники СТО;
- `VehicleAccess`;
- `ServiceVisit`;
- `ServiceWorkItem`;
- `VehicleDataChangeRequest`;
- `AuditLog`;
- нормализация VIN и госномера.
СТО не получает персональные данные владельца по VIN/номеру. Поиск возвращает только минимальную маскированную карточку и пишет действие в аудит. Критичные изменения автомобиля проходят через запрос подтверждения владельцем.
Для владельца CarPass превращает хаотичные чеки и заметки в понятную картину расходов и обслуживания. Для сервиса это аккуратный канал взаимодействия с клиентом, подтвержденная история работ и доверие без лишнего доступа к персональным данным.

View File

@@ -1,10 +1,10 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.db.base import Base
from app.models import car, expense, gamification, push, user # noqa: F401

View File

@@ -8,6 +8,7 @@ Create Date: 2026-05-11
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605110001"

View File

@@ -8,6 +8,7 @@ Create Date: 2026-05-11
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605110002"

View File

@@ -8,6 +8,7 @@ Create Date: 2026-05-11
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605110003"

View File

@@ -8,6 +8,7 @@ Create Date: 2026-05-12
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605120001"

View File

@@ -8,6 +8,7 @@ Create Date: 2026-05-12
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605120002"

View File

@@ -8,6 +8,7 @@ Create Date: 2026-05-12
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605120003"

View File

@@ -8,6 +8,7 @@ Create Date: 2026-05-12
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605120005"

View File

@@ -8,9 +8,10 @@ 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
from alembic import op
revision: str = "202605120006"
down_revision: str | None = "202605120005"
branch_labels: str | Sequence[str] | None = None

View File

@@ -0,0 +1,217 @@
"""product expenses and service reviews
Revision ID: 202605140001
Revises: 202605120006
Create Date: 2026-05-14 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "202605140001"
down_revision: str | None = "202605120006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
expense_category = postgresql.ENUM(
"insurance",
"tax",
"fine",
"parking",
"car_wash",
"toll",
"tires",
"wheels",
"battery",
"parts",
"repair",
"maintenance",
"diagnostics",
"towing",
"loan_payment",
"loan_interest",
"state_fee",
"registration",
"inspection",
"other",
name="expensecategory",
create_type=False,
)
def upgrade() -> None:
bind = op.get_bind()
expense_category.create(bind, checkfirst=True)
op.add_column("cars", sa.Column("currency", sa.String(length=3), server_default="RUB", nullable=False))
op.add_column(
"cars",
sa.Column("include_depreciation", sa.Boolean(), server_default=sa.text("false"), nullable=False),
)
op.add_column("service_centers", sa.Column("description", sa.Text(), nullable=True))
op.add_column("service_centers", sa.Column("specializations", sa.JSON(), nullable=True))
op.add_column("service_centers", sa.Column("working_hours", sa.String(length=240), nullable=True))
op.add_column("service_centers", sa.Column("facade_photo_url", sa.String(length=500), nullable=True))
op.add_column("service_centers", sa.Column("document_photo_urls", sa.JSON(), nullable=True))
op.add_column("service_centers", sa.Column("additional_photo_urls", sa.JSON(), nullable=True))
op.add_column("service_centers", sa.Column("contact_person", sa.String(length=160), nullable=True))
op.add_column("service_centers", sa.Column("rating_avg", sa.Numeric(3, 2), nullable=True))
op.add_column(
"service_centers",
sa.Column("reviews_count", sa.Integer(), server_default="0", nullable=False),
)
op.add_column(
"car_service_links",
sa.Column("access_level", sa.String(length=32), server_default="basic", nullable=False),
)
op.add_column(
"car_service_links",
sa.Column("status", sa.String(length=32), server_default="pending", nullable=False),
)
op.add_column("car_service_links", sa.Column("requested_by_user_id", sa.Integer(), nullable=True))
op.add_column("car_service_links", sa.Column("approved_by_user_id", sa.Integer(), nullable=True))
op.add_column("car_service_links", sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("car_service_links", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(
"fk_car_service_links_requested_by_user_id_users",
"car_service_links",
"users",
["requested_by_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_car_service_links_approved_by_user_id_users",
"car_service_links",
"users",
["approved_by_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_car_service_links_access_level", "car_service_links", ["access_level"])
op.create_index("ix_car_service_links_status", "car_service_links", ["status"])
op.create_index("ix_car_service_links_requested_by_user_id", "car_service_links", ["requested_by_user_id"])
op.create_index("ix_car_service_links_approved_by_user_id", "car_service_links", ["approved_by_user_id"])
op.create_table(
"expense_entries",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("car_id", sa.Integer(), nullable=False),
sa.Column("entry_date", sa.Date(), nullable=False),
sa.Column("category", expense_category, nullable=False),
sa.Column("title", sa.String(length=180), nullable=False),
sa.Column("vendor", sa.String(length=160), nullable=True),
sa.Column("total_cost", sa.Numeric(12, 2), nullable=False),
sa.Column("currency", sa.String(length=3), server_default="RUB", nullable=False),
sa.Column("odometer", sa.Integer(), nullable=True),
sa.Column("period_start", sa.Date(), nullable=True),
sa.Column("period_end", sa.Date(), nullable=True),
sa.Column("period_months", sa.Integer(), nullable=True),
sa.Column("is_recurring", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_expense_entries_car_id", "expense_entries", ["car_id"])
op.create_index("ix_expense_entries_entry_date", "expense_entries", ["entry_date"])
op.create_index(
"ix_expense_entries_car_category_date",
"expense_entries",
["car_id", "category", "entry_date"],
)
op.create_table(
"service_center_reviews",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_center_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("rating", sa.Integer(), nullable=False),
sa.Column("text", sa.Text(), nullable=True),
sa.Column("photo_urls", sa.JSON(), nullable=True),
sa.Column("status", sa.String(length=24), server_default="published", nullable=False),
sa.Column("service_response", sa.Text(), nullable=True),
sa.Column("service_responded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_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="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),
)
op.create_index("ix_service_center_reviews_created_at", "service_center_reviews", ["created_at"])
op.create_index("ix_service_center_reviews_service_center_id", "service_center_reviews", ["service_center_id"])
op.create_index("ix_service_center_reviews_status", "service_center_reviews", ["status"])
op.create_index("ix_service_center_reviews_user_id", "service_center_reviews", ["user_id"])
op.create_table(
"service_center_review_comments",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("review_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=24), server_default="published", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["review_id"], ["service_center_reviews.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_service_center_review_comments_created_at", "service_center_review_comments", ["created_at"])
op.create_index("ix_service_center_review_comments_review_id", "service_center_review_comments", ["review_id"])
op.create_index("ix_service_center_review_comments_status", "service_center_review_comments", ["status"])
op.create_index("ix_service_center_review_comments_user_id", "service_center_review_comments", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_service_center_review_comments_user_id", table_name="service_center_review_comments")
op.drop_index("ix_service_center_review_comments_status", table_name="service_center_review_comments")
op.drop_index("ix_service_center_review_comments_review_id", table_name="service_center_review_comments")
op.drop_index("ix_service_center_review_comments_created_at", table_name="service_center_review_comments")
op.drop_table("service_center_review_comments")
op.drop_index("ix_service_center_reviews_user_id", table_name="service_center_reviews")
op.drop_index("ix_service_center_reviews_status", table_name="service_center_reviews")
op.drop_index("ix_service_center_reviews_service_center_id", table_name="service_center_reviews")
op.drop_index("ix_service_center_reviews_created_at", table_name="service_center_reviews")
op.drop_table("service_center_reviews")
op.drop_index("ix_expense_entries_car_category_date", table_name="expense_entries")
op.drop_index("ix_expense_entries_entry_date", table_name="expense_entries")
op.drop_index("ix_expense_entries_car_id", table_name="expense_entries")
op.drop_table("expense_entries")
op.drop_index("ix_car_service_links_approved_by_user_id", table_name="car_service_links")
op.drop_index("ix_car_service_links_requested_by_user_id", table_name="car_service_links")
op.drop_index("ix_car_service_links_status", table_name="car_service_links")
op.drop_index("ix_car_service_links_access_level", table_name="car_service_links")
op.drop_constraint("fk_car_service_links_approved_by_user_id_users", "car_service_links", type_="foreignkey")
op.drop_constraint("fk_car_service_links_requested_by_user_id_users", "car_service_links", type_="foreignkey")
op.drop_column("car_service_links", "revoked_at")
op.drop_column("car_service_links", "approved_at")
op.drop_column("car_service_links", "approved_by_user_id")
op.drop_column("car_service_links", "requested_by_user_id")
op.drop_column("car_service_links", "status")
op.drop_column("car_service_links", "access_level")
op.drop_column("service_centers", "reviews_count")
op.drop_column("service_centers", "rating_avg")
op.drop_column("service_centers", "contact_person")
op.drop_column("service_centers", "additional_photo_urls")
op.drop_column("service_centers", "document_photo_urls")
op.drop_column("service_centers", "facade_photo_url")
op.drop_column("service_centers", "working_hours")
op.drop_column("service_centers", "specializations")
op.drop_column("service_centers", "description")
op.drop_column("cars", "include_depreciation")
op.drop_column("cars", "currency")
bind = op.get_bind()
expense_category.drop(bind, checkfirst=True)

View File

@@ -41,9 +41,9 @@ async def verify_service_center(
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "verified"
center.verification_status = "approved"
center.verified_at = datetime.now(UTC)
await mark_latest_verification(session, center.id, "verified", current_user.id)
await mark_latest_verification(session, center.id, "approved", current_user.id)
await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)

View File

@@ -9,9 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.car import Car
from app.models.expense import FuelEntry, ServiceEntry
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
from app.models.user import User
from app.schemas.expense import (
ExpenseEntryCreate,
ExpenseEntryRead,
ExpenseEntryUpdate,
FuelEntryCreate,
FuelEntryRead,
FuelEntryUpdate,
@@ -36,8 +39,8 @@ async def ensure_owned_car(session: AsyncSession, car_id: int, user: User) -> Ca
async def ensure_entry_owner(
session: AsyncSession, entry: FuelEntry | ServiceEntry | None, user: User
) -> FuelEntry | ServiceEntry:
session: AsyncSession, entry: FuelEntry | ServiceEntry | ExpenseEntry | None, user: User
) -> FuelEntry | ServiceEntry | ExpenseEntry:
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
await ensure_owned_car(session, entry.car_id, user)
@@ -60,9 +63,19 @@ async def refresh_current_odometer(session: AsyncSession, car_id: int) -> None:
.order_by(ServiceEntry.odometer.desc())
.limit(1)
)
expense_result = await session.execute(
select(ExpenseEntry.odometer)
.where(ExpenseEntry.car_id == car_id, ExpenseEntry.odometer.is_not(None))
.order_by(ExpenseEntry.odometer.desc())
.limit(1)
)
values = [
value
for value in (fuel_result.scalar_one_or_none(), service_result.scalar_one_or_none())
for value in (
fuel_result.scalar_one_or_none(),
service_result.scalar_one_or_none(),
expense_result.scalar_one_or_none(),
)
if value is not None
]
car.current_odometer = max(values) if values else None
@@ -212,6 +225,79 @@ async def delete_service_entry(
await session.commit()
@router.post("/expenses", response_model=ExpenseEntryRead, status_code=status.HTTP_201_CREATED)
async def create_expense_entry(
payload: ExpenseEntryCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ExpenseEntry:
car = await ensure_owned_car(session, payload.car_id, current_user)
entry = ExpenseEntry(**payload.model_dump())
session.add(entry)
if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer):
car.current_odometer = payload.odometer
await session.commit()
await session.refresh(entry)
return entry
@router.get("/cars/{car_id}/expenses", response_model=list[ExpenseEntryRead])
async def list_expense_entries(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
category: str | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ExpenseEntry]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
stmt = select(ExpenseEntry).where(ExpenseEntry.car_id == car_id)
if date_from:
stmt = stmt.where(ExpenseEntry.entry_date >= date_from)
if date_to:
stmt = stmt.where(ExpenseEntry.entry_date <= date_to)
if category:
stmt = stmt.where(ExpenseEntry.category == category)
result = await session.execute(
stmt.order_by(ExpenseEntry.entry_date.desc(), ExpenseEntry.id.desc()).limit(limit).offset(offset)
)
return list(result.scalars())
@router.patch("/expenses/{entry_id}", response_model=ExpenseEntryRead)
async def update_expense_entry(
entry_id: int,
payload: ExpenseEntryUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ExpenseEntry:
entry = await ensure_entry_owner(session, await session.get(ExpenseEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id)
await session.commit()
await session.refresh(entry)
return entry
@router.delete("/expenses/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_expense_entry(
entry_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
entry = await ensure_entry_owner(session, await session.get(ExpenseEntry, entry_id), current_user)
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await session.commit()
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
async def car_stats(
car_id: int,

View File

@@ -29,6 +29,7 @@ class OCRCandidateRead(BaseModel):
class OCRResultRead(BaseModel):
recognized_text: str
candidates: list[OCRCandidateRead]
provider: str = "heuristic"
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
@@ -39,16 +40,23 @@ async def parse_text_receipt(
content = await file.read()
content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf":
result = await get_ocr_provider().recognize(content, file.filename)
if not result.recognized_text:
return ReceiptSuggestion(
confidence=0,
message="OCR по фото/PDF пока не подключен. Загрузите текстовый чек или заполните поля вручную.",
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
)
return parse_receipt_text(result.recognized_text)
text = " ".join(
[
file.filename or "",
content.decode("utf-8", errors="ignore"),
]
)
return parse_receipt_text(text)
def parse_receipt_text(text: str) -> ReceiptSuggestion:
normalized = text.replace("\xa0", " ").replace(",", ".")
compact = re.sub(r"\s+", " ", normalized).strip()
numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)]
@@ -102,6 +110,7 @@ async def recognize_license_plate(
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
provider=result.provider,
)
@@ -114,6 +123,7 @@ async def recognize_vin(
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
provider=result.provider,
)
@@ -126,6 +136,7 @@ async def recognize_service_document(
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
provider=result.provider,
)

View File

@@ -1,5 +1,7 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import (
@@ -14,6 +16,8 @@ from app.models.car import (
Car,
CarServiceLink,
ServiceCenter,
ServiceCenterReview,
ServiceCenterReviewComment,
ServiceCenterVerification,
ServiceEmployee,
ServiceInboxMessage,
@@ -23,8 +27,14 @@ from app.models.user import User
from app.schemas.service_center import (
CarServiceLinkCreate,
CarServiceLinkRead,
ServiceCenterAccessRequest,
ServiceCenterCreate,
ServiceCenterPublicRead,
ServiceCenterRead,
ServiceCenterReviewCommentCreate,
ServiceCenterReviewCommentRead,
ServiceCenterReviewCreate,
ServiceCenterReviewRead,
ServiceCenterVerificationCreate,
ServiceCenterVerificationRead,
ServiceEmployeeInvite,
@@ -40,6 +50,8 @@ from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
async def create_service_center(
payload: ServiceCenterCreate,
@@ -57,6 +69,13 @@ async def create_service_center(
contact_phone=payload.contact_phone or payload.phone,
telegram_chat_id=payload.telegram_chat_id,
business_registration_number=payload.business_registration_number,
description=payload.description,
specializations=payload.specializations,
working_hours=payload.working_hours,
facade_photo_url=payload.facade_photo_url,
document_photo_urls=payload.document_photo_urls,
additional_photo_urls=payload.additional_photo_urls,
contact_person=payload.contact_person,
owner_user_id=current_user.id,
verification_status="pending",
)
@@ -89,6 +108,51 @@ async def my_service_centers(
return list(result.scalars())
@router.get("/public", response_model=list[ServiceCenterPublicRead])
async def public_service_centers(
city: str | None = None,
specialization: str | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
limit = min(max(limit, 1), 200)
stmt = select(ServiceCenter).where(ServiceCenter.verification_status.in_(APPROVED_SERVICE_STATUSES))
if city:
stmt = stmt.where(ServiceCenter.city.ilike(f"%{city}%"))
result = await session.execute(
stmt.order_by(ServiceCenter.rating_avg.desc().nullslast(), ServiceCenter.display_name.asc())
.limit(limit)
.offset(max(offset, 0))
)
centers = list(result.scalars())
if specialization:
needle = specialization.lower()
centers = [
center
for center in centers
if any(needle in item.lower() for item in (center.specializations or []))
]
return centers
@router.get("/{service_center_id}", response_model=ServiceCenterPublicRead)
async def get_public_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
if center.verification_status not in APPROVED_SERVICE_STATUSES:
is_employee = await service_employee_or_none(session, service_center_id, current_user)
if not is_employee and center.owner_user_id != current_user.id:
raise HTTPException(status_code=404, detail="Service center not found")
return center
@router.get("", response_model=list[ServiceCenterRead])
async def list_service_centers(
session: AsyncSession = Depends(get_session),
@@ -173,6 +237,45 @@ async def service_center_visits(
return list(result.scalars())
async def service_employee_or_none(
session: AsyncSession, service_center_id: int, user: User
) -> ServiceEmployee | None:
result = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.user_id == user.id,
ServiceEmployee.status == "active",
)
)
return result.scalar_one_or_none()
async def ensure_service_center_approved(session: AsyncSession, service_center_id: int) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
if center.verification_status not in APPROVED_SERVICE_STATUSES:
raise HTTPException(status_code=403, detail="Service center is awaiting approval")
return center
async def ensure_center_vehicle_access(
session: AsyncSession, service_center_id: int, vehicle: Car, user: User
) -> None:
if vehicle.owner_id == user.id:
return
result = await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == vehicle.id,
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
)
)
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
@router.post("/{service_center_id}/visits", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
async def create_visit(
service_center_id: int,
@@ -184,9 +287,8 @@ async def create_visit(
vehicle = await session.get(Car, payload.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
center = await session.get(ServiceCenter, service_center_id)
if center and center.verification_status not in {"verified", "pending"}:
raise HTTPException(status_code=403, detail="Service center is not allowed to create visits")
await ensure_service_center_approved(session, service_center_id)
await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user)
visit = ServiceVisit(
service_center_id=service_center_id,
vehicle_id=payload.vehicle_id,
@@ -213,6 +315,7 @@ async def request_vehicle_access(
current_user: User = Depends(get_current_telegram_user),
) -> VehicleSearchResult:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
stmt = select(Car)
if payload.vin:
stmt = stmt.where(Car.vin_normalized == payload.vin)
@@ -231,6 +334,17 @@ async def request_vehicle_access(
target_id=vehicle.id if vehicle else None,
metadata={"service_center_id": service_center_id, "found": bool(vehicle)},
)
link = None
if vehicle is not None:
link = await upsert_service_link(
session,
car_id=vehicle.id,
service_center_id=service_center_id,
requested_by_user_id=current_user.id,
access_level="basic",
external_vehicle_ref=None,
status_value="pending",
)
await session.commit()
if vehicle is None:
return VehicleSearchResult(access_status="not_found")
@@ -241,10 +355,242 @@ async def request_vehicle_access(
year=vehicle.year,
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
masked_vin=mask_vin(vehicle.vin_normalized or vehicle.vin),
access_status="request_logged",
access_status="pending_owner_confirmation" if link else "request_logged",
)
@router.post("/{service_center_id}/vehicle-links/request", response_model=CarServiceLinkRead)
async def request_vehicle_link(
service_center_id: int,
payload: ServiceCenterAccessRequest,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
vehicle = await session.get(Car, payload.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
link = await upsert_service_link(
session,
car_id=payload.car_id,
service_center_id=service_center_id,
requested_by_user_id=current_user.id,
access_level=payload.access_level,
external_vehicle_ref=payload.external_vehicle_ref,
status_value="pending",
)
await log_audit(
session,
actor=current_user,
action="car_service_link.request",
target_type="car_service_link",
target_id=link.id,
metadata={"car_id": payload.car_id, "service_center_id": service_center_id},
)
await session.commit()
await session.refresh(link)
return link
@router.post("/links/{link_id}/approve", response_model=CarServiceLinkRead)
async def approve_vehicle_link(
link_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
link = await session.get(CarServiceLink, link_id)
if link is None:
raise HTTPException(status_code=404, detail="Vehicle link not found")
vehicle = await session.get(Car, link.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await ensure_service_center_approved(session, link.service_center_id)
link.status = "approved"
link.is_active = True
link.approved_by_user_id = current_user.id
link.approved_at = datetime.now(UTC)
link.revoked_at = None
await log_audit(session, actor=current_user, action="car_service_link.approve", target_type="car_service_link", target_id=link.id)
await session.commit()
await session.refresh(link)
return link
@router.post("/links/{link_id}/revoke", response_model=CarServiceLinkRead)
async def revoke_vehicle_link(
link_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
link = await session.get(CarServiceLink, link_id)
if link is None:
raise HTTPException(status_code=404, detail="Vehicle link not found")
vehicle = await session.get(Car, link.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
link.status = "revoked"
link.is_active = False
link.revoked_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="car_service_link.revoke", target_type="car_service_link", target_id=link.id)
await session.commit()
await session.refresh(link)
return link
async def upsert_service_link(
session: AsyncSession,
*,
car_id: int,
service_center_id: int,
requested_by_user_id: int | None,
access_level: str,
external_vehicle_ref: str | None,
status_value: str,
) -> CarServiceLink:
result = await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == car_id,
CarServiceLink.service_center_id == service_center_id,
)
)
link = result.scalar_one_or_none()
if link is None:
link = CarServiceLink(
car_id=car_id,
service_center_id=service_center_id,
requested_by_user_id=requested_by_user_id,
access_level=access_level,
external_vehicle_ref=external_vehicle_ref,
status=status_value,
is_active=status_value == "approved",
)
session.add(link)
await session.flush()
else:
link.requested_by_user_id = requested_by_user_id
link.access_level = access_level
link.external_vehicle_ref = external_vehicle_ref or link.external_vehicle_ref
link.status = status_value
link.is_active = status_value == "approved"
return link
@router.get("/{service_center_id}/reviews", response_model=list[ServiceCenterReviewRead])
async def service_center_reviews(
service_center_id: int,
sort: str = "new",
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenterReview]:
await get_public_service_center(service_center_id, session, current_user)
limit = min(max(limit, 1), 200)
stmt = select(ServiceCenterReview).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.status == "published",
)
if sort == "high":
stmt = stmt.order_by(ServiceCenterReview.rating.desc(), ServiceCenterReview.created_at.desc())
elif sort == "low":
stmt = stmt.order_by(ServiceCenterReview.rating.asc(), ServiceCenterReview.created_at.desc())
else:
stmt = stmt.order_by(ServiceCenterReview.created_at.desc())
result = await session.execute(stmt.limit(limit).offset(max(offset, 0)))
return list(result.scalars())
@router.post("/{service_center_id}/reviews", response_model=ServiceCenterReviewRead, status_code=status.HTTP_201_CREATED)
async def create_service_center_review(
service_center_id: int,
payload: ServiceCenterReviewCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
await ensure_service_center_approved(session, service_center_id)
result = await session.execute(
select(ServiceCenterReview).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.user_id == current_user.id,
)
)
review = result.scalar_one_or_none()
if review is None:
review = ServiceCenterReview(
service_center_id=service_center_id,
user_id=current_user.id,
**payload.model_dump(),
)
session.add(review)
else:
review.rating = payload.rating
review.text = payload.text
review.photo_urls = payload.photo_urls
review.status = "published"
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
await session.flush()
await refresh_service_rating(session, service_center_id)
await session.commit()
await session.refresh(review)
return review
@router.post("/reviews/{review_id}/comments", response_model=ServiceCenterReviewCommentRead, status_code=status.HTTP_201_CREATED)
async def create_review_comment(
review_id: int,
payload: ServiceCenterReviewCommentCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReviewComment:
review = await session.get(ServiceCenterReview, review_id)
if review is None or review.status != "published":
raise HTTPException(status_code=404, detail="Review not found")
comment = ServiceCenterReviewComment(review_id=review_id, user_id=current_user.id, text=payload.text)
session.add(comment)
await log_audit(session, actor=current_user, action="service_review.comment", target_type="service_review", target_id=review_id)
await session.commit()
await session.refresh(comment)
return comment
@router.post("/reviews/{review_id}/respond", response_model=ServiceCenterReviewRead)
async def respond_to_review(
review_id: int,
payload: ServiceCenterReviewCommentCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
review = await session.get(ServiceCenterReview, review_id)
if review is None:
raise HTTPException(status_code=404, detail="Review not found")
await ensure_service_employee(session, review.service_center_id, current_user, {"owner", "manager"})
review.service_response = payload.text
review.service_responded_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_review.respond", target_type="service_review", target_id=review_id)
await session.commit()
await session.refresh(review)
return review
async def refresh_service_rating(session: AsyncSession, service_center_id: int) -> None:
result = await session.execute(
select(func.avg(ServiceCenterReview.rating), func.count(ServiceCenterReview.id)).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.status == "published",
)
)
avg_rating, count = result.one()
center = await session.get(ServiceCenter, service_center_id)
if center is not None:
center.rating_avg = round(avg_rating, 2) if avg_rating is not None else None
center.reviews_count = int(count or 0)
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
async def link_car_to_service(
payload: CarServiceLinkCreate,
@@ -256,7 +602,7 @@ async def link_car_to_service(
raise HTTPException(status_code=404, detail="Car not found")
if await session.get(ServiceCenter, payload.service_center_id) is None:
raise HTTPException(status_code=404, detail="Service center not found")
link = CarServiceLink(**payload.model_dump())
link = CarServiceLink(**payload.model_dump(), status="approved", approved_at=datetime.now(UTC))
session.add(link)
await session.commit()
await session.refresh(link)

View File

@@ -17,6 +17,10 @@ class Settings(BaseSettings):
internal_api_token: str = ""
vapid_public_key: str = ""
allow_dev_auth: bool = False
ocr_provider: str = "tesseract"
ocr_languages: str = "eng+rus+kor"
llm_base_url: str = ""
llm_model: str = ""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

View File

@@ -3,6 +3,7 @@ from decimal import Decimal
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
ForeignKey,
@@ -47,6 +48,8 @@ class Car(Base):
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
purchase_date: Mapped[date | None] = mapped_column(Date)
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
include_depreciation: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
current_odometer: Mapped[int | None]
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
@@ -56,6 +59,7 @@ class Car(Base):
owner = relationship("User", back_populates="cars")
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan")
expense_entries = relationship("ExpenseEntry", back_populates="car", cascade="all, delete-orphan")
service_links = relationship("CarServiceLink", back_populates="car", cascade="all, delete-orphan")
@@ -115,6 +119,15 @@ class ServiceCenter(Base):
phone: Mapped[str | None] = mapped_column(String(40))
contact_phone: Mapped[str | None] = mapped_column(String(40))
address: Mapped[str | None] = mapped_column(String(240))
description: Mapped[str | None] = mapped_column(Text)
specializations: Mapped[list | None] = mapped_column(JSON)
working_hours: Mapped[str | None] = mapped_column(String(240))
facade_photo_url: Mapped[str | None] = mapped_column(String(500))
document_photo_urls: Mapped[list | None] = mapped_column(JSON)
additional_photo_urls: Mapped[list | None] = mapped_column(JSON)
contact_person: Mapped[str | None] = mapped_column(String(160))
rating_avg: Mapped[Decimal | None] = mapped_column(Numeric(3, 2))
reviews_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
business_registration_number: Mapped[str | None] = mapped_column(String(80))
verification_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
@@ -126,6 +139,7 @@ class ServiceCenter(Base):
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
employees = relationship("ServiceEmployee", back_populates="service_center", cascade="all, delete-orphan")
visits = relationship("ServiceVisit", back_populates="service_center")
reviews = relationship("ServiceCenterReview", back_populates="service_center", cascade="all, delete-orphan")
class CarServiceLink(Base):
@@ -136,6 +150,12 @@ class CarServiceLink(Base):
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
external_vehicle_ref: Mapped[str | None] = mapped_column(String(120), index=True)
access_level: Mapped[str] = mapped_column(String(32), default="basic", server_default="basic", index=True)
status: Mapped[str] = mapped_column(String(32), default="pending", server_default="pending", index=True)
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -243,6 +263,41 @@ class ServiceWorkItem(Base):
visit = relationship("ServiceVisit", back_populates="work_items")
class ServiceCenterReview(Base):
__tablename__ = "service_center_reviews"
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),)
id: Mapped[int] = mapped_column(primary_key=True)
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
rating: Mapped[int] = mapped_column(Integer)
text: Mapped[str | None] = mapped_column(Text)
photo_urls: Mapped[list | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(24), default="published", server_default="published", index=True)
service_response: Mapped[str | None] = mapped_column(Text)
service_responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
service_center = relationship("ServiceCenter", back_populates="reviews")
comments = relationship("ServiceCenterReviewComment", back_populates="review", cascade="all, delete-orphan")
class ServiceCenterReviewComment(Base):
__tablename__ = "service_center_review_comments"
id: Mapped[int] = mapped_column(primary_key=True)
review_id: Mapped[int] = mapped_column(ForeignKey("service_center_reviews.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
text: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(24), default="published", server_default="published", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
review = relationship("ServiceCenterReview", back_populates="comments")
class VehicleDataChangeRequest(Base):
__tablename__ = "vehicle_data_change_requests"

View File

@@ -2,7 +2,7 @@ import enum
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
@@ -19,6 +19,29 @@ class ServiceType(str, enum.Enum):
other = "other"
class ExpenseCategory(str, enum.Enum):
insurance = "insurance"
tax = "tax"
fine = "fine"
parking = "parking"
car_wash = "car_wash"
toll = "toll"
tires = "tires"
wheels = "wheels"
battery = "battery"
parts = "parts"
repair = "repair"
maintenance = "maintenance"
diagnostics = "diagnostics"
towing = "towing"
loan_payment = "loan_payment"
loan_interest = "loan_interest"
state_fee = "state_fee"
registration = "registration"
inspection = "inspection"
other = "other"
class FuelEntry(Base):
__tablename__ = "fuel_entries"
@@ -56,3 +79,25 @@ class ServiceEntry(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
car = relationship("Car", back_populates="service_entries")
class ExpenseEntry(Base):
__tablename__ = "expense_entries"
id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
entry_date: Mapped[date] = mapped_column(Date, index=True)
category: Mapped[ExpenseCategory] = mapped_column(Enum(ExpenseCategory), index=True)
title: Mapped[str] = mapped_column(String(180))
vendor: Mapped[str | None] = mapped_column(String(160))
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2))
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
odometer: Mapped[int | None]
period_start: Mapped[date | None] = mapped_column(Date)
period_end: Mapped[date | None] = mapped_column(Date)
period_months: Mapped[int | None]
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
car = relationship("Car", back_populates="expense_entries")

View File

@@ -25,6 +25,8 @@ class CarBase(BaseModel):
tire_pressure_rear_bar: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
currency: str = "RUB"
include_depreciation: bool = False
current_odometer: int | None = None
@@ -53,6 +55,8 @@ class CarUpdate(BaseModel):
tire_pressure_rear_bar: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
currency: str | None = None
include_depreciation: bool | None = None
current_odometer: int | None = None

View File

@@ -1,9 +1,9 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.models.expense import ServiceType
from app.models.expense import ExpenseCategory, ServiceType
class FuelEntryBase(BaseModel):
@@ -87,6 +87,62 @@ class ServiceEntryRead(ServiceEntryBase):
model_config = ConfigDict(from_attributes=True)
class ExpenseEntryBase(BaseModel):
entry_date: date
category: ExpenseCategory
title: str
vendor: str | None = None
total_cost: Decimal
currency: str = "RUB"
odometer: int | None = None
period_start: date | None = None
period_end: date | None = None
period_months: int | None = None
is_recurring: bool = False
notes: str | None = None
@model_validator(mode="after")
def validate_period(self) -> "ExpenseEntryBase":
if self.period_months is not None and self.period_months < 1:
raise ValueError("period_months must be positive")
if self.period_start and self.period_end and self.period_end < self.period_start:
raise ValueError("period_end must be after period_start")
return self
class ExpenseEntryCreate(ExpenseEntryBase):
car_id: int
class ExpenseEntryUpdate(BaseModel):
entry_date: date | None = None
category: ExpenseCategory | None = None
title: str | None = None
vendor: str | None = None
total_cost: Decimal | None = None
currency: str | None = None
odometer: int | None = None
period_start: date | None = None
period_end: date | None = None
period_months: int | None = None
is_recurring: bool | None = None
notes: str | None = None
class ExpenseEntryRead(ExpenseEntryBase):
id: int
car_id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class OwnershipCategoryBreakdown(BaseModel):
category: str
total_cost: Decimal
entries_count: int
class OwnershipStats(BaseModel):
car_id: int
date_from: date
@@ -94,6 +150,14 @@ class OwnershipStats(BaseModel):
fuel_cost: Decimal
service_cost: Decimal
total_cost: Decimal
expenses_cost: Decimal = Decimal("0")
recurring_costs: Decimal = Decimal("0")
one_time_costs: Decimal = Decimal("0")
forecast_next_month: Decimal = Decimal("0")
depreciation_cost: Decimal = Decimal("0")
cost_per_month: Decimal = Decimal("0")
cost_by_category: dict[str, Decimal] = Field(default_factory=dict)
categories: list[OwnershipCategoryBreakdown] = Field(default_factory=list)
liters: Decimal
distance_km: int
avg_consumption_l_per_100km: float | None

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.services.vehicle_identity import normalize_license_plate, validate_vin
@@ -16,6 +16,13 @@ class ServiceCenterCreate(BaseModel):
business_registration_number: str | None = None
telegram_chat_id: str | None = None
contact_phone: str | None = None
description: str | None = None
specializations: list[str] | None = None
working_hours: str | None = None
facade_photo_url: str | None = None
document_photo_urls: list[str] | None = None
additional_photo_urls: list[str] | None = None
contact_person: str | None = None
class ServiceCenterRead(ServiceCenterCreate):
@@ -26,6 +33,27 @@ class ServiceCenterRead(ServiceCenterCreate):
created_at: datetime
verified_at: datetime | None = None
suspended_at: datetime | None = None
rating_avg: Decimal | None = None
reviews_count: int = 0
model_config = ConfigDict(from_attributes=True)
class ServiceCenterPublicRead(BaseModel):
id: int
display_name: str | None = None
name: str
country: str | None = None
city: str | None = None
address: str | None = None
phone: str | None = None
description: str | None = None
specializations: list[str] | None = None
working_hours: str | None = None
facade_photo_url: str | None = None
verification_status: str
rating_avg: Decimal | None = None
reviews_count: int = 0
model_config = ConfigDict(from_attributes=True)
@@ -91,8 +119,15 @@ class VehicleCreate(BaseModel):
license_plate_country: str | None = None
vin: str | None = None
current_odometer: int | None = None
fuel_type: str | None = None
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
currency: str = "RUB"
include_depreciation: bool = False
@field_validator("vin")
@classmethod
@@ -109,6 +144,13 @@ class VehicleUpdate(BaseModel):
license_plate_country: str | None = None
vin: str | None = None
current_odometer: int | None = None
fuel_type: str | None = None
fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
currency: str | None = None
include_depreciation: bool | None = None
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
@@ -129,6 +171,13 @@ class VehicleRead(BaseModel):
license_plate_country: str | None = None
vin_normalized: str | None = None
current_odometer: int | None = None
fuel_type: str | None = None
fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
currency: str = "RUB"
include_depreciation: bool = False
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
created_at: datetime
@@ -226,11 +275,70 @@ class CarServiceLinkCreate(BaseModel):
car_id: int
service_center_id: int
external_vehicle_ref: str | None = None
access_level: str = "basic"
is_active: bool = True
class CarServiceLinkRead(CarServiceLinkCreate):
id: int
status: str = "pending"
requested_by_user_id: int | None = None
approved_by_user_id: int | None = None
approved_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ServiceCenterAccessRequest(BaseModel):
car_id: int
access_level: str = "basic"
external_vehicle_ref: str | None = None
@field_validator("access_level")
@classmethod
def validate_access_level(cls, value: str) -> str:
allowed = {"basic", "service_history", "full"}
if value not in allowed:
raise ValueError(f"access_level must be one of {', '.join(sorted(allowed))}")
return value
class ServiceCenterReviewCreate(BaseModel):
rating: int = Field(ge=1, le=5)
text: str | None = None
photo_urls: list[str] | None = None
@model_validator(mode="after")
def validate_review(self) -> "ServiceCenterReviewCreate":
if self.text is not None and len(self.text.strip()) < 3:
raise ValueError("review text is too short")
return self
class ServiceCenterReviewRead(ServiceCenterReviewCreate):
id: int
service_center_id: int
user_id: int
status: str
service_response: str | None = None
service_responded_at: datetime | None = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ServiceCenterReviewCommentCreate(BaseModel):
text: str = Field(min_length=2, max_length=2000)
class ServiceCenterReviewCommentRead(ServiceCenterReviewCommentCreate):
id: int
review_id: int
user_id: int
status: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,11 +1,13 @@
from datetime import date
import calendar
from datetime import date, timedelta
from decimal import Decimal
import pandas as pd
from sqlalchemy import Select, func, select
from sqlalchemy import Select, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.expense import FuelEntry, ServiceEntry
from app.models.car import Car
from app.models.expense import ExpenseCategory, ExpenseEntry, FuelEntry, ServiceEntry
from app.schemas.expense import OdometerPrediction, OwnershipStats
@@ -36,10 +38,56 @@ async def get_ownership_stats(
)
service_cost, service_count = service_totals.one()
distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0
total_cost = Decimal(fuel_cost) + Decimal(service_cost)
odometer_values = [min_odo, max_odo]
service_odo = await session.execute(
select(func.min(ServiceEntry.odometer), func.max(ServiceEntry.odometer)).where(
ServiceEntry.car_id == car_id,
ServiceEntry.odometer.is_not(None),
ServiceEntry.entry_date >= date_from,
ServiceEntry.entry_date <= date_to,
)
)
expense_odo = await session.execute(
select(func.min(ExpenseEntry.odometer), func.max(ExpenseEntry.odometer)).where(
ExpenseEntry.car_id == car_id,
ExpenseEntry.odometer.is_not(None),
ExpenseEntry.entry_date >= date_from,
ExpenseEntry.entry_date <= date_to,
)
)
odometer_values.extend(service_odo.one())
odometer_values.extend(expense_odo.one())
odometer_values = [value for value in odometer_values if value is not None]
distance_km = int(max(odometer_values) - min(odometer_values)) if len(odometer_values) >= 2 else 0
expense_cost, recurring_cost, _expense_count, expense_categories = await expense_period_totals(
session, car_id, date_from, date_to
)
car = await session.get(Car, car_id)
depreciation_cost = calculate_depreciation(car, date_from, date_to) if car else Decimal("0")
total_cost = Decimal(fuel_cost) + Decimal(service_cost) + expense_cost + depreciation_cost
avg_consumption = await full_tank_consumption(session, car_id, date_from, date_to)
cost_per_km = float(total_cost / distance_km) if distance_km else None
months = max(Decimal(period_days(date_from, date_to)) / Decimal("30.4375"), Decimal("0.033"))
cost_per_month = (total_cost / months).quantize(Decimal("0.01"))
recurring_total = (recurring_cost + depreciation_cost).quantize(Decimal("0.01"))
one_time_costs = max(total_cost - recurring_total, Decimal("0")).quantize(Decimal("0.01"))
recurring_monthly = (recurring_total / months).quantize(Decimal("0.01"))
forecast_next_month = max(cost_per_month, recurring_monthly).quantize(Decimal("0.01"))
cost_by_category = {
"fuel": Decimal(fuel_cost),
"service": Decimal(service_cost),
**expense_categories,
}
if depreciation_cost:
cost_by_category["depreciation"] = depreciation_cost
categories = [
{"category": key, "total_cost": value, "entries_count": 0}
for key, value in sorted(cost_by_category.items())
if value
]
return OwnershipStats(
car_id=car_id,
@@ -47,7 +95,15 @@ async def get_ownership_stats(
date_to=date_to,
fuel_cost=fuel_cost,
service_cost=service_cost,
expenses_cost=expense_cost,
total_cost=total_cost,
recurring_costs=recurring_total,
one_time_costs=one_time_costs,
forecast_next_month=forecast_next_month,
depreciation_cost=depreciation_cost,
cost_per_month=cost_per_month,
cost_by_category=cost_by_category,
categories=categories,
liters=liters,
distance_km=distance_km,
avg_consumption_l_per_100km=avg_consumption,
@@ -57,6 +113,92 @@ async def get_ownership_stats(
)
def period_days(date_from: date, date_to: date) -> int:
return max((date_to - date_from).days + 1, 1)
def add_months(value: date, months: int) -> date:
month = value.month - 1 + months
year = value.year + month // 12
month = month % 12 + 1
day = min(value.day, calendar.monthrange(year, month)[1])
return date(year, month, day)
def overlap_days(left_start: date, left_end: date, right_start: date, right_end: date) -> int:
start = max(left_start, right_start)
end = min(left_end, right_end)
if end < start:
return 0
return period_days(start, end)
def expense_window(entry: ExpenseEntry) -> tuple[date, date]:
if entry.period_start and entry.period_end:
return entry.period_start, entry.period_end
if entry.period_start and entry.period_months:
return entry.period_start, add_months(entry.period_start, entry.period_months) - timedelta(days=1)
if entry.period_months:
return entry.entry_date, add_months(entry.entry_date, entry.period_months) - timedelta(days=1)
return entry.entry_date, entry.entry_date
def allocated_expense_cost(entry: ExpenseEntry, date_from: date, date_to: date) -> Decimal:
start, end = expense_window(entry)
total_days = period_days(start, end)
matched_days = overlap_days(start, end, date_from, date_to)
if matched_days <= 0:
return Decimal("0")
if total_days <= 1 and start == entry.entry_date:
return Decimal(entry.total_cost)
return (Decimal(entry.total_cost) * Decimal(matched_days) / Decimal(total_days)).quantize(Decimal("0.01"))
async def expense_period_totals(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> tuple[Decimal, Decimal, int, dict[str, Decimal]]:
result = await session.execute(
select(ExpenseEntry)
.where(
ExpenseEntry.car_id == car_id,
or_(
ExpenseEntry.entry_date.between(date_from, date_to),
ExpenseEntry.period_start.between(date_from, date_to),
ExpenseEntry.period_end.between(date_from, date_to),
(ExpenseEntry.period_start <= date_from) & (ExpenseEntry.period_end >= date_to),
),
)
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
)
total = Decimal("0")
recurring = Decimal("0")
categories: dict[str, Decimal] = {}
count = 0
for entry in result.scalars():
amount = allocated_expense_cost(entry, date_from, date_to)
if amount <= 0:
continue
count += 1
total += amount
category = entry.category.value if isinstance(entry.category, ExpenseCategory) else str(entry.category)
categories[category] = categories.get(category, Decimal("0")) + amount
if entry.is_recurring or entry.category in {ExpenseCategory.insurance, ExpenseCategory.loan_payment, ExpenseCategory.loan_interest}:
recurring += amount
return total.quantize(Decimal("0.01")), recurring.quantize(Decimal("0.01")), count, categories
def calculate_depreciation(car: Car, date_from: date, date_to: date) -> Decimal:
if not car.include_depreciation or not car.purchase_price or not car.purchase_date:
return Decimal("0")
depreciation_start = car.purchase_date
depreciation_end = add_months(car.purchase_date, 60) - timedelta(days=1)
matched_days = overlap_days(depreciation_start, depreciation_end, date_from, date_to)
if matched_days <= 0:
return Decimal("0")
daily_cost = Decimal(car.purchase_price) / Decimal(period_days(depreciation_start, depreciation_end))
return (daily_cost * Decimal(matched_days)).quantize(Decimal("0.01"))
async def full_tank_consumption(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> float | None:

View File

@@ -1,6 +1,11 @@
import asyncio
import re
from dataclasses import dataclass
from functools import lru_cache
from io import BytesIO
from typing import Protocol
from app.core.config import settings
from app.services.vehicle_identity import normalize_license_plate, validate_vin
@@ -15,34 +20,95 @@ class OcrCandidate:
class OcrResult:
recognized_text: str
candidates: list[OcrCandidate]
provider: str = "heuristic"
class OCRProvider:
class OCRProvider(Protocol):
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
raise NotImplementedError
...
class StubOCRProvider(OCRProvider):
class TextHeuristicOCRProvider:
provider_name = "heuristic"
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
text = " ".join(
[
filename or "",
content.decode("utf-8", errors="ignore"),
]
)
compact = re.sub(r"\s+", " ", text).strip()
candidates: list[OcrCandidate] = []
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", compact.upper()):
text = " ".join([filename or "", content.decode("utf-8", errors="ignore")])
return build_ocr_result(text, provider=self.provider_name, base_confidence=0.62)
class TesseractOCRProvider:
provider_name = "tesseract"
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
text = await asyncio.to_thread(self._recognize_sync, content)
if not text.strip():
fallback = await TextHeuristicOCRProvider().recognize(content, filename)
fallback.provider = self.provider_name
return fallback
return build_ocr_result(text, provider=self.provider_name, base_confidence=0.78)
def _recognize_sync(self, content: bytes) -> str:
try:
candidates.append(OcrCandidate(type="vin", value=validate_vin(raw) or raw, confidence=0.84))
import pytesseract
from PIL import Image
except ImportError:
return ""
try:
image = Image.open(BytesIO(content))
except Exception:
return ""
try:
return pytesseract.image_to_string(image, lang=settings.ocr_languages)
except Exception:
return pytesseract.image_to_string(image)
class CompositeOCRProvider:
def __init__(self) -> None:
provider = settings.ocr_provider.lower()
self.primary: OCRProvider = (
TextHeuristicOCRProvider() if provider == "heuristic" else TesseractOCRProvider()
)
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
return await self.primary.recognize(content, filename)
def build_ocr_result(text: str, *, provider: str, base_confidence: float) -> OcrResult:
compact = re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
candidates: list[OcrCandidate] = []
upper = compact.upper()
seen: set[tuple[str, str]] = set()
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", upper):
try:
value = validate_vin(raw) or raw
except ValueError:
continue
for raw in re.findall(r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b", compact.upper()):
key = ("vin", value)
if key not in seen:
seen.add(key)
candidates.append(OcrCandidate(type="vin", value=value, confidence=min(base_confidence + 0.12, 0.95)))
plate_patterns = [
r"\b\d{2,3}\s*[가-힣]\s*\d{4}\b",
r"\b[A-ZА-Я]{1}\s?\d{3}\s?[A-ZА-Я]{2}\s?\d{2,3}\b",
r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b",
]
for pattern in plate_patterns:
for raw in re.findall(pattern, upper):
normalized = normalize_license_plate(raw)
if normalized and 5 <= len(normalized) <= 10 and not any(item.value == normalized for item in candidates):
candidates.append(OcrCandidate(type="license_plate", value=normalized, confidence=0.62))
return OcrResult(recognized_text=compact, candidates=candidates[:8])
if normalized and 5 <= len(normalized) <= 10:
key = ("license_plate", normalized)
if key not in seen:
seen.add(key)
candidates.append(
OcrCandidate(type="license_plate", value=normalized, confidence=base_confidence)
)
return OcrResult(recognized_text=compact, candidates=candidates[:12], provider=provider)
@lru_cache
def get_ocr_provider() -> OCRProvider:
return StubOCRProvider()
return CompositeOCRProvider()

View File

@@ -436,13 +436,13 @@ async def compute_service_center_score(session: AsyncSession, center: ServiceCen
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 = 20 if center.verification_status in {"approved", "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":
if center.verification_status not in {"approved", "verified"}:
level = "new_service"
elif score >= 85:
level = "high_confidence_service"

View File

@@ -25,7 +25,7 @@ api = ApiClient()
def main_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))],
[KeyboardButton(text="Открыть CarPass")],
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
],
resize_keyboard=True,
@@ -49,7 +49,7 @@ async def start(message: Message) -> None:
"Нажми «Открыть CarPass», чтобы перейти в приложение."
)
await message.answer(text, reply_markup=webapp_inline_keyboard())
await message.answer("Быстрый вход также закреплен на клавиатуре ниже.", reply_markup=main_keyboard())
await message.answer("Клавиатура ниже открывает меню бота. Сам Mini App запускается кнопкой в сообщении выше.", reply_markup=main_keyboard())
@dp.message(Command("add_car"))
@@ -104,11 +104,25 @@ async def show_stats(callback: CallbackQuery) -> None:
@dp.message(Command("help"))
async def help_message(message: Message) -> None:
await message.answer(
"Команды:\n"
"/add_car Название - быстро добавить авто\n"
"/cars - список авто и статистика\n\n"
"Заправки, ремонты и обслуживание удобнее вести через кнопку «Открыть гараж».",
reply_markup=main_keyboard(),
"CarPass помогает вести цифровой паспорт автомобиля.\n\n"
"Что можно делать:\n"
"• добавлять автомобили и параметры обслуживания;\n"
"• вести заправки, ТО, ремонт, страховку, налоги и штрафы;\n"
"• видеть стоимость владения, стоимость 1 км и прогноз расходов;\n"
"• загрузить чек, проверить распознанные данные и сохранить запись;\n"
"• привязать авто к проверенному СТО и подтверждать сервисную историю;\n"
"• зарегистрировать СТО и отправить заявку на проверку.\n\n"
"Mini App нужно открывать кнопкой под этим сообщением: так Telegram передает защищенную авторизацию.",
reply_markup=webapp_inline_keyboard(),
)
@dp.message(F.text == "Открыть CarPass")
@dp.message(F.text == "Открыть гараж")
async def open_carpass(message: Message) -> None:
await message.answer(
"Открой CarPass кнопкой ниже. Это правильный Telegram Mini App вход с авторизацией.",
reply_markup=webapp_inline_keyboard(),
)

View File

@@ -32,6 +32,10 @@ services:
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
APP_ENV: ${APP_ENV:-development}
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
ports:
- "127.0.0.1:8000:8000"
depends_on:
@@ -52,6 +56,10 @@ services:
PUBLIC_WEBAPP_URL: ${PUBLIC_WEBAPP_URL:-}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
APP_ENV: ${APP_ENV:-development}
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
depends_on:
- api

View File

@@ -12,6 +12,7 @@ dependencies = [
"matplotlib>=3.8,<4.0",
"pandas>=2.2,<3.0",
"pydantic-settings>=2.2,<3.0",
"pytesseract>=0.3.13,<1.0",
"python-multipart>=0.0.9,<1.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"uvicorn[standard]>=0.29,<1.0",

View File

@@ -69,3 +69,13 @@ def auth_headers() -> dict[str, str]:
@pytest.fixture()
def other_auth_headers() -> dict[str, str]:
return {"X-Telegram-Init-Data": make_init_data(2002)}
@pytest.fixture()
def admin_auth_headers() -> dict[str, str]:
return {"X-Telegram-Init-Data": make_init_data(9001, "Admin")}
@pytest.fixture()
def internal_headers() -> dict[str, str]:
return {"X-Internal-API-Token": TEST_INTERNAL_TOKEN}

View File

@@ -87,3 +87,44 @@ async def test_stats_do_not_fail_with_insufficient_data(client, auth_headers) ->
assert response.status_code == 200
assert response.json()["avg_consumption_l_per_100km"] is None
@pytest.mark.asyncio
async def test_expense_crud_and_insurance_allocation(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Cost car"})).json()
created = await client.post(
"/api/expenses",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-01",
"category": "insurance",
"title": "Insurance",
"total_cost": 1200,
"period_start": "2026-01-01",
"period_end": "2026-12-31",
"is_recurring": True,
},
)
assert created.status_code == 201
entry_id = created.json()["id"]
stats = await client.get(
f"/api/cars/{car['id']}/stats?date_from=2026-01-01&date_to=2026-01-31",
headers=auth_headers,
)
assert stats.status_code == 200
body = stats.json()
assert body["expenses_cost"] in {"101.92", "101.93"}
assert body["cost_by_category"]["insurance"] in {"101.92", "101.93"}
patched = await client.patch(
f"/api/expenses/{entry_id}",
headers=auth_headers,
json={"title": "Insurance policy"},
)
assert patched.status_code == 200
assert patched.json()["title"] == "Insurance policy"
deleted = await client.delete(f"/api/expenses/{entry_id}", headers=auth_headers)
assert deleted.status_code == 204

View File

@@ -15,7 +15,9 @@ async def test_vin_validation_rejects_invalid_value(client, auth_headers) -> Non
@pytest.mark.asyncio
async def test_service_visit_owner_confirmation_and_change_request(client, auth_headers) -> None:
async def test_service_visit_owner_confirmation_and_change_request(
client, auth_headers, admin_auth_headers, internal_headers
) -> None:
vehicle = (
await client.post(
"/api/my/vehicles",
@@ -30,6 +32,16 @@ async def test_service_visit_owner_confirmation_and_change_request(client, auth_
json={"display_name": "Careful Service", "country": "KR", "city": "Seoul"},
)
).json()
await client.post(
"/api/users",
headers=internal_headers,
json={"telegram_id": 9001, "platform_role": "admin"},
)
verify_response = await client.post(
f"/api/admin/service-centers/{center['id']}/verify",
headers=admin_auth_headers,
)
assert verify_response.status_code == 200
visit = (
await client.post(
f"/api/service-centers/{center['id']}/visits",
@@ -63,6 +75,71 @@ async def test_service_visit_owner_confirmation_and_change_request(client, auth_
assert approve_response.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_pending_service_center_cannot_create_visit(client, auth_headers) -> None:
vehicle = (
await client.post(
"/api/my/vehicles",
headers=auth_headers,
json={"name": "Client car"},
)
).json()
center = (
await client.post(
"/api/service-centers",
headers=auth_headers,
json={"display_name": "Pending Service", "country": "KR", "city": "Seoul"},
)
).json()
response = await client.post(
f"/api/service-centers/{center['id']}/visits",
headers=auth_headers,
json={"vehicle_id": vehicle["id"], "visit_date": "2026-05-12"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_public_service_center_and_review_flow(
client, auth_headers, admin_auth_headers, internal_headers
) -> None:
center = (
await client.post(
"/api/service-centers",
headers=auth_headers,
json={
"display_name": "Review Service",
"country": "KR",
"city": "Seoul",
"description": "Clean workshop",
"specializations": ["oil", "diagnostics"],
},
)
).json()
pending_list = await client.get("/api/service-centers/public", headers=auth_headers)
assert pending_list.json() == []
await client.post(
"/api/users",
headers=internal_headers,
json={"telegram_id": 9001, "platform_role": "admin"},
)
await client.post(f"/api/admin/service-centers/{center['id']}/verify", headers=admin_auth_headers)
public_list = await client.get("/api/service-centers/public", headers=auth_headers)
assert public_list.status_code == 200
assert public_list.json()[0]["display_name"] == "Review Service"
review = await client.post(
f"/api/service-centers/{center['id']}/reviews",
headers=auth_headers,
json={"rating": 5, "text": "Accurate and transparent service"},
)
assert review.status_code == 201
refreshed = await client.get(f"/api/service-centers/{center['id']}", headers=auth_headers)
assert refreshed.json()["reviews_count"] == 1
@pytest.mark.asyncio
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
response = await client.post(

View File

@@ -22,7 +22,7 @@
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
</div>
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
<p class="auth-note" id="authNote">Если Telegram уже открыт, нажмите «Открыть гараж» в боте.</p>
<p class="auth-note" id="authNote">Mini App открывается кнопкой «Открыть CarPass» внутри бота.</p>
</div>
</div>
<main class="shell">
@@ -39,21 +39,22 @@
<section class="hero-grid">
<div class="summary-card">
<span>Автомобиль</span>
<span>Стоимость в месяц</span>
<strong id="selectedCarTitle">Не выбран</strong>
<small id="selectedCarMeta">Добавь авто или выбери из списка</small>
</div>
<div class="summary-card accent">
<span>Расходы</span>
<span>Стоимость 1 км</span>
<strong id="summaryTotal">0</strong>
<small>топливо, сервис и ремонты</small>
<small>по пробегу и расходам периода</small>
</div>
<div class="summary-card blue">
<span>Средний расход</span>
<span>Прогноз расходов</span>
<strong id="summaryConsumption">-</strong>
<small>л/100 км по полным данным</small>
<small>на ближайший месяц</small>
</div>
</section>
<button class="primary-add-btn" id="addRecordPrimaryBtn" type="button">Добавить запись</button>
<section class="layout">
<aside class="panel reveal">
@@ -104,11 +105,12 @@
</div>
<div class="period-controls">
<select id="periodPreset" aria-label="Период отчета">
<option value="all">Весь срок</option>
<option value="month">Месяц</option>
<option value="day">День</option>
<option value="quarter">Квартал</option>
<option value="year">Год</option>
<option value="7d">7 дней</option>
<option value="30d">30 дней</option>
<option value="3m">3 месяца</option>
<option value="6m">6 месяцев</option>
<option value="12m">12 месяцев</option>
<option value="all">Весь период</option>
<option value="custom">Свой период</option>
</select>
<input id="periodFrom" type="date" aria-label="Дата начала" />
@@ -118,7 +120,7 @@
<div class="stats" id="stats"></div>
<section class="quick-actions">
<section class="quick-actions hidden">
<button class="action-card active" data-action="fuel">
<span>Заправка</span>
<strong>30 сек</strong>
@@ -148,7 +150,7 @@
</div>
</section>
<form id="fuelForm" class="entry-form quick-form">
<form id="fuelForm" class="entry-form quick-form hidden">
<label>
Дата
<input name="entry_date" type="date" required />
@@ -242,14 +244,135 @@
<h2>Меню</h2>
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
</div>
<button class="menu-row" id="openCarFormBtn">Добавить автомобиль</button>
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
<button class="menu-row" id="openNotificationsBtn">Уведомления</button>
<button class="menu-row" id="openConfirmationsBtn">Запросы на подтверждение</button>
<button class="menu-row" id="openConnectedServicesBtn">Подключенные автосервисы</button>
<button class="menu-row" id="openServicePanelBtn">Панель СТО</button>
<button class="menu-row" id="openScanBtn">Разобрать чек</button>
<button class="menu-row" data-menu-section="carsSection">Автомобили</button>
<button class="menu-row" data-menu-section="carFormSection">Добавить авто</button>
<button class="menu-row" data-menu-section="carProfileSection">Параметры авто</button>
<button class="menu-row" data-menu-section="expensesSection">Расходы</button>
<button class="menu-row" data-menu-section="fuelSection">Заправки</button>
<button class="menu-row" data-menu-section="serviceSection">ТО и ремонт</button>
<button class="menu-row" data-menu-section="insuranceSection">Страховка</button>
<button class="menu-row" data-menu-section="taxSection">Налоги</button>
<button class="menu-row" data-menu-section="fineSection">Штрафы</button>
<button class="menu-row" data-menu-section="publicServicesSection">СТО</button>
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
<button class="menu-row" data-menu-section="settingsSection">Настройки</button>
<section class="drawer-section hidden" id="carsSection">
<h2>Автомобили</h2>
<div id="drawerCars" class="cars drawer-cars"></div>
</section>
<section class="drawer-section hidden" id="expensesSection">
<h2>Добавить расход</h2>
<form id="expenseForm" class="grid-form drawer-form">
<label>
Дата
<input name="entry_date" type="date" required />
</label>
<label>
Категория
<select name="category" id="expenseCategorySelect">
<option value="insurance">Страховка</option>
<option value="tax">Налог</option>
<option value="fine">Штраф</option>
<option value="parking">Парковка</option>
<option value="car_wash">Мойка</option>
<option value="toll">Платная дорога</option>
<option value="tires">Шины</option>
<option value="wheels">Диски</option>
<option value="battery">Аккумулятор</option>
<option value="parts">Запчасти</option>
<option value="repair">Ремонт</option>
<option value="maintenance">Плановое ТО</option>
<option value="diagnostics">Диагностика</option>
<option value="towing">Эвакуатор</option>
<option value="loan_payment">Кредит / лизинг</option>
<option value="loan_interest">Проценты по кредиту</option>
<option value="state_fee">Госпошлина</option>
<option value="registration">Регистрация</option>
<option value="inspection">Техосмотр</option>
<option value="other">Прочее</option>
</select>
</label>
<label>
Название
<input name="title" placeholder="ОСАГО / парковка / налог" required />
</label>
<label>
Стоимость
<input name="total_cost" type="number" min="0" step="0.01" required />
</label>
<label>
Валюта
<select name="currency">
<option value="RUB">RUB</option>
<option value="KRW">KRW</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</label>
<label>
Поставщик / место
<input name="vendor" />
</label>
<label>
Одометр
<input name="odometer" type="number" min="0" />
</label>
<label>
Начало периода
<input name="period_start" type="date" />
</label>
<label>
Конец периода
<input name="period_end" type="date" />
</label>
<label class="check">
<input name="is_recurring" type="checkbox" />
Регулярный расход
</label>
<button type="submit">Сохранить расход</button>
</form>
</section>
<section class="drawer-section hidden" id="fuelSection">
<h2>Заправка</h2>
<div id="fuelFormMount"></div>
<button type="button" class="wide-btn ghost-btn" id="fuelScanBtn">Скан чека</button>
</section>
<section class="drawer-section hidden" id="serviceSection">
<h2>ТО и ремонт</h2>
<div id="serviceFormMount"></div>
</section>
<section class="drawer-section hidden" id="insuranceSection">
<h2>Страховка</h2>
<div class="tip-card">Укажите период действия полиса, и CarPass распределит стоимость по месяцам.</div>
<button class="wide-btn" type="button" data-expense-preset="insurance">Добавить страховку</button>
</section>
<section class="drawer-section hidden" id="taxSection">
<h2>Налоги</h2>
<button class="wide-btn" type="button" data-expense-preset="tax">Добавить налог</button>
</section>
<section class="drawer-section hidden" id="fineSection">
<h2>Штрафы</h2>
<button class="wide-btn" type="button" data-expense-preset="fine">Добавить штраф</button>
</section>
<section class="drawer-section hidden" id="publicServicesSection">
<h2>СТО</h2>
<div class="tip-card">Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.</div>
<button class="wide-btn" type="button" data-menu-section="servicePanelSection">Зарегистрировать СТО</button>
<div id="publicServiceCenters" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="reviewsSection">
<h2>Отзывы</h2>
<div id="serviceReviews" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="settingsSection">
<h2>Настройки</h2>
@@ -321,16 +444,32 @@
Телефон
<input name="phone" />
</label>
<label>
Контактное лицо
<input name="contact_person" />
</label>
<label>
Специализация
<input name="specializations" placeholder="BMW, электрика, шиномонтаж" />
</label>
<label>
График работы
<input name="working_hours" placeholder="Пн-Сб 09:00-19:00" />
</label>
<label>
Описание
<input name="description" placeholder="Коротко о сервисе" />
</label>
<label>
Регистрационный номер
<input name="business_registration_number" />
</label>
<button type="submit">Создать СТО</button>
<button type="submit">Отправить заявку</button>
</form>
<div id="serviceCentersList" class="stack-list"></div>
</section>
<section class="drawer-section" id="carFormSection">
<section class="drawer-section hidden" id="carFormSection">
<h2>Новое авто</h2>
<form id="carForm" class="grid-form drawer-form">
<label>
@@ -447,7 +586,7 @@
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
<button type="submit">Разобрать текст</button>
</form>
<div id="ocrResult" class="tip-card">Сейчас разбираем текстовые чеки. OCR по фото будет подключен отдельно.</div>
<div id="ocrResult" class="tip-card">Загрузите фото или файл чека. CarPass распознает данные и предложит проверить их перед сохранением.</div>
</div>
</div>

View File

@@ -313,6 +313,7 @@ const state = {
selectedCarId: null,
latestFuel: [],
latestService: [],
latestExpenses: [],
latestStats: null,
allStats: null,
analytics: null,
@@ -412,11 +413,8 @@ function formData(form) {
}
async function api(path, options = {}) {
const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
}
const headers = { "Content-Type": "application/json", ...authHeaders(options.headers || {}) };
if (options.body instanceof FormData) delete headers["Content-Type"];
const response = await fetch(`/api${path}`, {
headers,
...options,
@@ -429,6 +427,15 @@ async function api(path, options = {}) {
return response.json();
}
function authHeaders(extra = {}) {
const headers = { ...extra };
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
}
return headers;
}
async function loadAuthConfig() {
state.authConfig = await api("/users/auth/config");
window.APP_VAPID_PUBLIC_KEY = state.authConfig.vapid_public_key || "";
@@ -539,7 +546,7 @@ function showTelegramOpenHint() {
if (slot && !slot.dataset.ready) slot.textContent = "";
if (note) {
note.textContent = isMobileBrowser()
? "После перехода в Telegram нажмите в боте кнопку «Открыть гараж»."
? "После перехода нажмите Start, затем кнопку «Открыть CarPass» под сообщением бота."
: "На компьютере можно войти кнопкой Telegram ниже или открыть бота.";
}
if (!botUsername) {
@@ -638,9 +645,12 @@ function applyPeriodPreset(preset = "month") {
const to = dateValue(now);
let fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
if (preset === "all") fromDate = new Date(2000, 0, 1);
if (preset === "7d") fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6);
if (preset === "30d" || preset === "month") fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29);
if (preset === "3m" || preset === "quarter") fromDate = shiftMonths(now, -3);
if (preset === "6m") fromDate = shiftMonths(now, -6);
if (preset === "12m" || preset === "year") fromDate = shiftMonths(now, -12);
if (preset === "day") fromDate = now;
if (preset === "quarter") fromDate = shiftMonths(now, -3);
if (preset === "year") fromDate = shiftMonths(now, -12);
if (preset !== "custom") {
document.querySelector("#periodFrom").value = dateValue(fromDate);
document.querySelector("#periodTo").value = to;
@@ -745,14 +755,12 @@ function resetCarCatalog() {
function updateHero(stats) {
const car = selectedCar();
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
document.querySelector("#selectedCarTitle").textContent = stats ? money(stats.cost_per_month || stats.total_cost || 0) : t("Не выбран");
document.querySelector("#selectedCarMeta").textContent = car
? [car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")
: t("Добавь авто или выбери из списка");
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
? `${stats.avg_consumption_l_per_100km.toFixed(2)} л`
: "-";
document.querySelector("#summaryTotal").textContent = stats?.cost_per_km ? money(stats.cost_per_km) : "-";
document.querySelector("#summaryConsumption").textContent = stats ? money(stats.forecast_next_month || 0) : "-";
}
function formatFuelPrice(value) {
@@ -762,12 +770,14 @@ function formatFuelPrice(value) {
function renderCars() {
const root = document.querySelector("#cars");
const drawerRoot = document.querySelector("#drawerCars");
if (!state.cars.length) {
root.innerHTML = `<div class="empty">${t("Добавь первый автомобиль")}</div>`;
if (drawerRoot) drawerRoot.innerHTML = root.innerHTML;
updateHero(null);
return;
}
root.innerHTML = state.cars
const markup = state.cars
.map(
(car) => `
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
@@ -780,7 +790,9 @@ function renderCars() {
`,
)
.join("");
root.querySelectorAll("[data-car]").forEach((button) => {
root.innerHTML = markup;
if (drawerRoot) drawerRoot.innerHTML = markup;
document.querySelectorAll("[data-car]").forEach((button) => {
button.addEventListener("click", () => selectCar(Number(button.dataset.car)));
});
}
@@ -818,10 +830,7 @@ function fillCarProfileForm() {
}
function openCarProfile() {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelector("#carProfileSection").classList.remove("hidden");
fillCarProfileForm();
document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" });
openDrawerSection("carProfileSection");
}
async function loadServiceCenters() {
@@ -859,6 +868,36 @@ function renderServiceCenters() {
.join("");
}
async function loadPublicServiceCenters() {
const root = document.querySelector("#publicServiceCenters");
if (!root) return;
try {
const centers = await api("/service-centers/public");
root.innerHTML = centers.length
? centers
.map(
(center) => `
<div class="stack-item">
<strong>${center.display_name || center.name}</strong>
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
<small>${center.specializations?.join(", ") || "Специализация не указана"}</small>
<span class="trust-badge">${center.rating_avg ? `${center.rating_avg}` : "Проверка пройдена"}</span>
</div>
`,
)
.join("")
: `<div class="empty">Проверенных СТО пока нет</div>`;
} catch (error) {
root.innerHTML = `<div class="empty">Не удалось загрузить СТО</div>`;
}
}
function renderServiceReviews() {
const root = document.querySelector("#serviceReviews");
if (!root) return;
root.innerHTML = `<div class="empty">Отзывы доступны в карточке проверенного СТО. Выберите сервис в разделе «СТО».</div>`;
}
function trustLabel(level) {
const labels = {
new_service: "Новый сервис",
@@ -894,6 +933,11 @@ function renderStats(stats) {
const costPer100 = stats.cost_per_km ? stats.cost_per_km * 100 : null;
const periodTitles = {
all: t("За весь срок"),
"7d": "7 дней",
"30d": "30 дней",
"3m": "3 месяца",
"6m": "6 месяцев",
"12m": "12 месяцев",
month: t("За месяц"),
day: t("За день"),
quarter: t("За квартал"),
@@ -904,6 +948,8 @@ function renderStats(stats) {
root.innerHTML = `
<button class="stat pop" data-report="summary"><span>${t("За весь срок")}</span><strong>${money(all.total_cost)}</strong><em>${all.fuel_entries_count + all.service_entries_count} ${t("записей")}</em></button>
<button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button>
<button class="stat pop" data-report="summary"><span>В месяц</span><strong>${money(stats.cost_per_month || 0)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="summary"><span>Прогноз</span><strong>${money(stats.forecast_next_month || 0)}</strong><em>ближайший месяц</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button>
@@ -929,6 +975,13 @@ function recordsForPeriod() {
meta: item.vendor || serviceLabel(item.service_type),
cost: item.total_cost,
})),
...state.latestExpenses.map((item) => ({
date: item.entry_date,
type: "expense",
title: item.title,
meta: expenseLabel(item.category),
cost: item.total_cost,
})),
].sort((a, b) => b.date.localeCompare(a.date));
}
@@ -1156,11 +1209,40 @@ function serviceLabel(value) {
}[value] || value;
}
function monthlySeries(fuel, service) {
function expenseLabel(value) {
return {
insurance: "Страховка",
tax: "Налог",
fine: "Штраф",
parking: "Парковка",
car_wash: "Мойка",
toll: "Платная дорога",
tires: "Шины",
wheels: "Диски",
battery: "Аккумулятор",
parts: "Запчасти",
repair: "Ремонт",
maintenance: "Плановое ТО",
diagnostics: "Диагностика",
towing: "Эвакуатор",
loan_payment: "Кредит / лизинг",
loan_interest: "Проценты",
state_fee: "Госпошлина",
registration: "Регистрация",
inspection: "Техосмотр",
other: "Прочее",
}[value] || value;
}
function monthlySeries(fuel, service, expenses = []) {
const map = new Map();
[...fuel.map((item) => ({ ...item, type: "fuel" })), ...service.map((item) => ({ ...item, type: "service" }))].forEach((item) => {
[
...fuel.map((item) => ({ ...item, type: "fuel" })),
...service.map((item) => ({ ...item, type: "service" })),
...expenses.map((item) => ({ ...item, type: "other" })),
].forEach((item) => {
const key = item.entry_date.slice(0, 7);
const current = map.get(key) || { label: key, fuel: 0, service: 0 };
const current = map.get(key) || { label: key, fuel: 0, service: 0, other: 0 };
current[item.type] += Number(item.total_cost || 0);
map.set(key, current);
});
@@ -1168,8 +1250,8 @@ function monthlySeries(fuel, service) {
}
function drawCharts(fuel, service, stats) {
drawExpensesChart(monthlySeries(fuel, service));
drawSplitChart(Number(stats?.fuel_cost || 0), Number(stats?.service_cost || 0));
drawExpensesChart(monthlySeries(fuel, service, state.latestExpenses));
drawSplitChart(stats?.cost_by_category || { fuel: Number(stats?.fuel_cost || 0), service: Number(stats?.service_cost || 0) });
}
function setupCanvas(canvas) {
@@ -1200,7 +1282,7 @@ function drawExpensesChart(series) {
ctx.clearRect(0, 0, width, height);
const pad = 28;
const chartH = height - pad * 2;
const max = Math.max(...series.map((item) => item.fuel + item.service), 1);
const max = Math.max(...series.map((item) => item.fuel + item.service + item.other), 1);
const barGap = 12;
const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length);
@@ -1216,17 +1298,21 @@ function drawExpensesChart(series) {
series.forEach((item, index) => {
const x = pad + index * (barW + barGap);
const total = item.fuel + item.service;
const total = item.fuel + item.service + item.other;
const totalH = (total / max) * chartH;
const fuelH = total ? (item.fuel / total) * totalH : 0;
const serviceH = totalH - fuelH;
const serviceH = total ? (item.service / total) * totalH : 0;
const otherH = Math.max(totalH - fuelH - serviceH, 0);
const y = height - pad - totalH;
ctx.fillStyle = "#36a388";
roundRect(ctx, x, y + serviceH, barW, fuelH, 6);
roundRect(ctx, x, y + serviceH + otherH, barW, fuelH, 6);
ctx.fill();
ctx.fillStyle = "#3f7fba";
roundRect(ctx, x, y, barW, serviceH, 6);
roundRect(ctx, x, y + otherH, barW, serviceH, 6);
ctx.fill();
ctx.fillStyle = "#d6a64f";
roundRect(ctx, x, y, barW, otherH, 6);
ctx.fill();
ctx.fillStyle = "#7c8783";
@@ -1236,10 +1322,15 @@ function drawExpensesChart(series) {
});
}
function drawSplitChart(fuelCost, serviceCost) {
function drawSplitChart(categories) {
const canvas = document.querySelector("#splitChart");
const { ctx, width, height } = setupCanvas(canvas);
const total = fuelCost + serviceCost;
const entries = Object.entries(categories || {})
.map(([key, value]) => [key, Number(value || 0)])
.filter(([, value]) => value > 0)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
const total = entries.reduce((sum, [, value]) => sum + value, 0);
if (!total) {
drawEmpty(ctx, width, height, "Нет расходов");
return;
@@ -1248,26 +1339,26 @@ function drawSplitChart(fuelCost, serviceCost) {
const cx = width / 2;
const cy = height / 2 - 8;
const radius = Math.min(width, height) * 0.31;
const fuelAngle = (fuelCost / total) * Math.PI * 2;
ctx.lineWidth = 22;
ctx.lineCap = "round";
ctx.strokeStyle = "#36a388";
let start = -Math.PI / 2;
const colors = ["#36a388", "#3f7fba", "#d6a64f", "#c7645d", "#768a82"];
entries.forEach(([, value], index) => {
const angle = (value / total) * Math.PI * 2;
ctx.strokeStyle = colors[index % colors.length];
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + fuelAngle);
ctx.stroke();
ctx.strokeStyle = "#3f7fba";
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2 + fuelAngle + 0.05, Math.PI * 1.5 - 0.05);
ctx.arc(cx, cy, radius, start, start + Math.max(angle - 0.05, 0.02));
ctx.stroke();
start += angle;
});
ctx.fillStyle = "#1d2522";
ctx.font = "700 22px system-ui";
ctx.textAlign = "center";
ctx.fillText(`${Math.round((fuelCost / total) * 100)}%`, cx, cy + 5);
ctx.fillText(`${Math.round((entries[0][1] / total) * 100)}%`, cx, cy + 5);
ctx.fillStyle = "#7c8783";
ctx.font = "12px system-ui";
ctx.fillText(t("топливо"), cx, cy + 25);
ctx.fillText(expenseLabel(entries[0][0]), cx, cy + 25);
}
function roundRect(ctx, x, y, width, height, radius) {
@@ -1310,6 +1401,7 @@ async function loadSelectedCar() {
if (!state.selectedCarId) {
state.latestFuel = [];
state.latestService = [];
state.latestExpenses = [];
state.latestStats = null;
state.allStats = null;
state.analytics = null;
@@ -1319,11 +1411,12 @@ async function loadSelectedCar() {
renderStats(null);
return;
}
const [stats, allStats, fuel, service, analytics, vehicleScore] = await Promise.all([
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`),
api(`/my/vehicles/${state.selectedCarId}/score`),
]);
@@ -1335,6 +1428,7 @@ async function loadSelectedCar() {
state.allStats = allStats;
state.latestFuel = fuel;
state.latestService = service;
state.latestExpenses = expenses;
state.analytics = analytics;
state.vehicleScore = vehicleScore;
state.vehicleTimeline = timeline;
@@ -1343,11 +1437,11 @@ async function loadSelectedCar() {
drawCharts(fuel, service, stats);
}
document.querySelectorAll('input[type="date"]').forEach((input) => {
if (input.name !== "next_due_date") input.value = today();
document.querySelectorAll('input[name="entry_date"]').forEach((input) => {
input.value = today();
});
applyPeriodPreset("month");
applyPeriodPreset("30d");
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
@@ -1514,6 +1608,40 @@ document.querySelector("#serviceForm").addEventListener("submit", async (event)
});
});
document.querySelector("#expenseForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) {
toast("Выбери автомобиль", "error");
return;
}
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/expenses", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
category: data.category,
title: data.title,
total_cost: Number(data.total_cost),
currency: data.currency || state.user?.currency || "RUB",
vendor: data.vendor || null,
odometer: numberOrNull(data.odometer),
period_start: data.period_start || null,
period_end: data.period_end || null,
is_recurring: Boolean(data.is_recurring),
}),
});
form.reset();
form.entry_date.value = today();
form.currency.value = state.user?.currency || "RUB";
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
function setAction(action) {
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
button.classList.toggle("active", button.dataset.action === action);
@@ -1528,6 +1656,62 @@ function openScanModal() {
document.querySelector("#scanModal").classList.remove("hidden");
}
function mountEntryForms() {
const fuelMount = document.querySelector("#fuelFormMount");
const serviceMount = document.querySelector("#serviceFormMount");
const fuelForm = document.querySelector("#fuelForm");
const serviceForm = document.querySelector("#serviceForm");
if (fuelMount && fuelForm && !fuelMount.contains(fuelForm)) {
fuelForm.classList.remove("hidden");
fuelMount.appendChild(fuelForm);
}
if (serviceMount && serviceForm && !serviceMount.contains(serviceForm)) {
serviceForm.classList.remove("hidden");
serviceMount.appendChild(serviceForm);
}
}
async function openDrawerSection(sectionId, options = {}) {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelectorAll(".drawer-section").forEach((section) => {
section.classList.toggle("hidden", section.id !== sectionId);
});
document.querySelectorAll(".menu-row").forEach((button) => {
button.classList.toggle("active", button.dataset.menuSection === sectionId);
});
mountEntryForms();
if (sectionId === "carProfileSection") fillCarProfileForm();
if (sectionId === "settingsSection") {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
}
if (sectionId === "notificationsSection") {
updateNotificationStatus(
"Notification" in window && Notification.permission === "granted"
? "Уведомления включены"
: "Напомним о ТО, страховке и регулярном внесении пробега.",
);
}
if (sectionId === "confirmationsSection") renderPlaceholderList("#confirmationRequests", "Новых запросов нет");
if (sectionId === "connectedServicesSection") renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет");
if (sectionId === "servicePanelSection") await loadServiceCenters();
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
if (sectionId === "reviewsSection") renderServiceReviews();
if (options.expenseCategory) {
openDrawerSection("expensesSection");
presetExpense(options.expenseCategory);
return;
}
document.querySelector(`#${sectionId}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function presetExpense(category) {
const form = document.querySelector("#expenseForm");
form.category.value = category;
form.title.value = expenseLabel(category);
if (category === "insurance") form.is_recurring.checked = true;
}
document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => {
haptic();
@@ -1557,54 +1741,29 @@ document.querySelectorAll("[data-service-title]").forEach((button) => {
document.querySelector("#menuBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.remove("hidden");
openDrawerSection("carsSection");
});
document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
openDrawerSection("carFormSection");
});
document.querySelector("#openCarFormBtn").addEventListener("click", () => {
document.querySelector("#carFormSection").classList.remove("hidden");
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
openDrawerSection("expensesSection");
});
document.querySelector("#openCarProfileBtn").addEventListener("click", openCarProfile);
document.querySelector("#openSettingsBtn").addEventListener("click", () => {
document.querySelector("#settingsSection").classList.remove("hidden");
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#settingsSection").scrollIntoView({ behavior: "smooth", block: "start" });
document.querySelectorAll("[data-menu-section]").forEach((button) => {
button.addEventListener("click", async (event) => {
await runAction(event.currentTarget, "Обновляю данные...", async () => {
await openDrawerSection(event.currentTarget.dataset.menuSection);
});
});
});
document.querySelector("#openNotificationsBtn").addEventListener("click", () => {
document.querySelector("#notificationsSection").classList.remove("hidden");
updateNotificationStatus(
"Notification" in window && Notification.permission === "granted"
? "Уведомления включены"
: "Напомним о ТО, страховке и регулярном внесении пробега.",
);
document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openConfirmationsBtn").addEventListener("click", () => {
document.querySelector("#confirmationsSection").classList.remove("hidden");
renderPlaceholderList("#confirmationRequests", "Новых запросов нет");
document.querySelector("#confirmationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openConnectedServicesBtn").addEventListener("click", () => {
document.querySelector("#connectedServicesSection").classList.remove("hidden");
renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет");
document.querySelector("#connectedServicesSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openServicePanelBtn").addEventListener("click", async (event) => {
await runAction(event.currentTarget, "Загружаю СТО...", async () => {
document.querySelector("#servicePanelSection").classList.remove("hidden");
await loadServiceCenters();
document.querySelector("#servicePanelSection").scrollIntoView({ behavior: "smooth", block: "start" });
document.querySelectorAll("[data-expense-preset]").forEach((button) => {
button.addEventListener("click", () => {
openDrawerSection("expensesSection");
presetExpense(button.dataset.expensePreset);
});
});
@@ -1622,6 +1781,12 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e
city: data.city || null,
address: data.address || null,
phone: data.phone || null,
contact_person: data.contact_person || null,
description: data.description || null,
specializations: data.specializations
? data.specializations.split(",").map((item) => item.trim()).filter(Boolean)
: null,
working_hours: data.working_hours || null,
business_registration_number: data.business_registration_number || null,
}),
});
@@ -1633,7 +1798,7 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
document.querySelector("#openScanBtn").addEventListener("click", () => {
document.querySelector("#fuelScanBtn").addEventListener("click", () => {
openScanModal();
});
@@ -1676,7 +1841,7 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
payload.append("file", file);
const response = await fetch("/api/ocr/parse-text-receipt", {
method: "POST",
headers: tg?.initData ? { "X-Telegram-Init-Data": tg.initData } : {},
headers: authHeaders(),
body: payload,
});
if (!response.ok) throw new Error(await response.text());
@@ -1686,8 +1851,8 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
if (result.liters) fuelForm.liters.value = result.liters;
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
if (result.station) fuelForm.station.value = result.station;
setAction("fuel");
document.querySelector("#scanModal").classList.add("hidden");
await openDrawerSection("fuelSection");
toast("Проверь распознанные значения");
haptic("success");
});
@@ -1712,6 +1877,8 @@ Promise.all([loadAuthConfig()])
.then(() => {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";
mountEntryForms();
applyTranslations();
initCarCatalog();
return loadCars();

View File

@@ -910,6 +910,10 @@ select:disabled {
color: var(--service);
}
.record .expense {
color: var(--warning);
}
.empty {
color: var(--muted);
padding: 18px 0;
@@ -1144,6 +1148,25 @@ button.is-busy {
var(--surface);
}
.primary-add-btn {
width: 100%;
min-height: 54px;
margin: 0 0 16px;
font-size: 16px;
background: linear-gradient(135deg, #12735f, #2f6f9f);
}
.menu-row.active {
border-color: rgba(18, 115, 95, 0.45);
background: #e7f4ef;
color: #0e604f;
}
.drawer-cars {
max-height: 320px;
overflow: auto;
}
.summary-card::after {
display: none;
}