diff --git a/.env.example b/.env.example
index 4afe472..bffbb7a 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/Dockerfile b/Dockerfile
index 0359682..58c0205 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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 ./
diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md
index cd4cad9..08cff21 100644
--- a/PROJECT_PLAN.md
+++ b/PROJECT_PLAN.md
@@ -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
diff --git a/README.md b/README.md
index 04c3e4d..c3ebd51 100644
--- a/README.md
+++ b/README.md
@@ -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 превращает хаотичные чеки и заметки в понятную картину расходов и обслуживания. Для сервиса это аккуратный канал взаимодействия с клиентом, подтвержденная история работ и доверие без лишнего доступа к персональным данным.
diff --git a/alembic/env.py b/alembic/env.py
index be6392f..1d1cb7f 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -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
diff --git a/alembic/versions/202605110001_initial_schema.py b/alembic/versions/202605110001_initial_schema.py
index a933285..9c0690f 100644
--- a/alembic/versions/202605110001_initial_schema.py
+++ b/alembic/versions/202605110001_initial_schema.py
@@ -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"
diff --git a/alembic/versions/202605110002_car_catalog.py b/alembic/versions/202605110002_car_catalog.py
index 787b355..d3e200c 100644
--- a/alembic/versions/202605110002_car_catalog.py
+++ b/alembic/versions/202605110002_car_catalog.py
@@ -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"
diff --git a/alembic/versions/202605110003_user_preferences.py b/alembic/versions/202605110003_user_preferences.py
index e5bf7c1..6184fe6 100644
--- a/alembic/versions/202605110003_user_preferences.py
+++ b/alembic/versions/202605110003_user_preferences.py
@@ -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"
diff --git a/alembic/versions/202605120001_push_subscriptions.py b/alembic/versions/202605120001_push_subscriptions.py
index 6e616fa..7738a3d 100644
--- a/alembic/versions/202605120001_push_subscriptions.py
+++ b/alembic/versions/202605120001_push_subscriptions.py
@@ -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"
diff --git a/alembic/versions/202605120002_car_trims.py b/alembic/versions/202605120002_car_trims.py
index f8b465c..354cccd 100644
--- a/alembic/versions/202605120002_car_trims.py
+++ b/alembic/versions/202605120002_car_trims.py
@@ -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"
diff --git a/alembic/versions/202605120003_vehicle_service_profile.py b/alembic/versions/202605120003_vehicle_service_profile.py
index c484da5..1c679a7 100644
--- a/alembic/versions/202605120003_vehicle_service_profile.py
+++ b/alembic/versions/202605120003_vehicle_service_profile.py
@@ -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"
diff --git a/alembic/versions/202605120005_platform_service_visits.py b/alembic/versions/202605120005_platform_service_visits.py
index 4f37fe7..28bb4f0 100644
--- a/alembic/versions/202605120005_platform_service_visits.py
+++ b/alembic/versions/202605120005_platform_service_visits.py
@@ -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"
diff --git a/alembic/versions/202605120006_gamification_scores.py b/alembic/versions/202605120006_gamification_scores.py
index 3d07bc8..00027f1 100644
--- a/alembic/versions/202605120006_gamification_scores.py
+++ b/alembic/versions/202605120006_gamification_scores.py
@@ -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
diff --git a/alembic/versions/202605140001_product_expenses_service_reviews.py b/alembic/versions/202605140001_product_expenses_service_reviews.py
new file mode 100644
index 0000000..1161875
--- /dev/null
+++ b/alembic/versions/202605140001_product_expenses_service_reviews.py
@@ -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)
diff --git a/app/api/admin.py b/app/api/admin.py
index ceba745..692b24d 100644
--- a/app/api/admin.py
+++ b/app/api/admin.py
@@ -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)
diff --git a/app/api/entries.py b/app/api/entries.py
index 89c0370..785695d 100644
--- a/app/api/entries.py
+++ b/app/api/entries.py
@@ -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,
diff --git a/app/api/ocr.py b/app/api/ocr.py
index 841fdf8..feb565d 100644
--- a/app/api/ocr.py
+++ b/app/api/ocr.py
@@ -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":
- return ReceiptSuggestion(
- confidence=0,
- message="OCR по фото/PDF пока не подключен. Загрузите текстовый чек или заполните поля вручную.",
- )
+ result = await get_ocr_provider().recognize(content, file.filename)
+ if not result.recognized_text:
+ return ReceiptSuggestion(
+ confidence=0,
+ 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,
)
diff --git a/app/api/service_centers.py b/app/api/service_centers.py
index d8385e1..8c898ea 100644
--- a/app/api/service_centers.py
+++ b/app/api/service_centers.py
@@ -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)
diff --git a/app/core/config.py b/app/core/config.py
index e245c48..ba9a357 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -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")
diff --git a/app/models/car.py b/app/models/car.py
index 741bf6c..2d1e6d6 100644
--- a/app/models/car.py
+++ b/app/models/car.py
@@ -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"
diff --git a/app/models/expense.py b/app/models/expense.py
index 22554f1..bc335c3 100644
--- a/app/models/expense.py
+++ b/app/models/expense.py
@@ -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")
diff --git a/app/schemas/car.py b/app/schemas/car.py
index 126d384..c28a129 100644
--- a/app/schemas/car.py
+++ b/app/schemas/car.py
@@ -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
diff --git a/app/schemas/expense.py b/app/schemas/expense.py
index e292e78..a0e81e0 100644
--- a/app/schemas/expense.py
+++ b/app/schemas/expense.py
@@ -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
diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py
index c0d065e..36ba829 100644
--- a/app/schemas/service_center.py
+++ b/app/schemas/service_center.py
@@ -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)
diff --git a/app/services/calculations.py b/app/services/calculations.py
index 4c7e2bc..ef73311 100644
--- a/app/services/calculations.py
+++ b/app/services/calculations.py
@@ -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:
diff --git a/app/services/ocr_provider.py b/app/services/ocr_provider.py
index 3304cad..930caad 100644
--- a/app/services/ocr_provider.py
+++ b/app/services/ocr_provider.py
@@ -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"),
- ]
+ 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:
+ 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()
)
- 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()):
- try:
- candidates.append(OcrCandidate(type="vin", value=validate_vin(raw) or raw, confidence=0.84))
- except ValueError:
- continue
- for raw in re.findall(r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b", compact.upper()):
+
+ 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
+ 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()
diff --git a/app/services/scoring.py b/app/services/scoring.py
index 9ce326d..a41a244 100644
--- a/app/services/scoring.py
+++ b/app/services/scoring.py
@@ -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"
diff --git a/bot/main.py b/bot/main.py
index fe0fddf..f62422a 100644
--- a/bot/main.py
+++ b/bot/main.py
@@ -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(),
)
diff --git a/docker-compose.yml b/docker-compose.yml
index c962cb5..0696e1f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index 3af7b58..886ee91 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/tests/conftest.py b/tests/conftest.py
index 417550a..047789d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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}
diff --git a/tests/test_entries.py b/tests/test_entries.py
index 8f04f2a..bda18af 100644
--- a/tests/test_entries.py
+++ b/tests/test_entries.py
@@ -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
diff --git a/tests/test_platform.py b/tests/test_platform.py
index f9dd52e..bc558c3 100644
--- a/tests/test_platform.py
+++ b/tests/test_platform.py
@@ -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(
diff --git a/web/index.html b/web/index.html
index 45fb100..1d30f62 100644
--- a/web/index.html
+++ b/web/index.html
@@ -22,7 +22,7 @@
- Если Telegram уже открыт, нажмите «Открыть гараж» в боте.
+ Mini App открывается кнопкой «Открыть CarPass» внутри бота.
@@ -39,21 +39,22 @@
- Автомобиль
+ Стоимость в месяц
Не выбран
Добавь авто или выбери из списка
- Расходы
+ Стоимость 1 км
0
- топливо, сервис и ремонты
+ по пробегу и расходам периода
- Средний расход
+ Прогноз расходов
-
- л/100 км по полным данным
+ на ближайший месяц
+
-
-