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 км по полным данным + на ближайший месяц
+
-
+