Improve CarPass product UX and service flows
This commit is contained in:
@@ -15,3 +15,7 @@ ALLOW_DEV_AUTH=false
|
|||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
|
OCR_PROVIDER=tesseract
|
||||||
|
OCR_LANGUAGES=eng+rus+kor
|
||||||
|
LLM_BASE_URL=
|
||||||
|
LLM_MODEL=
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- FastAPI serves JSON API and static Telegram Mini App files from `web/`.
|
- 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`.
|
- 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`.
|
- 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.
|
- 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
|
## 2. Target Product Model
|
||||||
|
|||||||
218
README.md
218
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.
|
- Стоимость владения за период, стоимость 1 км и прогноз ближайших расходов.
|
||||||
- `alembic/` - миграции PostgreSQL.
|
- Расход топлива по корректным полным бакам.
|
||||||
- `tests/` - базовые security/API тесты.
|
- Мягкий прогресс заполнения профиля авто: VIN, госномер, пробег, масло, параметры обслуживания.
|
||||||
|
- Бейджи качества истории без игровых очков и токсичных рейтингов.
|
||||||
|
- Напоминания о ТО, страховке и важных событиях.
|
||||||
|
- OCR чеков: фото или файл распознается, затем пользователь проверяет данные перед сохранением.
|
||||||
|
|
||||||
## Production Mini App
|
## Для СТО
|
||||||
|
|
||||||
Для production Mini App должен открываться только по публичному HTTPS-домену. Для текущего проекта:
|
- Регистрация автосервиса через Mini App.
|
||||||
|
- Заявка на проверку и статус модерации.
|
||||||
|
- Публичная карточка СТО после подтверждения.
|
||||||
|
- Отзывы, рейтинг и ответы сервиса.
|
||||||
|
- Запрос доступа к конкретному автомобилю только с подтверждением владельца.
|
||||||
|
- Добавление визитов, работ и рекомендаций с аудитом действий.
|
||||||
|
|
||||||
```text
|
## Безопасность данных
|
||||||
https://drivers.smartsoltech.kr
|
|
||||||
```
|
|
||||||
|
|
||||||
В BotFather нужно выполнить:
|
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||||
|
|
||||||
```text
|
## Telegram Mini App
|
||||||
/setdomain
|
|
||||||
@your_bot_username
|
|
||||||
drivers.smartsoltech.kr
|
|
||||||
```
|
|
||||||
|
|
||||||
Важно:
|
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
|
Для владельца CarPass превращает хаотичные чеки и заметки в понятную картину расходов и обслуживания. Для сервиса это аккуратный канал взаимодействия с клиентом, подтвержденная история работ и доверие без лишнего доступа к персональным данным.
|
||||||
|
|
||||||
```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/номеру. Поиск возвращает только минимальную маскированную карточку и пишет действие в аудит. Критичные изменения автомобиля проходят через запрос подтверждения владельцем.
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models import car, expense, gamification, push, user # noqa: F401
|
from app.models import car, expense, gamification, push, user # noqa: F401
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Create Date: 2026-05-11
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605110001"
|
revision: str = "202605110001"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Create Date: 2026-05-11
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605110002"
|
revision: str = "202605110002"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Create Date: 2026-05-11
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605110003"
|
revision: str = "202605110003"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Create Date: 2026-05-12
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605120001"
|
revision: str = "202605120001"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Create Date: 2026-05-12
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605120002"
|
revision: str = "202605120002"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Create Date: 2026-05-12
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605120003"
|
revision: str = "202605120003"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Create Date: 2026-05-12
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605120005"
|
revision: str = "202605120005"
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ Create Date: 2026-05-12 20:10:00.000000
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "202605120006"
|
revision: str = "202605120006"
|
||||||
down_revision: str | None = "202605120005"
|
down_revision: str | None = "202605120005"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -41,9 +41,9 @@ async def verify_service_center(
|
|||||||
center = await session.get(ServiceCenter, service_center_id)
|
center = await session.get(ServiceCenter, service_center_id)
|
||||||
if center is None:
|
if center is None:
|
||||||
raise HTTPException(status_code=404, detail="Service center not found")
|
raise HTTPException(status_code=404, detail="Service center not found")
|
||||||
center.verification_status = "verified"
|
center.verification_status = "approved"
|
||||||
center.verified_at = datetime.now(UTC)
|
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 log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(center)
|
await session.refresh(center)
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.api.deps import get_current_telegram_user
|
from app.api.deps import get_current_telegram_user
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import Car
|
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.models.user import User
|
||||||
from app.schemas.expense import (
|
from app.schemas.expense import (
|
||||||
|
ExpenseEntryCreate,
|
||||||
|
ExpenseEntryRead,
|
||||||
|
ExpenseEntryUpdate,
|
||||||
FuelEntryCreate,
|
FuelEntryCreate,
|
||||||
FuelEntryRead,
|
FuelEntryRead,
|
||||||
FuelEntryUpdate,
|
FuelEntryUpdate,
|
||||||
@@ -36,8 +39,8 @@ async def ensure_owned_car(session: AsyncSession, car_id: int, user: User) -> Ca
|
|||||||
|
|
||||||
|
|
||||||
async def ensure_entry_owner(
|
async def ensure_entry_owner(
|
||||||
session: AsyncSession, entry: FuelEntry | ServiceEntry | None, user: User
|
session: AsyncSession, entry: FuelEntry | ServiceEntry | ExpenseEntry | None, user: User
|
||||||
) -> FuelEntry | ServiceEntry:
|
) -> FuelEntry | ServiceEntry | ExpenseEntry:
|
||||||
if entry is None:
|
if entry is None:
|
||||||
raise HTTPException(status_code=404, detail="Entry not found")
|
raise HTTPException(status_code=404, detail="Entry not found")
|
||||||
await ensure_owned_car(session, entry.car_id, user)
|
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())
|
.order_by(ServiceEntry.odometer.desc())
|
||||||
.limit(1)
|
.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 = [
|
values = [
|
||||||
value
|
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
|
if value is not None
|
||||||
]
|
]
|
||||||
car.current_odometer = max(values) if values else None
|
car.current_odometer = max(values) if values else None
|
||||||
@@ -212,6 +225,79 @@ async def delete_service_entry(
|
|||||||
await session.commit()
|
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)
|
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
|
||||||
async def car_stats(
|
async def car_stats(
|
||||||
car_id: int,
|
car_id: int,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class OCRCandidateRead(BaseModel):
|
|||||||
class OCRResultRead(BaseModel):
|
class OCRResultRead(BaseModel):
|
||||||
recognized_text: str
|
recognized_text: str
|
||||||
candidates: list[OCRCandidateRead]
|
candidates: list[OCRCandidateRead]
|
||||||
|
provider: str = "heuristic"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||||
@@ -39,16 +40,23 @@ async def parse_text_receipt(
|
|||||||
content = await file.read()
|
content = await file.read()
|
||||||
content_type = (file.content_type or "").lower()
|
content_type = (file.content_type or "").lower()
|
||||||
if content_type.startswith("image/") or content_type == "application/pdf":
|
if content_type.startswith("image/") or content_type == "application/pdf":
|
||||||
|
result = await get_ocr_provider().recognize(content, file.filename)
|
||||||
|
if not result.recognized_text:
|
||||||
return ReceiptSuggestion(
|
return ReceiptSuggestion(
|
||||||
confidence=0,
|
confidence=0,
|
||||||
message="OCR по фото/PDF пока не подключен. Загрузите текстовый чек или заполните поля вручную.",
|
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||||
)
|
)
|
||||||
|
return parse_receipt_text(result.recognized_text)
|
||||||
text = " ".join(
|
text = " ".join(
|
||||||
[
|
[
|
||||||
file.filename or "",
|
file.filename or "",
|
||||||
content.decode("utf-8", errors="ignore"),
|
content.decode("utf-8", errors="ignore"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
return parse_receipt_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_receipt_text(text: str) -> ReceiptSuggestion:
|
||||||
normalized = text.replace("\xa0", " ").replace(",", ".")
|
normalized = text.replace("\xa0", " ").replace(",", ".")
|
||||||
compact = re.sub(r"\s+", " ", normalized).strip()
|
compact = re.sub(r"\s+", " ", normalized).strip()
|
||||||
numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)]
|
numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)]
|
||||||
@@ -102,6 +110,7 @@ async def recognize_license_plate(
|
|||||||
return OCRResultRead(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
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(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
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(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||||
|
provider=result.provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
@@ -14,6 +16,8 @@ from app.models.car import (
|
|||||||
Car,
|
Car,
|
||||||
CarServiceLink,
|
CarServiceLink,
|
||||||
ServiceCenter,
|
ServiceCenter,
|
||||||
|
ServiceCenterReview,
|
||||||
|
ServiceCenterReviewComment,
|
||||||
ServiceCenterVerification,
|
ServiceCenterVerification,
|
||||||
ServiceEmployee,
|
ServiceEmployee,
|
||||||
ServiceInboxMessage,
|
ServiceInboxMessage,
|
||||||
@@ -23,8 +27,14 @@ from app.models.user import User
|
|||||||
from app.schemas.service_center import (
|
from app.schemas.service_center import (
|
||||||
CarServiceLinkCreate,
|
CarServiceLinkCreate,
|
||||||
CarServiceLinkRead,
|
CarServiceLinkRead,
|
||||||
|
ServiceCenterAccessRequest,
|
||||||
ServiceCenterCreate,
|
ServiceCenterCreate,
|
||||||
|
ServiceCenterPublicRead,
|
||||||
ServiceCenterRead,
|
ServiceCenterRead,
|
||||||
|
ServiceCenterReviewCommentCreate,
|
||||||
|
ServiceCenterReviewCommentRead,
|
||||||
|
ServiceCenterReviewCreate,
|
||||||
|
ServiceCenterReviewRead,
|
||||||
ServiceCenterVerificationCreate,
|
ServiceCenterVerificationCreate,
|
||||||
ServiceCenterVerificationRead,
|
ServiceCenterVerificationRead,
|
||||||
ServiceEmployeeInvite,
|
ServiceEmployeeInvite,
|
||||||
@@ -40,6 +50,8 @@ from app.services.vehicle_identity import mask_license_plate, mask_vin
|
|||||||
|
|
||||||
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
|
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
|
||||||
|
|
||||||
|
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
|
||||||
|
|
||||||
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_service_center(
|
async def create_service_center(
|
||||||
payload: ServiceCenterCreate,
|
payload: ServiceCenterCreate,
|
||||||
@@ -57,6 +69,13 @@ async def create_service_center(
|
|||||||
contact_phone=payload.contact_phone or payload.phone,
|
contact_phone=payload.contact_phone or payload.phone,
|
||||||
telegram_chat_id=payload.telegram_chat_id,
|
telegram_chat_id=payload.telegram_chat_id,
|
||||||
business_registration_number=payload.business_registration_number,
|
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,
|
owner_user_id=current_user.id,
|
||||||
verification_status="pending",
|
verification_status="pending",
|
||||||
)
|
)
|
||||||
@@ -89,6 +108,51 @@ async def my_service_centers(
|
|||||||
return list(result.scalars())
|
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])
|
@router.get("", response_model=list[ServiceCenterRead])
|
||||||
async def list_service_centers(
|
async def list_service_centers(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -173,6 +237,45 @@ async def service_center_visits(
|
|||||||
return list(result.scalars())
|
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)
|
@router.post("/{service_center_id}/visits", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_visit(
|
async def create_visit(
|
||||||
service_center_id: int,
|
service_center_id: int,
|
||||||
@@ -184,9 +287,8 @@ async def create_visit(
|
|||||||
vehicle = await session.get(Car, payload.vehicle_id)
|
vehicle = await session.get(Car, payload.vehicle_id)
|
||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
center = await session.get(ServiceCenter, service_center_id)
|
await ensure_service_center_approved(session, service_center_id)
|
||||||
if center and center.verification_status not in {"verified", "pending"}:
|
await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user)
|
||||||
raise HTTPException(status_code=403, detail="Service center is not allowed to create visits")
|
|
||||||
visit = ServiceVisit(
|
visit = ServiceVisit(
|
||||||
service_center_id=service_center_id,
|
service_center_id=service_center_id,
|
||||||
vehicle_id=payload.vehicle_id,
|
vehicle_id=payload.vehicle_id,
|
||||||
@@ -213,6 +315,7 @@ async def request_vehicle_access(
|
|||||||
current_user: User = Depends(get_current_telegram_user),
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> VehicleSearchResult:
|
) -> VehicleSearchResult:
|
||||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
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)
|
stmt = select(Car)
|
||||||
if payload.vin:
|
if payload.vin:
|
||||||
stmt = stmt.where(Car.vin_normalized == 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,
|
target_id=vehicle.id if vehicle else None,
|
||||||
metadata={"service_center_id": service_center_id, "found": bool(vehicle)},
|
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()
|
await session.commit()
|
||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
return VehicleSearchResult(access_status="not_found")
|
return VehicleSearchResult(access_status="not_found")
|
||||||
@@ -241,10 +355,242 @@ async def request_vehicle_access(
|
|||||||
year=vehicle.year,
|
year=vehicle.year,
|
||||||
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
|
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
|
||||||
masked_vin=mask_vin(vehicle.vin_normalized or vehicle.vin),
|
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)
|
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def link_car_to_service(
|
async def link_car_to_service(
|
||||||
payload: CarServiceLinkCreate,
|
payload: CarServiceLinkCreate,
|
||||||
@@ -256,7 +602,7 @@ async def link_car_to_service(
|
|||||||
raise HTTPException(status_code=404, detail="Car not found")
|
raise HTTPException(status_code=404, detail="Car not found")
|
||||||
if await session.get(ServiceCenter, payload.service_center_id) is None:
|
if await session.get(ServiceCenter, payload.service_center_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Service center not found")
|
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)
|
session.add(link)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(link)
|
await session.refresh(link)
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class Settings(BaseSettings):
|
|||||||
internal_api_token: str = ""
|
internal_api_token: str = ""
|
||||||
vapid_public_key: str = ""
|
vapid_public_key: str = ""
|
||||||
allow_dev_auth: bool = False
|
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")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
JSON,
|
||||||
|
Boolean,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
@@ -47,6 +48,8 @@ class Car(Base):
|
|||||||
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
||||||
purchase_date: Mapped[date | None] = mapped_column(Date)
|
purchase_date: Mapped[date | None] = mapped_column(Date)
|
||||||
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
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]
|
current_odometer: Mapped[int | None]
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -56,6 +59,7 @@ class Car(Base):
|
|||||||
owner = relationship("User", back_populates="cars")
|
owner = relationship("User", back_populates="cars")
|
||||||
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
|
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
|
||||||
service_entries = relationship("ServiceEntry", 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")
|
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))
|
phone: Mapped[str | None] = mapped_column(String(40))
|
||||||
contact_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))
|
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))
|
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)
|
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)
|
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")
|
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
|
||||||
employees = relationship("ServiceEmployee", back_populates="service_center", cascade="all, delete-orphan")
|
employees = relationship("ServiceEmployee", back_populates="service_center", cascade="all, delete-orphan")
|
||||||
visits = relationship("ServiceVisit", back_populates="service_center")
|
visits = relationship("ServiceVisit", back_populates="service_center")
|
||||||
|
reviews = relationship("ServiceCenterReview", back_populates="service_center", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class CarServiceLink(Base):
|
class CarServiceLink(Base):
|
||||||
@@ -136,6 +150,12 @@ class CarServiceLink(Base):
|
|||||||
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
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)
|
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)
|
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)
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
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")
|
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):
|
class VehicleDataChangeRequest(Base):
|
||||||
__tablename__ = "vehicle_data_change_requests"
|
__tablename__ = "vehicle_data_change_requests"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import enum
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -19,6 +19,29 @@ class ServiceType(str, enum.Enum):
|
|||||||
other = "other"
|
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):
|
class FuelEntry(Base):
|
||||||
__tablename__ = "fuel_entries"
|
__tablename__ = "fuel_entries"
|
||||||
|
|
||||||
@@ -56,3 +79,25 @@ class ServiceEntry(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
car = relationship("Car", back_populates="service_entries")
|
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")
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class CarBase(BaseModel):
|
|||||||
tire_pressure_rear_bar: Decimal | None = None
|
tire_pressure_rear_bar: Decimal | None = None
|
||||||
purchase_date: date | None = None
|
purchase_date: date | None = None
|
||||||
purchase_price: Decimal | None = None
|
purchase_price: Decimal | None = None
|
||||||
|
currency: str = "RUB"
|
||||||
|
include_depreciation: bool = False
|
||||||
current_odometer: int | None = None
|
current_odometer: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ class CarUpdate(BaseModel):
|
|||||||
tire_pressure_rear_bar: Decimal | None = None
|
tire_pressure_rear_bar: Decimal | None = None
|
||||||
purchase_date: date | None = None
|
purchase_date: date | None = None
|
||||||
purchase_price: Decimal | None = None
|
purchase_price: Decimal | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
include_depreciation: bool | None = None
|
||||||
current_odometer: int | None = None
|
current_odometer: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
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):
|
class FuelEntryBase(BaseModel):
|
||||||
@@ -87,6 +87,62 @@ class ServiceEntryRead(ServiceEntryBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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):
|
class OwnershipStats(BaseModel):
|
||||||
car_id: int
|
car_id: int
|
||||||
date_from: date
|
date_from: date
|
||||||
@@ -94,6 +150,14 @@ class OwnershipStats(BaseModel):
|
|||||||
fuel_cost: Decimal
|
fuel_cost: Decimal
|
||||||
service_cost: Decimal
|
service_cost: Decimal
|
||||||
total_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
|
liters: Decimal
|
||||||
distance_km: int
|
distance_km: int
|
||||||
avg_consumption_l_per_100km: float | None
|
avg_consumption_l_per_100km: float | None
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
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
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||||
|
|
||||||
@@ -16,6 +16,13 @@ class ServiceCenterCreate(BaseModel):
|
|||||||
business_registration_number: str | None = None
|
business_registration_number: str | None = None
|
||||||
telegram_chat_id: str | None = None
|
telegram_chat_id: str | None = None
|
||||||
contact_phone: 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):
|
class ServiceCenterRead(ServiceCenterCreate):
|
||||||
@@ -26,6 +33,27 @@ class ServiceCenterRead(ServiceCenterCreate):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
verified_at: datetime | None = None
|
verified_at: datetime | None = None
|
||||||
suspended_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)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -91,8 +119,15 @@ class VehicleCreate(BaseModel):
|
|||||||
license_plate_country: str | None = None
|
license_plate_country: str | None = None
|
||||||
vin: str | None = None
|
vin: str | None = None
|
||||||
current_odometer: int | None = None
|
current_odometer: int | None = None
|
||||||
|
fuel_type: str | None = None
|
||||||
engine_oil_type: str | None = None
|
engine_oil_type: str | None = None
|
||||||
engine_oil_volume_l: Decimal | 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")
|
@field_validator("vin")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -109,6 +144,13 @@ class VehicleUpdate(BaseModel):
|
|||||||
license_plate_country: str | None = None
|
license_plate_country: str | None = None
|
||||||
vin: str | None = None
|
vin: str | None = None
|
||||||
current_odometer: int | 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_type: str | None = None
|
||||||
engine_oil_volume_l: Decimal | None = None
|
engine_oil_volume_l: Decimal | None = None
|
||||||
|
|
||||||
@@ -129,6 +171,13 @@ class VehicleRead(BaseModel):
|
|||||||
license_plate_country: str | None = None
|
license_plate_country: str | None = None
|
||||||
vin_normalized: str | None = None
|
vin_normalized: str | None = None
|
||||||
current_odometer: int | 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_type: str | None = None
|
||||||
engine_oil_volume_l: Decimal | None = None
|
engine_oil_volume_l: Decimal | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -226,11 +275,70 @@ class CarServiceLinkCreate(BaseModel):
|
|||||||
car_id: int
|
car_id: int
|
||||||
service_center_id: int
|
service_center_id: int
|
||||||
external_vehicle_ref: str | None = None
|
external_vehicle_ref: str | None = None
|
||||||
|
access_level: str = "basic"
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class CarServiceLinkRead(CarServiceLinkCreate):
|
class CarServiceLinkRead(CarServiceLinkCreate):
|
||||||
id: int
|
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
|
created_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from datetime import date
|
import calendar
|
||||||
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from sqlalchemy import Select, func, select
|
from sqlalchemy import Select, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
from app.schemas.expense import OdometerPrediction, OwnershipStats
|
||||||
|
|
||||||
|
|
||||||
@@ -36,10 +38,56 @@ async def get_ownership_stats(
|
|||||||
)
|
)
|
||||||
service_cost, service_count = service_totals.one()
|
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
|
odometer_values = [min_odo, max_odo]
|
||||||
total_cost = Decimal(fuel_cost) + Decimal(service_cost)
|
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)
|
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
|
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(
|
return OwnershipStats(
|
||||||
car_id=car_id,
|
car_id=car_id,
|
||||||
@@ -47,7 +95,15 @@ async def get_ownership_stats(
|
|||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
fuel_cost=fuel_cost,
|
fuel_cost=fuel_cost,
|
||||||
service_cost=service_cost,
|
service_cost=service_cost,
|
||||||
|
expenses_cost=expense_cost,
|
||||||
total_cost=total_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,
|
liters=liters,
|
||||||
distance_km=distance_km,
|
distance_km=distance_km,
|
||||||
avg_consumption_l_per_100km=avg_consumption,
|
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(
|
async def full_tank_consumption(
|
||||||
session: AsyncSession, car_id: int, date_from: date, date_to: date
|
session: AsyncSession, car_id: int, date_from: date, date_to: date
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
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
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||||
|
|
||||||
|
|
||||||
@@ -15,34 +20,95 @@ class OcrCandidate:
|
|||||||
class OcrResult:
|
class OcrResult:
|
||||||
recognized_text: str
|
recognized_text: str
|
||||||
candidates: list[OcrCandidate]
|
candidates: list[OcrCandidate]
|
||||||
|
provider: str = "heuristic"
|
||||||
|
|
||||||
|
|
||||||
class OCRProvider:
|
class OCRProvider(Protocol):
|
||||||
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
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:
|
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||||
text = " ".join(
|
text = " ".join([filename or "", content.decode("utf-8", errors="ignore")])
|
||||||
[
|
return build_ocr_result(text, provider=self.provider_name, base_confidence=0.62)
|
||||||
filename or "",
|
|
||||||
content.decode("utf-8", errors="ignore"),
|
|
||||||
]
|
class TesseractOCRProvider:
|
||||||
)
|
provider_name = "tesseract"
|
||||||
compact = re.sub(r"\s+", " ", text).strip()
|
|
||||||
candidates: list[OcrCandidate] = []
|
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||||
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", compact.upper()):
|
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:
|
try:
|
||||||
candidates.append(OcrCandidate(type="vin", value=validate_vin(raw) or raw, confidence=0.84))
|
import pytesseract
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
image = Image.open(BytesIO(content))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return pytesseract.image_to_string(image, lang=settings.ocr_languages)
|
||||||
|
except Exception:
|
||||||
|
return pytesseract.image_to_string(image)
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeOCRProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
provider = settings.ocr_provider.lower()
|
||||||
|
self.primary: OCRProvider = (
|
||||||
|
TextHeuristicOCRProvider() if provider == "heuristic" else TesseractOCRProvider()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||||
|
return await self.primary.recognize(content, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ocr_result(text: str, *, provider: str, base_confidence: float) -> OcrResult:
|
||||||
|
compact = re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
|
||||||
|
candidates: list[OcrCandidate] = []
|
||||||
|
upper = compact.upper()
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", upper):
|
||||||
|
try:
|
||||||
|
value = validate_vin(raw) or raw
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
for raw in re.findall(r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b", compact.upper()):
|
key = ("vin", value)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
candidates.append(OcrCandidate(type="vin", value=value, confidence=min(base_confidence + 0.12, 0.95)))
|
||||||
|
|
||||||
|
plate_patterns = [
|
||||||
|
r"\b\d{2,3}\s*[가-힣]\s*\d{4}\b",
|
||||||
|
r"\b[A-ZА-Я]{1}\s?\d{3}\s?[A-ZА-Я]{2}\s?\d{2,3}\b",
|
||||||
|
r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b",
|
||||||
|
]
|
||||||
|
for pattern in plate_patterns:
|
||||||
|
for raw in re.findall(pattern, upper):
|
||||||
normalized = normalize_license_plate(raw)
|
normalized = normalize_license_plate(raw)
|
||||||
if normalized and 5 <= len(normalized) <= 10 and not any(item.value == normalized for item in candidates):
|
if normalized and 5 <= len(normalized) <= 10:
|
||||||
candidates.append(OcrCandidate(type="license_plate", value=normalized, confidence=0.62))
|
key = ("license_plate", normalized)
|
||||||
return OcrResult(recognized_text=compact, candidates=candidates[:8])
|
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:
|
def get_ocr_provider() -> OCRProvider:
|
||||||
return StubOCRProvider()
|
return CompositeOCRProvider()
|
||||||
|
|||||||
@@ -436,13 +436,13 @@ async def compute_service_center_score(session: AsyncSession, center: ServiceCen
|
|||||||
confirmation_rate = Decimal(len(confirmed) * 100) / Decimal(len(relevant))
|
confirmation_rate = Decimal(len(confirmed) * 100) / Decimal(len(relevant))
|
||||||
dispute_rate = Decimal(len(disputed) * 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 += min(30, len(confirmed) * 5)
|
||||||
score += int(min(30, confirmation_rate * Decimal("0.3")))
|
score += int(min(30, confirmation_rate * Decimal("0.3")))
|
||||||
score -= int(min(25, dispute_rate * Decimal("0.5")))
|
score -= int(min(25, dispute_rate * Decimal("0.5")))
|
||||||
score = max(0, min(100, score))
|
score = max(0, min(100, score))
|
||||||
|
|
||||||
if center.verification_status != "verified":
|
if center.verification_status not in {"approved", "verified"}:
|
||||||
level = "new_service"
|
level = "new_service"
|
||||||
elif score >= 85:
|
elif score >= 85:
|
||||||
level = "high_confidence_service"
|
level = "high_confidence_service"
|
||||||
|
|||||||
28
bot/main.py
28
bot/main.py
@@ -25,7 +25,7 @@ api = ApiClient()
|
|||||||
def main_keyboard() -> ReplyKeyboardMarkup:
|
def main_keyboard() -> ReplyKeyboardMarkup:
|
||||||
return ReplyKeyboardMarkup(
|
return ReplyKeyboardMarkup(
|
||||||
keyboard=[
|
keyboard=[
|
||||||
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))],
|
[KeyboardButton(text="Открыть CarPass")],
|
||||||
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
|
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
|
||||||
],
|
],
|
||||||
resize_keyboard=True,
|
resize_keyboard=True,
|
||||||
@@ -49,7 +49,7 @@ async def start(message: Message) -> None:
|
|||||||
"Нажми «Открыть CarPass», чтобы перейти в приложение."
|
"Нажми «Открыть CarPass», чтобы перейти в приложение."
|
||||||
)
|
)
|
||||||
await message.answer(text, reply_markup=webapp_inline_keyboard())
|
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"))
|
@dp.message(Command("add_car"))
|
||||||
@@ -104,11 +104,25 @@ async def show_stats(callback: CallbackQuery) -> None:
|
|||||||
@dp.message(Command("help"))
|
@dp.message(Command("help"))
|
||||||
async def help_message(message: Message) -> None:
|
async def help_message(message: Message) -> None:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Команды:\n"
|
"CarPass помогает вести цифровой паспорт автомобиля.\n\n"
|
||||||
"/add_car Название - быстро добавить авто\n"
|
"Что можно делать:\n"
|
||||||
"/cars - список авто и статистика\n\n"
|
"• добавлять автомобили и параметры обслуживания;\n"
|
||||||
"Заправки, ремонты и обслуживание удобнее вести через кнопку «Открыть гараж».",
|
"• вести заправки, ТО, ремонт, страховку, налоги и штрафы;\n"
|
||||||
reply_markup=main_keyboard(),
|
"• видеть стоимость владения, стоимость 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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ services:
|
|||||||
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||||
APP_ENV: ${APP_ENV:-development}
|
APP_ENV: ${APP_ENV:-development}
|
||||||
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
|
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:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -52,6 +56,10 @@ services:
|
|||||||
PUBLIC_WEBAPP_URL: ${PUBLIC_WEBAPP_URL:-}
|
PUBLIC_WEBAPP_URL: ${PUBLIC_WEBAPP_URL:-}
|
||||||
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||||
APP_ENV: ${APP_ENV:-development}
|
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:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"matplotlib>=3.8,<4.0",
|
"matplotlib>=3.8,<4.0",
|
||||||
"pandas>=2.2,<3.0",
|
"pandas>=2.2,<3.0",
|
||||||
"pydantic-settings>=2.2,<3.0",
|
"pydantic-settings>=2.2,<3.0",
|
||||||
|
"pytesseract>=0.3.13,<1.0",
|
||||||
"python-multipart>=0.0.9,<1.0",
|
"python-multipart>=0.0.9,<1.0",
|
||||||
"sqlalchemy[asyncio]>=2.0,<3.0",
|
"sqlalchemy[asyncio]>=2.0,<3.0",
|
||||||
"uvicorn[standard]>=0.29,<1.0",
|
"uvicorn[standard]>=0.29,<1.0",
|
||||||
|
|||||||
@@ -69,3 +69,13 @@ def auth_headers() -> dict[str, str]:
|
|||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def other_auth_headers() -> dict[str, str]:
|
def other_auth_headers() -> dict[str, str]:
|
||||||
return {"X-Telegram-Init-Data": make_init_data(2002)}
|
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}
|
||||||
|
|||||||
@@ -87,3 +87,44 @@ async def test_stats_do_not_fail_with_insufficient_data(client, auth_headers) ->
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["avg_consumption_l_per_100km"] is None
|
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
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ async def test_vin_validation_rejects_invalid_value(client, auth_headers) -> Non
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 = (
|
vehicle = (
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/my/vehicles",
|
"/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={"display_name": "Careful Service", "country": "KR", "city": "Seoul"},
|
||||||
)
|
)
|
||||||
).json()
|
).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 = (
|
visit = (
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/service-centers/{center['id']}/visits",
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
|
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
|
|||||||
187
web/index.html
187
web/index.html
@@ -22,7 +22,7 @@
|
|||||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
|
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
|
||||||
<p class="auth-note" id="authNote">Если Telegram уже открыт, нажмите «Открыть гараж» в боте.</p>
|
<p class="auth-note" id="authNote">Mini App открывается кнопкой «Открыть CarPass» внутри бота.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
@@ -39,21 +39,22 @@
|
|||||||
|
|
||||||
<section class="hero-grid">
|
<section class="hero-grid">
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<span>Автомобиль</span>
|
<span>Стоимость в месяц</span>
|
||||||
<strong id="selectedCarTitle">Не выбран</strong>
|
<strong id="selectedCarTitle">Не выбран</strong>
|
||||||
<small id="selectedCarMeta">Добавь авто или выбери из списка</small>
|
<small id="selectedCarMeta">Добавь авто или выбери из списка</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card accent">
|
<div class="summary-card accent">
|
||||||
<span>Расходы</span>
|
<span>Стоимость 1 км</span>
|
||||||
<strong id="summaryTotal">0</strong>
|
<strong id="summaryTotal">0</strong>
|
||||||
<small>топливо, сервис и ремонты</small>
|
<small>по пробегу и расходам периода</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card blue">
|
<div class="summary-card blue">
|
||||||
<span>Средний расход</span>
|
<span>Прогноз расходов</span>
|
||||||
<strong id="summaryConsumption">-</strong>
|
<strong id="summaryConsumption">-</strong>
|
||||||
<small>л/100 км по полным данным</small>
|
<small>на ближайший месяц</small>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<button class="primary-add-btn" id="addRecordPrimaryBtn" type="button">Добавить запись</button>
|
||||||
|
|
||||||
<section class="layout">
|
<section class="layout">
|
||||||
<aside class="panel reveal">
|
<aside class="panel reveal">
|
||||||
@@ -104,11 +105,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="period-controls">
|
<div class="period-controls">
|
||||||
<select id="periodPreset" aria-label="Период отчета">
|
<select id="periodPreset" aria-label="Период отчета">
|
||||||
<option value="all">Весь срок</option>
|
<option value="7d">7 дней</option>
|
||||||
<option value="month">Месяц</option>
|
<option value="30d">30 дней</option>
|
||||||
<option value="day">День</option>
|
<option value="3m">3 месяца</option>
|
||||||
<option value="quarter">Квартал</option>
|
<option value="6m">6 месяцев</option>
|
||||||
<option value="year">Год</option>
|
<option value="12m">12 месяцев</option>
|
||||||
|
<option value="all">Весь период</option>
|
||||||
<option value="custom">Свой период</option>
|
<option value="custom">Свой период</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="periodFrom" type="date" aria-label="Дата начала" />
|
<input id="periodFrom" type="date" aria-label="Дата начала" />
|
||||||
@@ -118,7 +120,7 @@
|
|||||||
|
|
||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
|
|
||||||
<section class="quick-actions">
|
<section class="quick-actions hidden">
|
||||||
<button class="action-card active" data-action="fuel">
|
<button class="action-card active" data-action="fuel">
|
||||||
<span>Заправка</span>
|
<span>Заправка</span>
|
||||||
<strong>30 сек</strong>
|
<strong>30 сек</strong>
|
||||||
@@ -148,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form id="fuelForm" class="entry-form quick-form">
|
<form id="fuelForm" class="entry-form quick-form hidden">
|
||||||
<label>
|
<label>
|
||||||
Дата
|
Дата
|
||||||
<input name="entry_date" type="date" required />
|
<input name="entry_date" type="date" required />
|
||||||
@@ -242,14 +244,135 @@
|
|||||||
<h2>Меню</h2>
|
<h2>Меню</h2>
|
||||||
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
|
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="menu-row" id="openCarFormBtn">Добавить автомобиль</button>
|
<button class="menu-row" data-menu-section="carsSection">Автомобили</button>
|
||||||
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
|
<button class="menu-row" data-menu-section="carFormSection">Добавить авто</button>
|
||||||
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
|
<button class="menu-row" data-menu-section="carProfileSection">Параметры авто</button>
|
||||||
<button class="menu-row" id="openNotificationsBtn">Уведомления</button>
|
<button class="menu-row" data-menu-section="expensesSection">Расходы</button>
|
||||||
<button class="menu-row" id="openConfirmationsBtn">Запросы на подтверждение</button>
|
<button class="menu-row" data-menu-section="fuelSection">Заправки</button>
|
||||||
<button class="menu-row" id="openConnectedServicesBtn">Подключенные автосервисы</button>
|
<button class="menu-row" data-menu-section="serviceSection">ТО и ремонт</button>
|
||||||
<button class="menu-row" id="openServicePanelBtn">Панель СТО</button>
|
<button class="menu-row" data-menu-section="insuranceSection">Страховка</button>
|
||||||
<button class="menu-row" id="openScanBtn">Разобрать чек</button>
|
<button class="menu-row" data-menu-section="taxSection">Налоги</button>
|
||||||
|
<button class="menu-row" data-menu-section="fineSection">Штрафы</button>
|
||||||
|
<button class="menu-row" data-menu-section="publicServicesSection">СТО</button>
|
||||||
|
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
|
||||||
|
<button class="menu-row" data-menu-section="settingsSection">Настройки</button>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="carsSection">
|
||||||
|
<h2>Автомобили</h2>
|
||||||
|
<div id="drawerCars" class="cars drawer-cars"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="expensesSection">
|
||||||
|
<h2>Добавить расход</h2>
|
||||||
|
<form id="expenseForm" class="grid-form drawer-form">
|
||||||
|
<label>
|
||||||
|
Дата
|
||||||
|
<input name="entry_date" type="date" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Категория
|
||||||
|
<select name="category" id="expenseCategorySelect">
|
||||||
|
<option value="insurance">Страховка</option>
|
||||||
|
<option value="tax">Налог</option>
|
||||||
|
<option value="fine">Штраф</option>
|
||||||
|
<option value="parking">Парковка</option>
|
||||||
|
<option value="car_wash">Мойка</option>
|
||||||
|
<option value="toll">Платная дорога</option>
|
||||||
|
<option value="tires">Шины</option>
|
||||||
|
<option value="wheels">Диски</option>
|
||||||
|
<option value="battery">Аккумулятор</option>
|
||||||
|
<option value="parts">Запчасти</option>
|
||||||
|
<option value="repair">Ремонт</option>
|
||||||
|
<option value="maintenance">Плановое ТО</option>
|
||||||
|
<option value="diagnostics">Диагностика</option>
|
||||||
|
<option value="towing">Эвакуатор</option>
|
||||||
|
<option value="loan_payment">Кредит / лизинг</option>
|
||||||
|
<option value="loan_interest">Проценты по кредиту</option>
|
||||||
|
<option value="state_fee">Госпошлина</option>
|
||||||
|
<option value="registration">Регистрация</option>
|
||||||
|
<option value="inspection">Техосмотр</option>
|
||||||
|
<option value="other">Прочее</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input name="title" placeholder="ОСАГО / парковка / налог" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Стоимость
|
||||||
|
<input name="total_cost" type="number" min="0" step="0.01" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Валюта
|
||||||
|
<select name="currency">
|
||||||
|
<option value="RUB">RUB</option>
|
||||||
|
<option value="KRW">KRW</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Поставщик / место
|
||||||
|
<input name="vendor" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Одометр
|
||||||
|
<input name="odometer" type="number" min="0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Начало периода
|
||||||
|
<input name="period_start" type="date" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Конец периода
|
||||||
|
<input name="period_end" type="date" />
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input name="is_recurring" type="checkbox" />
|
||||||
|
Регулярный расход
|
||||||
|
</label>
|
||||||
|
<button type="submit">Сохранить расход</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="fuelSection">
|
||||||
|
<h2>Заправка</h2>
|
||||||
|
<div id="fuelFormMount"></div>
|
||||||
|
<button type="button" class="wide-btn ghost-btn" id="fuelScanBtn">Скан чека</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="serviceSection">
|
||||||
|
<h2>ТО и ремонт</h2>
|
||||||
|
<div id="serviceFormMount"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="insuranceSection">
|
||||||
|
<h2>Страховка</h2>
|
||||||
|
<div class="tip-card">Укажите период действия полиса, и CarPass распределит стоимость по месяцам.</div>
|
||||||
|
<button class="wide-btn" type="button" data-expense-preset="insurance">Добавить страховку</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="taxSection">
|
||||||
|
<h2>Налоги</h2>
|
||||||
|
<button class="wide-btn" type="button" data-expense-preset="tax">Добавить налог</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="fineSection">
|
||||||
|
<h2>Штрафы</h2>
|
||||||
|
<button class="wide-btn" type="button" data-expense-preset="fine">Добавить штраф</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="publicServicesSection">
|
||||||
|
<h2>СТО</h2>
|
||||||
|
<div class="tip-card">Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.</div>
|
||||||
|
<button class="wide-btn" type="button" data-menu-section="servicePanelSection">Зарегистрировать СТО</button>
|
||||||
|
<div id="publicServiceCenters" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="reviewsSection">
|
||||||
|
<h2>Отзывы</h2>
|
||||||
|
<div id="serviceReviews" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section hidden" id="settingsSection">
|
<section class="drawer-section hidden" id="settingsSection">
|
||||||
<h2>Настройки</h2>
|
<h2>Настройки</h2>
|
||||||
@@ -321,16 +444,32 @@
|
|||||||
Телефон
|
Телефон
|
||||||
<input name="phone" />
|
<input name="phone" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Контактное лицо
|
||||||
|
<input name="contact_person" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Специализация
|
||||||
|
<input name="specializations" placeholder="BMW, электрика, шиномонтаж" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
График работы
|
||||||
|
<input name="working_hours" placeholder="Пн-Сб 09:00-19:00" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<input name="description" placeholder="Коротко о сервисе" />
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Регистрационный номер
|
Регистрационный номер
|
||||||
<input name="business_registration_number" />
|
<input name="business_registration_number" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Создать СТО</button>
|
<button type="submit">Отправить заявку</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="serviceCentersList" class="stack-list"></div>
|
<div id="serviceCentersList" class="stack-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section" id="carFormSection">
|
<section class="drawer-section hidden" id="carFormSection">
|
||||||
<h2>Новое авто</h2>
|
<h2>Новое авто</h2>
|
||||||
<form id="carForm" class="grid-form drawer-form">
|
<form id="carForm" class="grid-form drawer-form">
|
||||||
<label>
|
<label>
|
||||||
@@ -447,7 +586,7 @@
|
|||||||
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
|
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
|
||||||
<button type="submit">Разобрать текст</button>
|
<button type="submit">Разобрать текст</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="ocrResult" class="tip-card">Сейчас разбираем текстовые чеки. OCR по фото будет подключен отдельно.</div>
|
<div id="ocrResult" class="tip-card">Загрузите фото или файл чека. CarPass распознает данные и предложит проверить их перед сохранением.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ const state = {
|
|||||||
selectedCarId: null,
|
selectedCarId: null,
|
||||||
latestFuel: [],
|
latestFuel: [],
|
||||||
latestService: [],
|
latestService: [],
|
||||||
|
latestExpenses: [],
|
||||||
latestStats: null,
|
latestStats: null,
|
||||||
allStats: null,
|
allStats: null,
|
||||||
analytics: null,
|
analytics: null,
|
||||||
@@ -412,11 +413,8 @@ function formData(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
|
const headers = { "Content-Type": "application/json", ...authHeaders(options.headers || {}) };
|
||||||
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
|
if (options.body instanceof FormData) delete headers["Content-Type"];
|
||||||
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
|
|
||||||
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
|
|
||||||
}
|
|
||||||
const response = await fetch(`/api${path}`, {
|
const response = await fetch(`/api${path}`, {
|
||||||
headers,
|
headers,
|
||||||
...options,
|
...options,
|
||||||
@@ -429,6 +427,15 @@ async function api(path, options = {}) {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authHeaders(extra = {}) {
|
||||||
|
const headers = { ...extra };
|
||||||
|
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
|
||||||
|
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
|
||||||
|
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAuthConfig() {
|
async function loadAuthConfig() {
|
||||||
state.authConfig = await api("/users/auth/config");
|
state.authConfig = await api("/users/auth/config");
|
||||||
window.APP_VAPID_PUBLIC_KEY = state.authConfig.vapid_public_key || "";
|
window.APP_VAPID_PUBLIC_KEY = state.authConfig.vapid_public_key || "";
|
||||||
@@ -539,7 +546,7 @@ function showTelegramOpenHint() {
|
|||||||
if (slot && !slot.dataset.ready) slot.textContent = "";
|
if (slot && !slot.dataset.ready) slot.textContent = "";
|
||||||
if (note) {
|
if (note) {
|
||||||
note.textContent = isMobileBrowser()
|
note.textContent = isMobileBrowser()
|
||||||
? "После перехода в Telegram нажмите в боте кнопку «Открыть гараж»."
|
? "После перехода нажмите Start, затем кнопку «Открыть CarPass» под сообщением бота."
|
||||||
: "На компьютере можно войти кнопкой Telegram ниже или открыть бота.";
|
: "На компьютере можно войти кнопкой Telegram ниже или открыть бота.";
|
||||||
}
|
}
|
||||||
if (!botUsername) {
|
if (!botUsername) {
|
||||||
@@ -638,9 +645,12 @@ function applyPeriodPreset(preset = "month") {
|
|||||||
const to = dateValue(now);
|
const to = dateValue(now);
|
||||||
let fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
let fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
if (preset === "all") fromDate = new Date(2000, 0, 1);
|
if (preset === "all") fromDate = new Date(2000, 0, 1);
|
||||||
|
if (preset === "7d") fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6);
|
||||||
|
if (preset === "30d" || preset === "month") fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29);
|
||||||
|
if (preset === "3m" || preset === "quarter") fromDate = shiftMonths(now, -3);
|
||||||
|
if (preset === "6m") fromDate = shiftMonths(now, -6);
|
||||||
|
if (preset === "12m" || preset === "year") fromDate = shiftMonths(now, -12);
|
||||||
if (preset === "day") fromDate = now;
|
if (preset === "day") fromDate = now;
|
||||||
if (preset === "quarter") fromDate = shiftMonths(now, -3);
|
|
||||||
if (preset === "year") fromDate = shiftMonths(now, -12);
|
|
||||||
if (preset !== "custom") {
|
if (preset !== "custom") {
|
||||||
document.querySelector("#periodFrom").value = dateValue(fromDate);
|
document.querySelector("#periodFrom").value = dateValue(fromDate);
|
||||||
document.querySelector("#periodTo").value = to;
|
document.querySelector("#periodTo").value = to;
|
||||||
@@ -745,14 +755,12 @@ function resetCarCatalog() {
|
|||||||
|
|
||||||
function updateHero(stats) {
|
function updateHero(stats) {
|
||||||
const car = selectedCar();
|
const car = selectedCar();
|
||||||
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
|
document.querySelector("#selectedCarTitle").textContent = stats ? money(stats.cost_per_month || stats.total_cost || 0) : t("Не выбран");
|
||||||
document.querySelector("#selectedCarMeta").textContent = car
|
document.querySelector("#selectedCarMeta").textContent = car
|
||||||
? [car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")
|
? [car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")
|
||||||
: t("Добавь авто или выбери из списка");
|
: t("Добавь авто или выбери из списка");
|
||||||
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
|
document.querySelector("#summaryTotal").textContent = stats?.cost_per_km ? money(stats.cost_per_km) : "-";
|
||||||
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
|
document.querySelector("#summaryConsumption").textContent = stats ? money(stats.forecast_next_month || 0) : "-";
|
||||||
? `${stats.avg_consumption_l_per_100km.toFixed(2)} л`
|
|
||||||
: "-";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFuelPrice(value) {
|
function formatFuelPrice(value) {
|
||||||
@@ -762,12 +770,14 @@ function formatFuelPrice(value) {
|
|||||||
|
|
||||||
function renderCars() {
|
function renderCars() {
|
||||||
const root = document.querySelector("#cars");
|
const root = document.querySelector("#cars");
|
||||||
|
const drawerRoot = document.querySelector("#drawerCars");
|
||||||
if (!state.cars.length) {
|
if (!state.cars.length) {
|
||||||
root.innerHTML = `<div class="empty">${t("Добавь первый автомобиль")}</div>`;
|
root.innerHTML = `<div class="empty">${t("Добавь первый автомобиль")}</div>`;
|
||||||
|
if (drawerRoot) drawerRoot.innerHTML = root.innerHTML;
|
||||||
updateHero(null);
|
updateHero(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.innerHTML = state.cars
|
const markup = state.cars
|
||||||
.map(
|
.map(
|
||||||
(car) => `
|
(car) => `
|
||||||
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
|
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
|
||||||
@@ -780,7 +790,9 @@ function renderCars() {
|
|||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
root.querySelectorAll("[data-car]").forEach((button) => {
|
root.innerHTML = markup;
|
||||||
|
if (drawerRoot) drawerRoot.innerHTML = markup;
|
||||||
|
document.querySelectorAll("[data-car]").forEach((button) => {
|
||||||
button.addEventListener("click", () => selectCar(Number(button.dataset.car)));
|
button.addEventListener("click", () => selectCar(Number(button.dataset.car)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -818,10 +830,7 @@ function fillCarProfileForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCarProfile() {
|
function openCarProfile() {
|
||||||
document.querySelector("#userDrawer").classList.remove("hidden");
|
openDrawerSection("carProfileSection");
|
||||||
document.querySelector("#carProfileSection").classList.remove("hidden");
|
|
||||||
fillCarProfileForm();
|
|
||||||
document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadServiceCenters() {
|
async function loadServiceCenters() {
|
||||||
@@ -859,6 +868,36 @@ function renderServiceCenters() {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPublicServiceCenters() {
|
||||||
|
const root = document.querySelector("#publicServiceCenters");
|
||||||
|
if (!root) return;
|
||||||
|
try {
|
||||||
|
const centers = await api("/service-centers/public");
|
||||||
|
root.innerHTML = centers.length
|
||||||
|
? centers
|
||||||
|
.map(
|
||||||
|
(center) => `
|
||||||
|
<div class="stack-item">
|
||||||
|
<strong>${center.display_name || center.name}</strong>
|
||||||
|
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
|
||||||
|
<small>${center.specializations?.join(", ") || "Специализация не указана"}</small>
|
||||||
|
<span class="trust-badge">${center.rating_avg ? `★ ${center.rating_avg}` : "Проверка пройдена"}</span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="empty">Проверенных СТО пока нет</div>`;
|
||||||
|
} catch (error) {
|
||||||
|
root.innerHTML = `<div class="empty">Не удалось загрузить СТО</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServiceReviews() {
|
||||||
|
const root = document.querySelector("#serviceReviews");
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = `<div class="empty">Отзывы доступны в карточке проверенного СТО. Выберите сервис в разделе «СТО».</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function trustLabel(level) {
|
function trustLabel(level) {
|
||||||
const labels = {
|
const labels = {
|
||||||
new_service: "Новый сервис",
|
new_service: "Новый сервис",
|
||||||
@@ -894,6 +933,11 @@ function renderStats(stats) {
|
|||||||
const costPer100 = stats.cost_per_km ? stats.cost_per_km * 100 : null;
|
const costPer100 = stats.cost_per_km ? stats.cost_per_km * 100 : null;
|
||||||
const periodTitles = {
|
const periodTitles = {
|
||||||
all: t("За весь срок"),
|
all: t("За весь срок"),
|
||||||
|
"7d": "7 дней",
|
||||||
|
"30d": "30 дней",
|
||||||
|
"3m": "3 месяца",
|
||||||
|
"6m": "6 месяцев",
|
||||||
|
"12m": "12 месяцев",
|
||||||
month: t("За месяц"),
|
month: t("За месяц"),
|
||||||
day: t("За день"),
|
day: t("За день"),
|
||||||
quarter: t("За квартал"),
|
quarter: t("За квартал"),
|
||||||
@@ -904,6 +948,8 @@ function renderStats(stats) {
|
|||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<button class="stat pop" data-report="summary"><span>${t("За весь срок")}</span><strong>${money(all.total_cost)}</strong><em>${all.fuel_entries_count + all.service_entries_count} ${t("записей")}</em></button>
|
<button class="stat pop" data-report="summary"><span>${t("За весь срок")}</span><strong>${money(all.total_cost)}</strong><em>${all.fuel_entries_count + all.service_entries_count} ${t("записей")}</em></button>
|
||||||
<button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button>
|
<button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button>
|
||||||
|
<button class="stat pop" data-report="summary"><span>В месяц</span><strong>${money(stats.cost_per_month || 0)}</strong><em>${t("среднее в периоде")}</em></button>
|
||||||
|
<button class="stat pop" data-report="summary"><span>Прогноз</span><strong>${money(stats.forecast_next_month || 0)}</strong><em>ближайший месяц</em></button>
|
||||||
<button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button>
|
<button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button>
|
||||||
<button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button>
|
<button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button>
|
||||||
<button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button>
|
<button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button>
|
||||||
@@ -929,6 +975,13 @@ function recordsForPeriod() {
|
|||||||
meta: item.vendor || serviceLabel(item.service_type),
|
meta: item.vendor || serviceLabel(item.service_type),
|
||||||
cost: item.total_cost,
|
cost: item.total_cost,
|
||||||
})),
|
})),
|
||||||
|
...state.latestExpenses.map((item) => ({
|
||||||
|
date: item.entry_date,
|
||||||
|
type: "expense",
|
||||||
|
title: item.title,
|
||||||
|
meta: expenseLabel(item.category),
|
||||||
|
cost: item.total_cost,
|
||||||
|
})),
|
||||||
].sort((a, b) => b.date.localeCompare(a.date));
|
].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,11 +1209,40 @@ function serviceLabel(value) {
|
|||||||
}[value] || value;
|
}[value] || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthlySeries(fuel, service) {
|
function expenseLabel(value) {
|
||||||
|
return {
|
||||||
|
insurance: "Страховка",
|
||||||
|
tax: "Налог",
|
||||||
|
fine: "Штраф",
|
||||||
|
parking: "Парковка",
|
||||||
|
car_wash: "Мойка",
|
||||||
|
toll: "Платная дорога",
|
||||||
|
tires: "Шины",
|
||||||
|
wheels: "Диски",
|
||||||
|
battery: "Аккумулятор",
|
||||||
|
parts: "Запчасти",
|
||||||
|
repair: "Ремонт",
|
||||||
|
maintenance: "Плановое ТО",
|
||||||
|
diagnostics: "Диагностика",
|
||||||
|
towing: "Эвакуатор",
|
||||||
|
loan_payment: "Кредит / лизинг",
|
||||||
|
loan_interest: "Проценты",
|
||||||
|
state_fee: "Госпошлина",
|
||||||
|
registration: "Регистрация",
|
||||||
|
inspection: "Техосмотр",
|
||||||
|
other: "Прочее",
|
||||||
|
}[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthlySeries(fuel, service, expenses = []) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
[...fuel.map((item) => ({ ...item, type: "fuel" })), ...service.map((item) => ({ ...item, type: "service" }))].forEach((item) => {
|
[
|
||||||
|
...fuel.map((item) => ({ ...item, type: "fuel" })),
|
||||||
|
...service.map((item) => ({ ...item, type: "service" })),
|
||||||
|
...expenses.map((item) => ({ ...item, type: "other" })),
|
||||||
|
].forEach((item) => {
|
||||||
const key = item.entry_date.slice(0, 7);
|
const key = item.entry_date.slice(0, 7);
|
||||||
const current = map.get(key) || { label: key, fuel: 0, service: 0 };
|
const current = map.get(key) || { label: key, fuel: 0, service: 0, other: 0 };
|
||||||
current[item.type] += Number(item.total_cost || 0);
|
current[item.type] += Number(item.total_cost || 0);
|
||||||
map.set(key, current);
|
map.set(key, current);
|
||||||
});
|
});
|
||||||
@@ -1168,8 +1250,8 @@ function monthlySeries(fuel, service) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawCharts(fuel, service, stats) {
|
function drawCharts(fuel, service, stats) {
|
||||||
drawExpensesChart(monthlySeries(fuel, service));
|
drawExpensesChart(monthlySeries(fuel, service, state.latestExpenses));
|
||||||
drawSplitChart(Number(stats?.fuel_cost || 0), Number(stats?.service_cost || 0));
|
drawSplitChart(stats?.cost_by_category || { fuel: Number(stats?.fuel_cost || 0), service: Number(stats?.service_cost || 0) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupCanvas(canvas) {
|
function setupCanvas(canvas) {
|
||||||
@@ -1200,7 +1282,7 @@ function drawExpensesChart(series) {
|
|||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
const pad = 28;
|
const pad = 28;
|
||||||
const chartH = height - pad * 2;
|
const chartH = height - pad * 2;
|
||||||
const max = Math.max(...series.map((item) => item.fuel + item.service), 1);
|
const max = Math.max(...series.map((item) => item.fuel + item.service + item.other), 1);
|
||||||
const barGap = 12;
|
const barGap = 12;
|
||||||
const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length);
|
const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length);
|
||||||
|
|
||||||
@@ -1216,17 +1298,21 @@ function drawExpensesChart(series) {
|
|||||||
|
|
||||||
series.forEach((item, index) => {
|
series.forEach((item, index) => {
|
||||||
const x = pad + index * (barW + barGap);
|
const x = pad + index * (barW + barGap);
|
||||||
const total = item.fuel + item.service;
|
const total = item.fuel + item.service + item.other;
|
||||||
const totalH = (total / max) * chartH;
|
const totalH = (total / max) * chartH;
|
||||||
const fuelH = total ? (item.fuel / total) * totalH : 0;
|
const fuelH = total ? (item.fuel / total) * totalH : 0;
|
||||||
const serviceH = totalH - fuelH;
|
const serviceH = total ? (item.service / total) * totalH : 0;
|
||||||
|
const otherH = Math.max(totalH - fuelH - serviceH, 0);
|
||||||
const y = height - pad - totalH;
|
const y = height - pad - totalH;
|
||||||
|
|
||||||
ctx.fillStyle = "#36a388";
|
ctx.fillStyle = "#36a388";
|
||||||
roundRect(ctx, x, y + serviceH, barW, fuelH, 6);
|
roundRect(ctx, x, y + serviceH + otherH, barW, fuelH, 6);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.fillStyle = "#3f7fba";
|
ctx.fillStyle = "#3f7fba";
|
||||||
roundRect(ctx, x, y, barW, serviceH, 6);
|
roundRect(ctx, x, y + otherH, barW, serviceH, 6);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "#d6a64f";
|
||||||
|
roundRect(ctx, x, y, barW, otherH, 6);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.fillStyle = "#7c8783";
|
ctx.fillStyle = "#7c8783";
|
||||||
@@ -1236,10 +1322,15 @@ function drawExpensesChart(series) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSplitChart(fuelCost, serviceCost) {
|
function drawSplitChart(categories) {
|
||||||
const canvas = document.querySelector("#splitChart");
|
const canvas = document.querySelector("#splitChart");
|
||||||
const { ctx, width, height } = setupCanvas(canvas);
|
const { ctx, width, height } = setupCanvas(canvas);
|
||||||
const total = fuelCost + serviceCost;
|
const entries = Object.entries(categories || {})
|
||||||
|
.map(([key, value]) => [key, Number(value || 0)])
|
||||||
|
.filter(([, value]) => value > 0)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5);
|
||||||
|
const total = entries.reduce((sum, [, value]) => sum + value, 0);
|
||||||
if (!total) {
|
if (!total) {
|
||||||
drawEmpty(ctx, width, height, "Нет расходов");
|
drawEmpty(ctx, width, height, "Нет расходов");
|
||||||
return;
|
return;
|
||||||
@@ -1248,26 +1339,26 @@ function drawSplitChart(fuelCost, serviceCost) {
|
|||||||
const cx = width / 2;
|
const cx = width / 2;
|
||||||
const cy = height / 2 - 8;
|
const cy = height / 2 - 8;
|
||||||
const radius = Math.min(width, height) * 0.31;
|
const radius = Math.min(width, height) * 0.31;
|
||||||
const fuelAngle = (fuelCost / total) * Math.PI * 2;
|
|
||||||
|
|
||||||
ctx.lineWidth = 22;
|
ctx.lineWidth = 22;
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
ctx.strokeStyle = "#36a388";
|
let start = -Math.PI / 2;
|
||||||
|
const colors = ["#36a388", "#3f7fba", "#d6a64f", "#c7645d", "#768a82"];
|
||||||
|
entries.forEach(([, value], index) => {
|
||||||
|
const angle = (value / total) * Math.PI * 2;
|
||||||
|
ctx.strokeStyle = colors[index % colors.length];
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + fuelAngle);
|
ctx.arc(cx, cy, radius, start, start + Math.max(angle - 0.05, 0.02));
|
||||||
ctx.stroke();
|
|
||||||
ctx.strokeStyle = "#3f7fba";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, radius, -Math.PI / 2 + fuelAngle + 0.05, Math.PI * 1.5 - 0.05);
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
start += angle;
|
||||||
|
});
|
||||||
|
|
||||||
ctx.fillStyle = "#1d2522";
|
ctx.fillStyle = "#1d2522";
|
||||||
ctx.font = "700 22px system-ui";
|
ctx.font = "700 22px system-ui";
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.fillText(`${Math.round((fuelCost / total) * 100)}%`, cx, cy + 5);
|
ctx.fillText(`${Math.round((entries[0][1] / total) * 100)}%`, cx, cy + 5);
|
||||||
ctx.fillStyle = "#7c8783";
|
ctx.fillStyle = "#7c8783";
|
||||||
ctx.font = "12px system-ui";
|
ctx.font = "12px system-ui";
|
||||||
ctx.fillText(t("топливо"), cx, cy + 25);
|
ctx.fillText(expenseLabel(entries[0][0]), cx, cy + 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundRect(ctx, x, y, width, height, radius) {
|
function roundRect(ctx, x, y, width, height, radius) {
|
||||||
@@ -1310,6 +1401,7 @@ async function loadSelectedCar() {
|
|||||||
if (!state.selectedCarId) {
|
if (!state.selectedCarId) {
|
||||||
state.latestFuel = [];
|
state.latestFuel = [];
|
||||||
state.latestService = [];
|
state.latestService = [];
|
||||||
|
state.latestExpenses = [];
|
||||||
state.latestStats = null;
|
state.latestStats = null;
|
||||||
state.allStats = null;
|
state.allStats = null;
|
||||||
state.analytics = null;
|
state.analytics = null;
|
||||||
@@ -1319,11 +1411,12 @@ async function loadSelectedCar() {
|
|||||||
renderStats(null);
|
renderStats(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [stats, allStats, fuel, service, analytics, vehicleScore] = await Promise.all([
|
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([
|
||||||
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
|
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
|
||||||
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
|
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
|
||||||
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
|
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
|
||||||
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
|
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
|
||||||
|
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
|
||||||
api(`/cars/${state.selectedCarId}/analytics`),
|
api(`/cars/${state.selectedCarId}/analytics`),
|
||||||
api(`/my/vehicles/${state.selectedCarId}/score`),
|
api(`/my/vehicles/${state.selectedCarId}/score`),
|
||||||
]);
|
]);
|
||||||
@@ -1335,6 +1428,7 @@ async function loadSelectedCar() {
|
|||||||
state.allStats = allStats;
|
state.allStats = allStats;
|
||||||
state.latestFuel = fuel;
|
state.latestFuel = fuel;
|
||||||
state.latestService = service;
|
state.latestService = service;
|
||||||
|
state.latestExpenses = expenses;
|
||||||
state.analytics = analytics;
|
state.analytics = analytics;
|
||||||
state.vehicleScore = vehicleScore;
|
state.vehicleScore = vehicleScore;
|
||||||
state.vehicleTimeline = timeline;
|
state.vehicleTimeline = timeline;
|
||||||
@@ -1343,11 +1437,11 @@ async function loadSelectedCar() {
|
|||||||
drawCharts(fuel, service, stats);
|
drawCharts(fuel, service, stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('input[type="date"]').forEach((input) => {
|
document.querySelectorAll('input[name="entry_date"]').forEach((input) => {
|
||||||
if (input.name !== "next_due_date") input.value = today();
|
input.value = today();
|
||||||
});
|
});
|
||||||
|
|
||||||
applyPeriodPreset("month");
|
applyPeriodPreset("30d");
|
||||||
|
|
||||||
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
|
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
|
||||||
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
|
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
|
||||||
@@ -1514,6 +1608,40 @@ document.querySelector("#serviceForm").addEventListener("submit", async (event)
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("#expenseForm").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!state.selectedCarId) {
|
||||||
|
toast("Выбери автомобиль", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = event.currentTarget;
|
||||||
|
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||||||
|
const data = formData(form);
|
||||||
|
await api("/expenses", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
car_id: state.selectedCarId,
|
||||||
|
entry_date: data.entry_date,
|
||||||
|
category: data.category,
|
||||||
|
title: data.title,
|
||||||
|
total_cost: Number(data.total_cost),
|
||||||
|
currency: data.currency || state.user?.currency || "RUB",
|
||||||
|
vendor: data.vendor || null,
|
||||||
|
odometer: numberOrNull(data.odometer),
|
||||||
|
period_start: data.period_start || null,
|
||||||
|
period_end: data.period_end || null,
|
||||||
|
is_recurring: Boolean(data.is_recurring),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
form.entry_date.value = today();
|
||||||
|
form.currency.value = state.user?.currency || "RUB";
|
||||||
|
await loadSelectedCar();
|
||||||
|
toast("Сохранено");
|
||||||
|
haptic("success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function setAction(action) {
|
function setAction(action) {
|
||||||
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
|
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
|
||||||
button.classList.toggle("active", button.dataset.action === action);
|
button.classList.toggle("active", button.dataset.action === action);
|
||||||
@@ -1528,6 +1656,62 @@ function openScanModal() {
|
|||||||
document.querySelector("#scanModal").classList.remove("hidden");
|
document.querySelector("#scanModal").classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mountEntryForms() {
|
||||||
|
const fuelMount = document.querySelector("#fuelFormMount");
|
||||||
|
const serviceMount = document.querySelector("#serviceFormMount");
|
||||||
|
const fuelForm = document.querySelector("#fuelForm");
|
||||||
|
const serviceForm = document.querySelector("#serviceForm");
|
||||||
|
if (fuelMount && fuelForm && !fuelMount.contains(fuelForm)) {
|
||||||
|
fuelForm.classList.remove("hidden");
|
||||||
|
fuelMount.appendChild(fuelForm);
|
||||||
|
}
|
||||||
|
if (serviceMount && serviceForm && !serviceMount.contains(serviceForm)) {
|
||||||
|
serviceForm.classList.remove("hidden");
|
||||||
|
serviceMount.appendChild(serviceForm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDrawerSection(sectionId, options = {}) {
|
||||||
|
document.querySelector("#userDrawer").classList.remove("hidden");
|
||||||
|
document.querySelectorAll(".drawer-section").forEach((section) => {
|
||||||
|
section.classList.toggle("hidden", section.id !== sectionId);
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".menu-row").forEach((button) => {
|
||||||
|
button.classList.toggle("active", button.dataset.menuSection === sectionId);
|
||||||
|
});
|
||||||
|
mountEntryForms();
|
||||||
|
if (sectionId === "carProfileSection") fillCarProfileForm();
|
||||||
|
if (sectionId === "settingsSection") {
|
||||||
|
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||||
|
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
||||||
|
}
|
||||||
|
if (sectionId === "notificationsSection") {
|
||||||
|
updateNotificationStatus(
|
||||||
|
"Notification" in window && Notification.permission === "granted"
|
||||||
|
? "Уведомления включены"
|
||||||
|
: "Напомним о ТО, страховке и регулярном внесении пробега.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sectionId === "confirmationsSection") renderPlaceholderList("#confirmationRequests", "Новых запросов нет");
|
||||||
|
if (sectionId === "connectedServicesSection") renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет");
|
||||||
|
if (sectionId === "servicePanelSection") await loadServiceCenters();
|
||||||
|
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
|
||||||
|
if (sectionId === "reviewsSection") renderServiceReviews();
|
||||||
|
if (options.expenseCategory) {
|
||||||
|
openDrawerSection("expensesSection");
|
||||||
|
presetExpense(options.expenseCategory);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.querySelector(`#${sectionId}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetExpense(category) {
|
||||||
|
const form = document.querySelector("#expenseForm");
|
||||||
|
form.category.value = category;
|
||||||
|
form.title.value = expenseLabel(category);
|
||||||
|
if (category === "insurance") form.is_recurring.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll("[data-action]").forEach((button) => {
|
document.querySelectorAll("[data-action]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
haptic();
|
haptic();
|
||||||
@@ -1557,54 +1741,29 @@ document.querySelectorAll("[data-service-title]").forEach((button) => {
|
|||||||
|
|
||||||
document.querySelector("#menuBtn").addEventListener("click", () => {
|
document.querySelector("#menuBtn").addEventListener("click", () => {
|
||||||
document.querySelector("#userDrawer").classList.remove("hidden");
|
document.querySelector("#userDrawer").classList.remove("hidden");
|
||||||
|
openDrawerSection("carsSection");
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
|
document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
|
||||||
document.querySelector("#userDrawer").classList.remove("hidden");
|
openDrawerSection("carFormSection");
|
||||||
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#openCarFormBtn").addEventListener("click", () => {
|
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
|
||||||
document.querySelector("#carFormSection").classList.remove("hidden");
|
openDrawerSection("expensesSection");
|
||||||
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#openCarProfileBtn").addEventListener("click", openCarProfile);
|
document.querySelectorAll("[data-menu-section]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async (event) => {
|
||||||
document.querySelector("#openSettingsBtn").addEventListener("click", () => {
|
await runAction(event.currentTarget, "Обновляю данные...", async () => {
|
||||||
document.querySelector("#settingsSection").classList.remove("hidden");
|
await openDrawerSection(event.currentTarget.dataset.menuSection);
|
||||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
});
|
||||||
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
});
|
||||||
document.querySelector("#settingsSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#openNotificationsBtn").addEventListener("click", () => {
|
document.querySelectorAll("[data-expense-preset]").forEach((button) => {
|
||||||
document.querySelector("#notificationsSection").classList.remove("hidden");
|
button.addEventListener("click", () => {
|
||||||
updateNotificationStatus(
|
openDrawerSection("expensesSection");
|
||||||
"Notification" in window && Notification.permission === "granted"
|
presetExpense(button.dataset.expensePreset);
|
||||||
? "Уведомления включены"
|
|
||||||
: "Напомним о ТО, страховке и регулярном внесении пробега.",
|
|
||||||
);
|
|
||||||
document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector("#openConfirmationsBtn").addEventListener("click", () => {
|
|
||||||
document.querySelector("#confirmationsSection").classList.remove("hidden");
|
|
||||||
renderPlaceholderList("#confirmationRequests", "Новых запросов нет");
|
|
||||||
document.querySelector("#confirmationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector("#openConnectedServicesBtn").addEventListener("click", () => {
|
|
||||||
document.querySelector("#connectedServicesSection").classList.remove("hidden");
|
|
||||||
renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет");
|
|
||||||
document.querySelector("#connectedServicesSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector("#openServicePanelBtn").addEventListener("click", async (event) => {
|
|
||||||
await runAction(event.currentTarget, "Загружаю СТО...", async () => {
|
|
||||||
document.querySelector("#servicePanelSection").classList.remove("hidden");
|
|
||||||
await loadServiceCenters();
|
|
||||||
document.querySelector("#servicePanelSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1622,6 +1781,12 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e
|
|||||||
city: data.city || null,
|
city: data.city || null,
|
||||||
address: data.address || null,
|
address: data.address || null,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
|
contact_person: data.contact_person || null,
|
||||||
|
description: data.description || null,
|
||||||
|
specializations: data.specializations
|
||||||
|
? data.specializations.split(",").map((item) => item.trim()).filter(Boolean)
|
||||||
|
: null,
|
||||||
|
working_hours: data.working_hours || null,
|
||||||
business_registration_number: data.business_registration_number || null,
|
business_registration_number: data.business_registration_number || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1633,7 +1798,7 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e
|
|||||||
|
|
||||||
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
|
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
|
||||||
|
|
||||||
document.querySelector("#openScanBtn").addEventListener("click", () => {
|
document.querySelector("#fuelScanBtn").addEventListener("click", () => {
|
||||||
openScanModal();
|
openScanModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1676,7 +1841,7 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
|
|||||||
payload.append("file", file);
|
payload.append("file", file);
|
||||||
const response = await fetch("/api/ocr/parse-text-receipt", {
|
const response = await fetch("/api/ocr/parse-text-receipt", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: tg?.initData ? { "X-Telegram-Init-Data": tg.initData } : {},
|
headers: authHeaders(),
|
||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
@@ -1686,8 +1851,8 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
|
|||||||
if (result.liters) fuelForm.liters.value = result.liters;
|
if (result.liters) fuelForm.liters.value = result.liters;
|
||||||
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
|
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
|
||||||
if (result.station) fuelForm.station.value = result.station;
|
if (result.station) fuelForm.station.value = result.station;
|
||||||
setAction("fuel");
|
|
||||||
document.querySelector("#scanModal").classList.add("hidden");
|
document.querySelector("#scanModal").classList.add("hidden");
|
||||||
|
await openDrawerSection("fuelSection");
|
||||||
toast("Проверь распознанные значения");
|
toast("Проверь распознанные значения");
|
||||||
haptic("success");
|
haptic("success");
|
||||||
});
|
});
|
||||||
@@ -1712,6 +1877,8 @@ Promise.all([loadAuthConfig()])
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||||
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
||||||
|
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";
|
||||||
|
mountEntryForms();
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
initCarCatalog();
|
initCarCatalog();
|
||||||
return loadCars();
|
return loadCars();
|
||||||
|
|||||||
@@ -910,6 +910,10 @@ select:disabled {
|
|||||||
color: var(--service);
|
color: var(--service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record .expense {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 18px 0;
|
padding: 18px 0;
|
||||||
@@ -1144,6 +1148,25 @@ button.is-busy {
|
|||||||
var(--surface);
|
var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-add-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: linear-gradient(135deg, #12735f, #2f6f9f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-row.active {
|
||||||
|
border-color: rgba(18, 115, 95, 0.45);
|
||||||
|
background: #e7f4ef;
|
||||||
|
color: #0e604f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-cars {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-card::after {
|
.summary-card::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user