first commit

This commit is contained in:
VPN SaaS Dev
2026-05-12 03:52:13 +09:00
commit d93c88c751
44 changed files with 4108 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
.env
.env.*
.history/
.sixth/
__pycache__/
*.py[cod]
*.sqlite
*.sqlite3
*.db
.pytest_cache/
.ruff_cache/
.mypy_cache/
.venv/
venv/
dist/
build/

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
COPY app ./app
COPY bot ./bot
RUN pip install --no-cache-dir .
COPY . .

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# Drivers Bot
Telegram mini app для учета расходов автовладельца: заправки, ремонты, обслуживание, жидкости, статистика стоимости владения и расхода топлива.
## Состав
- `app/` - FastAPI сервис. Через него работают и бот, и HTML5 mini app.
- `bot/` - aiogram 3 бот, который регистрирует пользователя, открывает mini app и показывает быстрые команды.
- `web/` - HTML5 Telegram WebApp фронт.
- `alembic/` - миграции PostgreSQL.
## Основные таблицы
- `users` - пользователь Telegram.
- `cars` - автомобили пользователя.
- `fuel_entries` - заправки: дата, одометр, литры, цена, стоимость, АЗС.
- `service_entries` - обслуживание, ремонты, жидкости, шины, страховка, налоги и прочие расходы.
Связи: `users 1:N cars`, `cars 1:N fuel_entries`, `cars 1:N service_entries`.
## Запуск
1. Создай `.env`:
```bash
cp .env.example .env
```
2. Заполни `BOT_TOKEN` и `WEBAPP_URL`. Для Telegram mini app `WEBAPP_URL` должен быть HTTPS URL, доступный Telegram.
3. Подними сервисы:
```bash
docker compose up --build
```
API будет доступен на `http://localhost:8000`, документация - `http://localhost:8000/docs`.
## Локальный запуск без Docker
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .
alembic upgrade head
uvicorn app.main:app --reload
```
В отдельном терминале:
```bash
python -m bot.main
```
## API
Ключевые endpoint-ы:
- `POST /api/users` - создать или обновить пользователя Telegram.
- `POST /api/cars`, `GET /api/cars?owner_id=...` - автомобили.
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel` - заправки.
- `POST /api/service`, `GET /api/cars/{car_id}/service` - сервисные записи.
- `GET /api/cars/{car_id}/stats` - стоимость владения, топливо, пробег, расход л/100 км, цена 1 км.
- `GET /api/cars/{car_id}/charts/expenses.png` - график расходов через pandas/matplotlib.
## Что дальше
Практичные следующие шаги: авторизация WebApp через проверку `initData`, CRUD редактирование записей, напоминания по `next_due_date` и `next_due_odometer`, экспорт в CSV/XLSX, валюта и единицы измерения на уровне пользователя.

40
alembic.ini Normal file
View File

@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

62
alembic/env.py Normal file
View File

@@ -0,0 +1,62 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import settings
from app.db.base import Base
from app.models import car, expense, user # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
import asyncio
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,123 @@
"""initial schema
Revision ID: 202605110001
Revises:
Create Date: 2026-05-11
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605110001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
service_type = sa.Enum(
"maintenance",
"repair",
"fluid",
"tire",
"inspection",
"insurance",
"tax",
"other",
name="servicetype",
create_type=False,
)
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("telegram_id", sa.BigInteger(), nullable=False),
sa.Column("username", sa.String(length=128), nullable=True),
sa.Column("first_name", sa.String(length=128), nullable=True),
sa.Column("last_name", sa.String(length=128), 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.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_telegram_id"), "users", ["telegram_id"], unique=True)
op.create_table(
"cars",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("make", sa.String(length=80), nullable=True),
sa.Column("model", sa.String(length=80), nullable=True),
sa.Column("year", sa.Integer(), nullable=True),
sa.Column("plate_number", sa.String(length=32), nullable=True),
sa.Column("vin", sa.String(length=32), nullable=True),
sa.Column("fuel_type", sa.String(length=32), nullable=True),
sa.Column("purchase_date", sa.Date(), nullable=True),
sa.Column("purchase_price", sa.Numeric(12, 2), nullable=True),
sa.Column("current_odometer", sa.Integer(), 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(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_cars_owner_id"), "cars", ["owner_id"], unique=False)
op.create_table(
"fuel_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("odometer", sa.Integer(), nullable=False),
sa.Column("liters", sa.Numeric(8, 3), nullable=False),
sa.Column("price_per_liter", sa.Numeric(10, 2), nullable=False),
sa.Column("total_cost", sa.Numeric(12, 2), nullable=False),
sa.Column("station", sa.String(length=160), nullable=True),
sa.Column("fuel_brand", sa.String(length=80), nullable=True),
sa.Column("is_full_tank", sa.Boolean(), 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(op.f("ix_fuel_entries_car_id"), "fuel_entries", ["car_id"], unique=False)
op.create_index(op.f("ix_fuel_entries_entry_date"), "fuel_entries", ["entry_date"], unique=False)
op.create_table(
"service_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("odometer", sa.Integer(), nullable=True),
sa.Column("service_type", service_type, nullable=False),
sa.Column("title", sa.String(length=180), nullable=False),
sa.Column("category", sa.String(length=80), nullable=True),
sa.Column("vendor", sa.String(length=160), nullable=True),
sa.Column("total_cost", sa.Numeric(12, 2), nullable=False),
sa.Column("next_due_date", sa.Date(), nullable=True),
sa.Column("next_due_odometer", sa.Integer(), nullable=True),
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(op.f("ix_service_entries_car_id"), "service_entries", ["car_id"], unique=False)
op.create_index(op.f("ix_service_entries_entry_date"), "service_entries", ["entry_date"], unique=False)
op.create_index(op.f("ix_service_entries_service_type"), "service_entries", ["service_type"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_service_entries_service_type"), table_name="service_entries")
op.drop_index(op.f("ix_service_entries_entry_date"), table_name="service_entries")
op.drop_index(op.f("ix_service_entries_car_id"), table_name="service_entries")
op.drop_table("service_entries")
op.drop_index(op.f("ix_fuel_entries_entry_date"), table_name="fuel_entries")
op.drop_index(op.f("ix_fuel_entries_car_id"), table_name="fuel_entries")
op.drop_table("fuel_entries")
op.drop_index(op.f("ix_cars_owner_id"), table_name="cars")
op.drop_table("cars")
op.drop_index(op.f("ix_users_telegram_id"), table_name="users")
op.drop_table("users")
service_type.drop(op.get_bind(), checkfirst=True)

View File

@@ -0,0 +1,49 @@
"""car catalog
Revision ID: 202605110002
Revises: 202605110001
Create Date: 2026-05-11
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605110002"
down_revision: str | None = "202605110001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"car_makes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("country", sa.String(length=80), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_car_makes_name"), "car_makes", ["name"], unique=True)
op.create_table(
"car_models",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("make_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["make_id"], ["car_makes.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("make_id", "name", name="uq_car_models_make_name"),
)
op.create_index(op.f("ix_car_models_make_id"), "car_models", ["make_id"], unique=False)
op.create_index(op.f("ix_car_models_name"), "car_models", ["name"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_car_models_name"), table_name="car_models")
op.drop_index(op.f("ix_car_models_make_id"), table_name="car_models")
op.drop_table("car_models")
op.drop_index(op.f("ix_car_makes_name"), table_name="car_makes")
op.drop_table("car_makes")

View File

@@ -0,0 +1,26 @@
"""user preferences
Revision ID: 202605110003
Revises: 202605110002
Create Date: 2026-05-11
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605110003"
down_revision: str | None = "202605110002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("locale", sa.String(length=8), server_default="ru", nullable=False))
op.add_column("users", sa.Column("currency", sa.String(length=3), server_default="RUB", nullable=False))
def downgrade() -> None:
op.drop_column("users", "currency")
op.drop_column("users", "locale")

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@

57
app/api/cars.py Normal file
View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session
from app.models.car import Car
from app.schemas.car import CarCreate, CarRead, CarUpdate
router = APIRouter(prefix="/cars", tags=["cars"])
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_session)) -> Car:
car = Car(**payload.model_dump())
session.add(car)
await session.commit()
await session.refresh(car)
return car
@router.get("", response_model=list[CarRead])
async def list_cars(owner_id: int, session: AsyncSession = Depends(get_session)) -> list[Car]:
result = await session.execute(
select(Car).where(Car.owner_id == owner_id).order_by(Car.created_at.desc())
)
return list(result.scalars())
@router.get("/{car_id}", response_model=CarRead)
async def get_car(car_id: int, session: AsyncSession = Depends(get_session)) -> Car:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
return car
@router.patch("/{car_id}", response_model=CarRead)
async def update_car(
car_id: int, payload: CarUpdate, session: AsyncSession = Depends(get_session)
) -> Car:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(car, field, value)
await session.commit()
await session.refresh(car)
return car
@router.delete("/{car_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_car(car_id: int, session: AsyncSession = Depends(get_session)) -> None:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
await session.delete(car)
await session.commit()

21
app/api/catalog.py Normal file
View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db.session import get_session
from app.models.car import CarMake
from app.schemas.car import CarMakeRead
router = APIRouter(prefix="/catalog", tags=["catalog"])
@router.get("/makes", response_model=list[CarMakeRead])
async def list_makes(session: AsyncSession = Depends(get_session)) -> list[CarMake]:
result = await session.execute(
select(CarMake).options(selectinload(CarMake.models)).order_by(CarMake.name)
)
makes = list(result.scalars())
for make in makes:
make.models.sort(key=lambda model: model.name)
return makes

160
app/api/entries.py Normal file
View File

@@ -0,0 +1,160 @@
from io import BytesIO
from datetime import date
import matplotlib.pyplot as plt
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session
from app.models.car import Car
from app.models.expense import FuelEntry, ServiceEntry
from app.schemas.expense import (
FuelEntryCreate,
FuelEntryRead,
OdometerPrediction,
OwnershipStats,
ServiceEntryCreate,
ServiceEntryRead,
)
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
router = APIRouter(tags=["entries"])
async def ensure_car(session: AsyncSession, car_id: int) -> None:
if await session.get(Car, car_id) is None:
raise HTTPException(status_code=404, detail="Car not found")
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
async def create_fuel_entry(
payload: FuelEntryCreate, session: AsyncSession = Depends(get_session)
) -> FuelEntry:
await ensure_car(session, payload.car_id)
entry = FuelEntry(**payload.model_dump())
session.add(entry)
car = await session.get(Car, payload.car_id)
if car 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}/fuel", response_model=list[FuelEntryRead])
async def list_fuel_entries(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
session: AsyncSession = Depends(get_session),
) -> list[FuelEntry]:
stmt = select(FuelEntry).where(FuelEntry.car_id == car_id)
if date_from:
stmt = stmt.where(FuelEntry.entry_date >= date_from)
if date_to:
stmt = stmt.where(FuelEntry.entry_date <= date_to)
result = await session.execute(
stmt.order_by(FuelEntry.entry_date.desc())
)
return list(result.scalars())
@router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED)
async def create_service_entry(
payload: ServiceEntryCreate, session: AsyncSession = Depends(get_session)
) -> ServiceEntry:
await ensure_car(session, payload.car_id)
entry = ServiceEntry(**payload.model_dump())
session.add(entry)
car = await session.get(Car, payload.car_id)
if car and 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}/service", response_model=list[ServiceEntryRead])
async def list_service_entries(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ServiceEntry]:
stmt = select(ServiceEntry).where(ServiceEntry.car_id == car_id)
if date_from:
stmt = stmt.where(ServiceEntry.entry_date >= date_from)
if date_to:
stmt = stmt.where(ServiceEntry.entry_date <= date_to)
result = await session.execute(
stmt.order_by(ServiceEntry.entry_date.desc())
)
return list(result.scalars())
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
async def car_stats(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
session: AsyncSession = Depends(get_session),
) -> OwnershipStats:
await ensure_car(session, car_id)
today = date.today()
period_from = date_from or today.replace(day=1)
period_to = date_to or today
return await get_ownership_stats(session, car_id, period_from, period_to)
@router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction)
async def car_analytics(car_id: int, session: AsyncSession = Depends(get_session)) -> OdometerPrediction:
await ensure_car(session, car_id)
return await predict_odometer(session, car_id)
@router.get("/cars/{car_id}/charts/expenses.png")
async def expenses_chart(car_id: int, session: AsyncSession = Depends(get_session)) -> Response:
await ensure_car(session, car_id)
fuel_df = await dataframe_from_query(
session,
select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where(
FuelEntry.car_id == car_id
),
)
service_df = await dataframe_from_query(
session,
select(ServiceEntry.entry_date.label("date"), ServiceEntry.total_cost.label("cost")).where(
ServiceEntry.car_id == car_id
),
)
if fuel_df.empty and service_df.empty:
raise HTTPException(status_code=404, detail="No data for chart")
frames = []
if not fuel_df.empty:
fuel_df["type"] = "fuel"
frames.append(fuel_df)
if not service_df.empty:
service_df["type"] = "service"
frames.append(service_df)
import pandas as pd
df = pd.concat(frames)
df["date"] = pd.to_datetime(df["date"])
pivot = df.pivot_table(index="date", columns="type", values="cost", aggfunc="sum").sort_index()
fig, ax = plt.subplots(figsize=(8, 4.5))
pivot.plot(kind="bar", stacked=True, ax=ax)
ax.set_title("Car expenses")
ax.set_xlabel("Date")
ax.set_ylabel("Cost")
fig.tight_layout()
buffer = BytesIO()
fig.savefig(buffer, format="png")
plt.close(fig)
return Response(buffer.getvalue(), media_type="image/png")

41
app/api/ocr.py Normal file
View File

@@ -0,0 +1,41 @@
import re
from decimal import Decimal
from fastapi import APIRouter, File, UploadFile
from pydantic import BaseModel
router = APIRouter(prefix="/ocr", tags=["ocr"])
class ReceiptSuggestion(BaseModel):
total_cost: Decimal | None = None
liters: Decimal | None = None
price_per_liter: Decimal | None = None
station: str | None = None
confidence: float
message: str
@router.post("/fuel-receipt", response_model=ReceiptSuggestion)
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
content = await file.read()
text = content.decode("utf-8", errors="ignore")
numbers = [Decimal(item.replace(",", ".")) for item in re.findall(r"\d+[,.]\d+|\d+", text)]
total = max(numbers) if numbers else None
liters = next((item for item in numbers if Decimal("5") <= item <= Decimal("120")), None)
price = None
if total and liters and liters:
price = (total / liters).quantize(Decimal("0.01"))
return ReceiptSuggestion(
total_cost=total,
liters=liters,
price_per_liter=price,
station=None,
confidence=0.35 if numbers else 0,
message=(
"OCR-модуль готов к подключению движка распознавания. Сейчас извлекаю числа из текстового слоя/имени файла."
if numbers
else "Не удалось распознать чек. Можно заполнить поля вручную, а OCR-движок подключить отдельным сервисом."
),
)

46
app/api/users.py Normal file
View File

@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session
from app.models.user import User
from app.schemas.user import UserPreferencesUpdate, UserRead, UserUpsert
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserRead)
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User:
result = await session.execute(select(User).where(User.telegram_id == payload.telegram_id))
user = result.scalar_one_or_none()
if user is None:
user = User(**payload.model_dump(exclude_none=True))
session.add(user)
else:
for field, value in payload.model_dump(exclude_none=True).items():
setattr(user, field, value)
await session.commit()
await session.refresh(user)
return user
@router.get("/telegram/{telegram_id}", response_model=UserRead)
async def get_user_by_telegram_id(
telegram_id: int, session: AsyncSession = Depends(get_session)
) -> User:
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
return result.scalar_one()
@router.patch("/{user_id}/preferences", response_model=UserRead)
async def update_preferences(
user_id: int, payload: UserPreferencesUpdate, session: AsyncSession = Depends(get_session)
) -> User:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
for field, value in payload.model_dump(exclude_none=True).items():
setattr(user, field, value)
await session.commit()
await session.refresh(user)
return user

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@

22
app/core/config.py Normal file
View File

@@ -0,0 +1,22 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://drivers:drivers@localhost:5432/drivers"
bot_token: str = ""
api_base_url: str = "http://localhost:8000"
webapp_url: str = "http://localhost:8000"
app_host: str = "0.0.0.0"
app_port: int = 8000
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

1
app/db/__init__.py Normal file
View File

@@ -0,0 +1 @@

5
app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

214
app/db/seed.py Normal file
View File

@@ -0,0 +1,214 @@
import asyncio
from datetime import date
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db.session import async_session_factory
from app.models.car import Car, CarMake, CarModel
from app.models.expense import FuelEntry, ServiceEntry, ServiceType
from app.models.user import User
from app.services.catalog_data import CAR_CATALOG
MOCK_PLATE_PREFIX = "MOCK"
MOCK_CARS = [
("KIA Sportage", "KIA", "Sportage", 2021, "gasoline", 36200, Decimal("2450000")),
("Toyota Camry", "Toyota", "Camry", 2020, "gasoline", 58400, Decimal("2850000")),
("Hyundai Tucson", "Hyundai", "Tucson", 2022, "gasoline", 27100, Decimal("2750000")),
("Volkswagen Tiguan", "Volkswagen", "Tiguan", 2019, "gasoline", 73400, Decimal("2300000")),
("BMW X3", "BMW", "X3", 2021, "diesel", 48900, Decimal("4350000")),
("Mercedes GLC", "Mercedes", "GLC", 2020, "gasoline", 52200, Decimal("4500000")),
("Nissan X-Trail", "Nissan", "X-Trail", 2018, "gasoline", 91400, Decimal("1850000")),
("Skoda Octavia", "Skoda", "Octavia", 2021, "gasoline", 46800, Decimal("2050000")),
("Tesla Model 3", "Tesla", "Model 3", 2022, "electric", 33800, Decimal("3900000")),
("Haval Jolion", "Haval", "Jolion", 2023, "gasoline", 19600, Decimal("2150000")),
]
def month_shift(base: date, months_back: int) -> date:
month_index = base.year * 12 + base.month - 1 - months_back
year = month_index // 12
month = month_index % 12 + 1
return date(year, month, min(base.day, 24))
async def seed_catalog(session: AsyncSession) -> None:
result = await session.execute(select(CarMake).options(selectinload(CarMake.models)))
existing = {make.name: make for make in result.scalars()}
for make_name, model_names in CAR_CATALOG.items():
make = existing.get(make_name)
if make is None:
make = CarMake(name=make_name)
session.add(make)
await session.flush()
existing_models = set()
else:
existing_models = {model.name for model in make.models}
for model_name in model_names:
if model_name not in existing_models:
session.add(CarModel(make_id=make.id, name=model_name))
async def pick_owner(session: AsyncSession) -> User:
result = await session.execute(select(User).where(User.telegram_id == 1))
user = result.scalar_one_or_none()
if user:
return user
user = User(telegram_id=1, username="demo", first_name="Demo")
session.add(user)
await session.flush()
return user
async def clear_previous_mock(session: AsyncSession) -> None:
result = await session.execute(
select(Car.id).where(Car.plate_number.like(f"{MOCK_PLATE_PREFIX}-%"))
)
car_ids = list(result.scalars())
if not car_ids:
return
await session.execute(delete(ServiceEntry).where(ServiceEntry.car_id.in_(car_ids)))
await session.execute(delete(FuelEntry).where(FuelEntry.car_id.in_(car_ids)))
await session.execute(delete(Car).where(Car.id.in_(car_ids)))
async def seed_mock_usage(session: AsyncSession, owner: User) -> None:
await clear_previous_mock(session)
today = date.today()
for index, (name, make, model, year, fuel_type, start_odo, price) in enumerate(MOCK_CARS, start=1):
car = Car(
owner_id=owner.id,
name=name,
make=make,
model=model,
year=year,
plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}",
fuel_type=fuel_type,
purchase_date=date(year, min(index, 12), 10),
purchase_price=price,
current_odometer=start_odo,
)
session.add(car)
await session.flush()
odometer = start_odo
monthly_km = 820 + index * 95
consumption = Decimal("7.2") + Decimal(index % 5) * Decimal("0.45")
if fuel_type == "electric":
consumption = Decimal("0")
for months_back in range(11, -1, -1):
base_day = month_shift(today.replace(day=15), months_back)
odometer += monthly_km
if fuel_type == "electric":
energy_cost = Decimal(110 + index * 8 + months_back % 3 * 15)
session.add(
ServiceEntry(
car_id=car.id,
entry_date=base_day,
odometer=odometer,
service_type=ServiceType.other,
title="Зарядка и парковка",
category="charging",
vendor="EV network",
total_cost=energy_cost,
)
)
else:
liters_per_month = Decimal(monthly_km) * consumption / Decimal(100)
price_per_liter = Decimal("58.50") + Decimal(index % 4) * Decimal("2.10")
for fill in range(2):
fill_date = date(base_day.year, base_day.month, 8 if fill == 0 else 22)
liters = (liters_per_month / 2).quantize(Decimal("0.001"))
session.add(
FuelEntry(
car_id=car.id,
entry_date=fill_date,
odometer=odometer - (monthly_km // 2 if fill == 0 else 0),
liters=liters,
price_per_liter=price_per_liter,
total_cost=(liters * price_per_liter).quantize(Decimal("0.01")),
station=["Shell", "Lukoil", "Gazprom", "Rosneft", "Neste"][index % 5],
is_full_tank=True,
)
)
if months_back in {11, 5}:
session.add(
ServiceEntry(
car_id=car.id,
entry_date=date(base_day.year, base_day.month, 12),
odometer=odometer,
service_type=ServiceType.maintenance,
title="Плановое ТО",
category="regular",
vendor="Service center",
total_cost=Decimal(12500 + index * 950),
next_due_odometer=odometer + 10000,
)
)
if months_back in {8, 2}:
session.add(
ServiceEntry(
car_id=car.id,
entry_date=date(base_day.year, base_day.month, 18),
odometer=odometer,
service_type=ServiceType.tire,
title="Сезонная замена шин",
category="tires",
vendor="Tire shop",
total_cost=Decimal(4200 + index * 180),
)
)
if months_back == 6:
session.add(
ServiceEntry(
car_id=car.id,
entry_date=date(base_day.year, base_day.month, 20),
odometer=odometer,
service_type=ServiceType.insurance,
title="ОСАГО / страховка",
category="insurance",
vendor="Insurance",
total_cost=Decimal(18200 + index * 1150),
)
)
if months_back == index % 10:
session.add(
ServiceEntry(
car_id=car.id,
entry_date=date(base_day.year, base_day.month, 25),
odometer=odometer,
service_type=ServiceType.repair,
title=["Тормозные колодки", "Диагностика подвески", "Замена АКБ", "Ремонт кондиционера"][index % 4],
category="repair",
vendor="Garage",
total_cost=Decimal(7200 + index * 1350),
)
)
car.current_odometer = odometer
async def main() -> None:
async with async_session_factory() as session:
await seed_catalog(session)
owner = await pick_owner(session)
await seed_mock_usage(session, owner)
await session.commit()
print(f"Seeded catalog and mock usage for owner_id={owner.id}")
if __name__ == "__main__":
asyncio.run(main())

13
app/db/session.py Normal file
View File

@@ -0,0 +1,13 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
yield session

29
app/main.py Normal file
View File

@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api import cars, catalog, entries, ocr, users
app = FastAPI(title="Drivers Bot API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(users.router, prefix="/api")
app.include_router(catalog.router, prefix="/api")
app.include_router(cars.router, prefix="/api")
app.include_router(entries.router, prefix="/api")
app.include_router(ocr.router, prefix="/api")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
app.mount("/", StaticFiles(directory="web", html=True), name="web")

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@

55
app/models/car.py Normal file
View File

@@ -0,0 +1,55 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Car(Base):
__tablename__ = "cars"
id: Mapped[int] = mapped_column(primary_key=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(160))
make: Mapped[str | None] = mapped_column(String(80))
model: Mapped[str | None] = mapped_column(String(80))
year: Mapped[int | None]
plate_number: Mapped[str | None] = mapped_column(String(32))
vin: Mapped[str | None] = mapped_column(String(32))
fuel_type: Mapped[str | None] = mapped_column(String(32))
purchase_date: Mapped[date | None] = mapped_column(Date)
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
current_odometer: Mapped[int | None]
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
owner = relationship("User", back_populates="cars")
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan")
class CarMake(Base):
__tablename__ = "car_makes"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
country: Mapped[str | None] = mapped_column(String(80))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
models = relationship("CarModel", back_populates="make", cascade="all, delete-orphan")
class CarModel(Base):
__tablename__ = "car_models"
__table_args__ = (UniqueConstraint("make_id", "name", name="uq_car_models_make_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
make_id: Mapped[int] = mapped_column(ForeignKey("car_makes.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
make = relationship("CarMake", back_populates="models")

58
app/models/expense.py Normal file
View File

@@ -0,0 +1,58 @@
import enum
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class ServiceType(str, enum.Enum):
maintenance = "maintenance"
repair = "repair"
fluid = "fluid"
tire = "tire"
inspection = "inspection"
insurance = "insurance"
tax = "tax"
other = "other"
class FuelEntry(Base):
__tablename__ = "fuel_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)
odometer: Mapped[int]
liters: Mapped[Decimal] = mapped_column(Numeric(8, 3))
price_per_liter: Mapped[Decimal] = mapped_column(Numeric(10, 2))
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2))
station: Mapped[str | None] = mapped_column(String(160))
fuel_brand: Mapped[str | None] = mapped_column(String(80))
is_full_tank: Mapped[bool] = mapped_column(default=True)
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="fuel_entries")
class ServiceEntry(Base):
__tablename__ = "service_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)
odometer: Mapped[int | None]
service_type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
title: Mapped[str] = mapped_column(String(180))
category: Mapped[str | None] = mapped_column(String(80))
vendor: Mapped[str | None] = mapped_column(String(160))
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2))
next_due_date: Mapped[date | None] = mapped_column(Date)
next_due_odometer: Mapped[int | None]
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="service_entries")

24
app/models/user.py Normal file
View File

@@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
username: Mapped[str | None] = mapped_column(String(128))
first_name: Mapped[str | None] = mapped_column(String(128))
last_name: Mapped[str | None] = mapped_column(String(128))
locale: Mapped[str] = mapped_column(String(8), default="ru", server_default="ru")
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
cars = relationship("Car", back_populates="owner", cascade="all, delete-orphan")

1
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1 @@

58
app/schemas/car.py Normal file
View File

@@ -0,0 +1,58 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class CarBase(BaseModel):
name: str
make: str | None = None
model: str | None = None
year: int | None = None
plate_number: str | None = None
vin: str | None = None
fuel_type: str | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
current_odometer: int | None = None
class CarCreate(CarBase):
owner_id: int
class CarUpdate(BaseModel):
name: str | None = None
make: str | None = None
model: str | None = None
year: int | None = None
plate_number: str | None = None
vin: str | None = None
fuel_type: str | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
current_odometer: int | None = None
class CarRead(CarBase):
id: int
owner_id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CarModelRead(BaseModel):
id: int
name: str
model_config = ConfigDict(from_attributes=True)
class CarMakeRead(BaseModel):
id: int
name: str
country: str | None = None
models: list[CarModelRead] = []
model_config = ConfigDict(from_attributes=True)

89
app/schemas/expense.py Normal file
View File

@@ -0,0 +1,89 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, model_validator
from app.models.expense import ServiceType
class FuelEntryBase(BaseModel):
entry_date: date
odometer: int
liters: Decimal
price_per_liter: Decimal
total_cost: Decimal | None = None
station: str | None = None
fuel_brand: str | None = None
is_full_tank: bool = True
notes: str | None = None
@model_validator(mode="after")
def fill_total_cost(self) -> "FuelEntryBase":
if self.total_cost is None:
self.total_cost = self.liters * self.price_per_liter
return self
class FuelEntryCreate(FuelEntryBase):
car_id: int
class FuelEntryRead(FuelEntryBase):
id: int
car_id: int
total_cost: Decimal
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ServiceEntryBase(BaseModel):
entry_date: date
odometer: int | None = None
service_type: ServiceType
title: str
category: str | None = None
vendor: str | None = None
total_cost: Decimal
next_due_date: date | None = None
next_due_odometer: int | None = None
notes: str | None = None
class ServiceEntryCreate(ServiceEntryBase):
car_id: int
class ServiceEntryRead(ServiceEntryBase):
id: int
car_id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class OwnershipStats(BaseModel):
car_id: int
date_from: date
date_to: date
fuel_cost: Decimal
service_cost: Decimal
total_cost: Decimal
liters: Decimal
distance_km: int
avg_consumption_l_per_100km: float | None
cost_per_km: float | None
fuel_entries_count: int
service_entries_count: int
class OdometerPrediction(BaseModel):
car_id: int
samples: int
current_odometer: int | None
predicted_today: int | None
predicted_30_days: int | None
avg_km_per_day: float | None
avg_km_per_month: float | None
confidence: float
insight: str

26
app/schemas/user.py Normal file
View File

@@ -0,0 +1,26 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class UserUpsert(BaseModel):
telegram_id: int
username: str | None = None
first_name: str | None = None
last_name: str | None = None
locale: str | None = None
currency: str | None = None
class UserPreferencesUpdate(BaseModel):
locale: str | None = None
currency: str | None = None
class UserRead(UserUpsert):
id: int
locale: str
currency: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,134 @@
from datetime import date
from decimal import Decimal
import pandas as pd
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.expense import FuelEntry, ServiceEntry
from app.schemas.expense import OdometerPrediction, OwnershipStats
async def get_ownership_stats(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> OwnershipStats:
fuel_totals = await session.execute(
select(
func.coalesce(func.sum(FuelEntry.total_cost), 0),
func.coalesce(func.sum(FuelEntry.liters), 0),
func.count(FuelEntry.id),
func.min(FuelEntry.odometer),
func.max(FuelEntry.odometer),
).where(
FuelEntry.car_id == car_id,
FuelEntry.entry_date >= date_from,
FuelEntry.entry_date <= date_to,
)
)
fuel_cost, liters, fuel_count, min_odo, max_odo = fuel_totals.one()
service_totals = await session.execute(
select(func.coalesce(func.sum(ServiceEntry.total_cost), 0), func.count(ServiceEntry.id)).where(
ServiceEntry.car_id == car_id,
ServiceEntry.entry_date >= date_from,
ServiceEntry.entry_date <= date_to,
)
)
service_cost, service_count = service_totals.one()
distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0
total_cost = Decimal(fuel_cost) + Decimal(service_cost)
avg_consumption = float(Decimal(liters) * Decimal(100) / distance_km) if distance_km else None
cost_per_km = float(total_cost / distance_km) if distance_km else None
return OwnershipStats(
car_id=car_id,
date_from=date_from,
date_to=date_to,
fuel_cost=fuel_cost,
service_cost=service_cost,
total_cost=total_cost,
liters=liters,
distance_km=distance_km,
avg_consumption_l_per_100km=avg_consumption,
cost_per_km=cost_per_km,
fuel_entries_count=fuel_count,
service_entries_count=service_count,
)
async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame:
result = await session.execute(stmt)
rows = result.mappings().all()
return pd.DataFrame(rows)
async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction:
fuel = await dataframe_from_query(
session,
select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where(
FuelEntry.car_id == car_id
),
)
service = await dataframe_from_query(
session,
select(ServiceEntry.entry_date.label("date"), ServiceEntry.odometer.label("odometer")).where(
ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None)
),
)
if fuel.empty and service.empty:
return OdometerPrediction(
car_id=car_id,
samples=0,
current_odometer=None,
predicted_today=None,
predicted_30_days=None,
avg_km_per_day=None,
avg_km_per_month=None,
confidence=0,
insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.",
)
df = pd.concat([fuel, service]).dropna().drop_duplicates().sort_values("date")
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values(["date", "odometer"]).drop_duplicates(subset=["date"], keep="last")
if len(df) < 2:
current = int(df.iloc[-1]["odometer"])
return OdometerPrediction(
car_id=car_id,
samples=len(df),
current_odometer=current,
predicted_today=current,
predicted_30_days=None,
avg_km_per_day=None,
avg_km_per_month=None,
confidence=0.2,
insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.",
)
first = df.iloc[0]
last = df.iloc[-1]
days = max((last["date"] - first["date"]).days, 1)
distance = max(int(last["odometer"] - first["odometer"]), 0)
km_per_day = distance / days
today = pd.Timestamp.utcnow().tz_localize(None).normalize()
days_since_last = max((today - last["date"]).days, 0)
predicted_today = int(last["odometer"] + km_per_day * days_since_last)
predicted_30 = int(predicted_today + km_per_day * 30)
confidence = min(0.95, 0.35 + len(df) * 0.035 + min(days, 365) / 730)
insight = (
"Пробег стабилен, прогноз надежный."
if confidence >= 0.75
else "Прогноз предварительный: точность вырастет после нескольких новых записей."
)
return OdometerPrediction(
car_id=car_id,
samples=len(df),
current_odometer=int(last["odometer"]),
predicted_today=predicted_today,
predicted_30_days=predicted_30,
avg_km_per_day=round(km_per_day, 1),
avg_km_per_month=round(km_per_day * 30.4, 1),
confidence=round(confidence, 2),
insight=insight,
)

View File

@@ -0,0 +1,57 @@
CAR_CATALOG: dict[str, list[str]] = {
"Acura": ["ILX", "Integra", "MDX", "RDX", "TLX", "TSX"],
"Alfa Romeo": ["Giulia", "Giulietta", "Stelvio", "Tonale"],
"Audi": ["A1", "A3", "A4", "A5", "A6", "A7", "A8", "Q2", "Q3", "Q5", "Q7", "Q8", "e-tron", "TT"],
"BMW": ["1 Series", "2 Series", "3 Series", "4 Series", "5 Series", "7 Series", "X1", "X2", "X3", "X4", "X5", "X6", "X7", "i3", "i4", "iX"],
"BYD": ["Atto 3", "Dolphin", "Han", "Seal", "Song Plus", "Tang"],
"Cadillac": ["ATS", "CT4", "CT5", "Escalade", "SRX", "XT4", "XT5", "XT6"],
"Changan": ["Alsvin", "CS35 Plus", "CS55 Plus", "CS75 Plus", "UNI-K", "UNI-T", "UNI-V"],
"Chery": ["Arrizo 5", "Arrizo 8", "Tiggo 4", "Tiggo 7", "Tiggo 8", "Tiggo 9"],
"Chevrolet": ["Aveo", "Camaro", "Captiva", "Cobalt", "Cruze", "Equinox", "Lacetti", "Malibu", "Niva", "Spark", "Tahoe", "Trailblazer"],
"Citroen": ["Berlingo", "C3", "C4", "C5 Aircross", "C-Elysee", "Jumpy"],
"Daewoo": ["Gentra", "Lanos", "Matiz", "Nexia"],
"Daihatsu": ["Boon", "Copen", "Mira", "Rocky", "Terios"],
"Dodge": ["Caliber", "Challenger", "Charger", "Durango", "Journey", "Ram"],
"Exeed": ["LX", "RX", "TXL", "VX"],
"FAW": ["Bestune B70", "Bestune T77", "Bestune T99", "Oley"],
"Fiat": ["500", "Albea", "Doblo", "Ducato", "Panda", "Punto", "Tipo"],
"Ford": ["Bronco", "EcoSport", "Edge", "Escape", "Explorer", "F-150", "Fiesta", "Focus", "Fusion", "Kuga", "Mondeo", "Mustang", "Ranger", "Transit"],
"Geely": ["Atlas", "Coolray", "Emgrand", "Monjaro", "Okavango", "Preface", "Tugella"],
"Genesis": ["G70", "G80", "G90", "GV60", "GV70", "GV80"],
"GreatWall": ["Coolbear", "Hover", "Poer", "Safe", "Wingle"],
"Haval": ["Dargo", "F7", "F7x", "H2", "H6", "H9", "Jolion", "M6"],
"Honda": ["Accord", "Civic", "CR-V", "Crosstour", "Fit", "HR-V", "Insight", "Jazz", "Odyssey", "Pilot", "Ridgeline", "Stepwgn", "Vezel"],
"Hongqi": ["E-HS9", "H5", "H7", "H9", "HS5"],
"Hyundai": ["Accent", "Avante", "Creta", "Elantra", "Genesis", "Getz", "Grandeur", "i20", "i30", "ix35", "Kona", "Palisade", "Santa Fe", "Solaris", "Sonata", "Staria", "Tucson", "Venue"],
"Infiniti": ["EX", "FX", "G", "JX", "Q30", "Q50", "Q60", "Q70", "QX50", "QX56", "QX60", "QX70", "QX80"],
"Jaguar": ["E-Pace", "F-Pace", "F-Type", "I-Pace", "XE", "XF", "XJ"],
"Jeep": ["Cherokee", "Compass", "Grand Cherokee", "Renegade", "Wrangler"],
"Jetour": ["Dashing", "T2", "X70", "X90"],
"KIA": ["Carens", "Carnival", "Ceed", "Cerato", "Forte", "K3", "K5", "Mohave", "Morning", "Niro", "Optima", "Picanto", "Rio", "Seltos", "Sorento", "Soul", "Sportage", "Stinger", "Telluride"],
"LADA": ["2107", "Granta", "Kalina", "Largus", "Niva Legend", "Niva Travel", "Priora", "Vesta", "XRAY"],
"Lexus": ["CT", "ES", "GS", "GX", "IS", "LC", "LS", "LX", "NX", "RX", "UX"],
"LiXiang": ["L6", "L7", "L8", "L9", "MEGA"],
"Lincoln": ["Aviator", "Continental", "Corsair", "MKC", "MKX", "Navigator"],
"Mazda": ["2", "3", "5", "6", "Atenza", "BT-50", "CX-3", "CX-30", "CX-5", "CX-7", "CX-9", "CX-50", "MX-5"],
"Mercedes": ["A-Class", "B-Class", "C-Class", "CLA", "CLS", "E-Class", "G-Class", "GLA", "GLB", "GLC", "GLE", "GLS", "S-Class", "Sprinter", "V-Class", "Vito"],
"Mini": ["Clubman", "Cooper", "Countryman", "Paceman"],
"Mitsubishi": ["ASX", "Eclipse Cross", "Galant", "L200", "Lancer", "Montero", "Outlander", "Pajero", "Pajero Sport", "RVR"],
"Nissan": ["Almera", "Altima", "Juke", "Leaf", "Maxima", "Murano", "Navara", "Note", "Pathfinder", "Patrol", "Qashqai", "Rogue", "Sentra", "Serena", "Teana", "Terrano", "Tiida", "X-Trail"],
"Omoda": ["C5", "E5", "S5"],
"Opel": ["Astra", "Combo", "Corsa", "Crossland", "Insignia", "Meriva", "Mokka", "Vectra", "Zafira"],
"Peugeot": ["2008", "206", "207", "208", "3008", "301", "307", "308", "408", "5008", "Partner", "Traveller"],
"Porsche": ["911", "Boxster", "Cayenne", "Cayman", "Macan", "Panamera", "Taycan"],
"Renault": ["Arkana", "Captur", "Clio", "Duster", "Fluence", "Kangoo", "Kaptur", "Koleos", "Logan", "Megane", "Sandero", "Scenic"],
"Seat": ["Alhambra", "Arona", "Ateca", "Ibiza", "Leon", "Toledo"],
"Skoda": ["Fabia", "Karoq", "Kodiaq", "Octavia", "Rapid", "Roomster", "Scala", "Superb", "Yeti"],
"Smart": ["Forfour", "Fortwo"],
"SsangYong": ["Actyon", "Korando", "Kyron", "Musso", "Rexton", "Tivoli"],
"Subaru": ["BRZ", "Forester", "Impreza", "Legacy", "Levorg", "Outback", "Tribeca", "WRX", "XV"],
"Suzuki": ["Baleno", "Grand Vitara", "Ignis", "Jimny", "S-Cross", "Solio", "Swift", "SX4", "Vitara"],
"Tesla": ["Model 3", "Model S", "Model X", "Model Y"],
"Toyota": ["4Runner", "Alphard", "Aqua", "Avalon", "Avensis", "C-HR", "Camry", "Corolla", "Crown", "Fortuner", "Harrier", "Highlander", "Hilux", "Land Cruiser", "Noah", "Prado", "Prius", "RAV4", "Sequoia", "Sienna", "Vellfire", "Venza", "Yaris"],
"Volkswagen": ["Amarok", "Arteon", "Caddy", "Caravelle", "Golf", "Jetta", "Multivan", "Passat", "Polo", "Taos", "Teramont", "Tiguan", "Touareg", "Touran", "Transporter"],
"Volvo": ["C30", "S40", "S60", "S80", "S90", "V40", "V60", "V90", "XC40", "XC60", "XC70", "XC90"],
"Zeekr": ["001", "007", "009", "X"],
"УАЗ": ["Буханка", "Патриот", "Пикап", "Хантер"],
}

1
bot/__init__.py Normal file
View File

@@ -0,0 +1 @@

40
bot/api_client.py Normal file
View File

@@ -0,0 +1,40 @@
from typing import Any
import httpx
from app.core.config import settings
class ApiClient:
def __init__(self) -> None:
self.base_url = settings.api_base_url.rstrip("/")
async def upsert_user(self, telegram_user: Any) -> dict[str, Any]:
payload = {
"telegram_id": telegram_user.id,
"username": telegram_user.username,
"first_name": telegram_user.first_name,
"last_name": telegram_user.last_name,
}
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.post("/api/users", json=payload)
response.raise_for_status()
return response.json()
async def list_cars(self, owner_id: int) -> list[dict[str, Any]]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.get("/api/cars", params={"owner_id": owner_id})
response.raise_for_status()
return response.json()
async def create_car(self, owner_id: int, name: str) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.post("/api/cars", json={"owner_id": owner_id, "name": name})
response.raise_for_status()
return response.json()
async def stats(self, car_id: int) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.get(f"/api/cars/{car_id}/stats")
response.raise_for_status()
return response.json()

114
bot/main.py Normal file
View File

@@ -0,0 +1,114 @@
import asyncio
import logging
from aiogram import Bot, Dispatcher, F
from aiogram.filters import Command, CommandObject
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
Message,
ReplyKeyboardMarkup,
WebAppInfo,
)
from app.core.config import settings
from bot.api_client import ApiClient
logging.basicConfig(level=logging.INFO)
dp = Dispatcher()
api = ApiClient()
def main_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.webapp_url))],
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
],
resize_keyboard=True,
)
@dp.message(Command("start"))
async def start(message: Message) -> None:
user = await api.upsert_user(message.from_user)
text = (
f"Готово, {user.get('first_name') or 'водитель'}.\n\n"
"Здесь можно вести заправки, обслуживание, ремонты и смотреть стоимость владения. "
"Основная работа идет в mini app, а бот остается быстрым входом."
)
await message.answer(text, reply_markup=main_keyboard())
@dp.message(Command("add_car"))
async def add_car(message: Message, command: CommandObject) -> None:
user = await api.upsert_user(message.from_user)
name = command.args.strip() if command.args else ""
if not name:
await message.answer("Напиши так: /add_car Toyota Camry")
return
car = await api.create_car(user["id"], name)
await message.answer(f"Добавил авто: {car['name']}")
@dp.message(Command("cars"))
@dp.message(F.text == "Мои авто")
async def cars(message: Message) -> None:
user = await api.upsert_user(message.from_user)
items = await api.list_cars(user["id"])
if not items:
await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.")
return
buttons = [
[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items
]
await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@dp.callback_query(F.data.startswith("stats:"))
async def show_stats(callback: CallbackQuery) -> None:
car_id = int(callback.data.split(":", 1)[1])
stats = await api.stats(car_id)
consumption = stats["avg_consumption_l_per_100km"]
cost_per_km = stats["cost_per_km"]
await callback.message.answer(
"\n".join(
[
"Статистика авто:",
f"Расходы всего: {stats['total_cost']}",
f"Топливо: {stats['fuel_cost']}",
f"Сервис и ремонты: {stats['service_cost']}",
f"Пробег по записям: {stats['distance_km']} км",
f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных",
f"Стоимость 1 км: {cost_per_km:.2f}" if cost_per_km else "Стоимость 1 км: нет данных",
]
)
)
await callback.answer()
@dp.message(F.text == "Помощь")
@dp.message(Command("help"))
async def help_message(message: Message) -> None:
await message.answer(
"Команды:\n"
"/add_car Название - быстро добавить авто\n"
"/cars - список авто и статистика\n\n"
"Заправки, ремонты и обслуживание удобнее вести через кнопку «Открыть гараж».",
reply_markup=main_keyboard(),
)
async def main() -> None:
if not settings.bot_token:
raise RuntimeError("BOT_TOKEN is empty")
bot = Bot(settings.bot_token)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-drivers}
POSTGRES_USER: ${POSTGRES_USER:-drivers}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-drivers}
ports:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-drivers} -d ${POSTGRES_DB:-drivers}"]
interval: 5s
timeout: 3s
retries: 10
api:
build: .
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
env_file:
- path: .env
required: false
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://drivers:drivers@db:5432/drivers}
BOT_TOKEN: ${BOT_TOKEN:-}
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000}
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
bot:
build: .
command: python -m bot.main
env_file:
- path: .env
required: false
environment:
BOT_TOKEN: ${BOT_TOKEN:-}
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000}
depends_on:
- api
volumes:
pgdata:

37
pyproject.toml Normal file
View File

@@ -0,0 +1,37 @@
[project]
name = "drivers-bot"
version = "0.1.0"
description = "Telegram mini app and bot for car ownership expenses"
requires-python = ">=3.11"
dependencies = [
"aiogram>=3.4,<4.0",
"alembic>=1.13,<2.0",
"asyncpg>=0.29,<1.0",
"fastapi>=0.110,<1.0",
"httpx>=0.27,<1.0",
"matplotlib>=3.8,<4.0",
"pandas>=2.2,<3.0",
"pydantic-settings>=2.2,<3.0",
"python-multipart>=0.0.9,<1.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"uvicorn[standard]>=0.29,<1.0",
]
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*", "bot*"]
[project.optional-dependencies]
dev = [
"ruff>=0.4,<1.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

291
web/index.html Normal file
View File

@@ -0,0 +1,291 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16806a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Гараж" />
<title>Гараж</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Drivers</p>
<h1>Гараж</h1>
</div>
<div class="top-actions">
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить"></button>
<button class="icon-btn" id="menuBtn" title="Меню" aria-label="Меню"></button>
</div>
</header>
<section class="hero-grid">
<div class="summary-card">
<span>Автомобиль</span>
<strong id="selectedCarTitle">Не выбран</strong>
<small id="selectedCarMeta">Добавь авто или выбери из списка</small>
</div>
<div class="summary-card accent">
<span>Расходы</span>
<strong id="summaryTotal">0</strong>
<small>топливо, сервис и ремонты</small>
</div>
<div class="summary-card blue">
<span>Средний расход</span>
<strong id="summaryConsumption">-</strong>
<small>л/100 км по полным данным</small>
</div>
</section>
<section class="layout">
<aside class="panel reveal">
<div class="section-head">
<h2>Автомобили</h2>
<button class="ghost-btn" id="addCarQuickBtn">+</button>
</div>
<div id="cars" class="cars"></div>
</aside>
<section class="workspace reveal">
<section class="progress-strip">
<div>
<span>Профиль учета</span>
<strong id="scoreTitle">Старт</strong>
</div>
<div class="progress-track"><span id="scoreBar"></span></div>
<small id="scoreHint">Добавь авто и первую запись, чтобы видеть точные отчеты</small>
</section>
<section class="report-bar">
<div>
<p class="eyebrow">Отчет</p>
<h2>Стоимость владения</h2>
</div>
<div class="period-controls">
<select id="periodPreset" aria-label="Период отчета">
<option value="all">Весь срок</option>
<option value="month">Месяц</option>
<option value="day">День</option>
<option value="quarter">Квартал</option>
<option value="year">Год</option>
<option value="custom">Свой период</option>
</select>
<input id="periodFrom" type="date" aria-label="Дата начала" />
<input id="periodTo" type="date" aria-label="Дата окончания" />
</div>
</section>
<div class="stats" id="stats"></div>
<section class="quick-actions">
<button class="action-card active" data-action="fuel">
<span>Заправка</span>
<strong>30 сек</strong>
</button>
<button class="action-card" data-action="service">
<span>Сервис</span>
<strong>ТО / ремонт</strong>
</button>
<button class="action-card" data-action="scan">
<span>Скан чека</span>
<strong>OCR</strong>
</button>
</section>
<section class="charts">
<div class="chart-card">
<div class="section-head">
<h2>Динамика расходов</h2>
</div>
<canvas id="expensesChart" width="720" height="260"></canvas>
</div>
<div class="chart-card compact">
<div class="section-head">
<h2>Структура</h2>
</div>
<canvas id="splitChart" width="280" height="260"></canvas>
</div>
</section>
<form id="fuelForm" class="entry-form quick-form">
<label>
Дата
<input name="entry_date" type="date" required />
</label>
<label>
Одометр, км
<input name="odometer" type="number" min="0" required />
</label>
<label>
Литры
<input name="liters" type="number" min="0" step="0.001" required />
</label>
<label>
Цена за литр
<input name="price_per_liter" type="number" min="0" step="0.01" required />
</label>
<label>
АЗС
<select name="station">
<option value="">Не выбрано</option>
<option>Shell</option>
<option>Lukoil</option>
<option>Gazprom</option>
<option>Rosneft</option>
<option>Neste</option>
</select>
</label>
<label class="check">
<input name="is_full_tank" type="checkbox" checked />
Полный бак
</label>
<button type="submit">Сохранить заправку</button>
</form>
<form id="serviceForm" class="entry-form hidden">
<label>
Дата
<input name="entry_date" type="date" required />
</label>
<label>
Одометр, км
<input name="odometer" type="number" min="0" />
</label>
<label>
Тип
<select name="service_type">
<option value="maintenance">Обслуживание</option>
<option value="repair">Ремонт</option>
<option value="fluid">Жидкости</option>
<option value="tire">Шины</option>
<option value="inspection">Осмотр</option>
<option value="insurance">Страховка</option>
<option value="tax">Налог</option>
<option value="other">Другое</option>
</select>
</label>
<label>
Что сделано
<input name="title" placeholder="Замена масла" required />
</label>
<div class="preset-row">
<button type="button" data-service-title="Замена масла" data-service-type="maintenance">Масло</button>
<button type="button" data-service-title="Шиномонтаж" data-service-type="tire">Шины</button>
<button type="button" data-service-title="Диагностика" data-service-type="inspection">Осмотр</button>
</div>
<label>
Стоимость
<input name="total_cost" type="number" min="0" step="0.01" required />
</label>
<label>
Исполнитель
<input name="vendor" placeholder="СТО / магазин" />
</label>
<button type="submit">Сохранить запись</button>
</form>
</section>
</section>
</main>
<div class="drawer hidden" id="userDrawer">
<div class="drawer-panel">
<div class="section-head">
<h2>Меню</h2>
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
</div>
<button class="menu-row" id="openCarFormBtn">Добавить автомобиль</button>
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
<button class="menu-row" id="openNotificationsBtn">Уведомления</button>
<button class="menu-row" id="openScanBtn">Сканировать чек</button>
<section class="drawer-section hidden" id="settingsSection">
<h2>Настройки</h2>
<form id="settingsForm" class="grid-form drawer-form">
<label>
Язык
<select name="locale" id="localeSelect">
<option value="ru">Русский</option>
<option value="en">English</option>
<option value="ko">한국어</option>
</select>
</label>
<label>
Валюта
<select name="currency" id="currencySelect">
<option value="RUB">RUB ₽</option>
<option value="USD">USD $</option>
<option value="EUR">EUR €</option>
<option value="KRW">KRW ₩</option>
<option value="KZT">KZT ₸</option>
</select>
</label>
<button type="submit">Сохранить настройки</button>
</form>
</section>
<section class="drawer-section hidden" id="notificationsSection">
<h2>Уведомления</h2>
<div class="tip-card" id="notificationStatus">Напомним о ТО, страховке и регулярном внесении пробега.</div>
<button type="button" class="wide-btn" id="enableNotificationsBtn">Включить уведомления</button>
</section>
<section class="drawer-section hidden" id="scanSection">
<h2>Скан чека</h2>
<form id="ocrForm" class="grid-form drawer-form">
<label>
Фото или файл чека
<span class="scan-actions">
<button type="button" class="ghost-btn" id="scanCameraBtn">Сфотографировать</button>
<button type="button" class="ghost-btn" id="scanFileBtn">Выбрать файл</button>
</span>
<input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" />
<input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" />
</label>
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
<button type="submit">Распознать</button>
</form>
<div id="ocrResult" class="tip-card">После распознавания поля заправки заполнятся автоматически.</div>
</section>
<section class="drawer-section" id="carFormSection">
<h2>Новое авто</h2>
<form id="carForm" class="grid-form drawer-form">
<label>
Название авто
<input name="name" placeholder="Toyota Camry" required />
</label>
<label>
Марка
<select name="make" id="makeSelect" required></select>
</label>
<label>
Модель
<select name="model" id="modelSelect" required></select>
</label>
<label>
Год
<input name="year" type="number" min="1900" max="2100" />
</label>
<button type="submit">Добавить авто</button>
</form>
</section>
</div>
</div>
<div class="report-sheet hidden" id="reportSheet">
<div class="sheet-panel">
<div class="section-head">
<h2 id="reportTitle">Отчет</h2>
<button class="icon-btn" id="closeReportBtn" aria-label="Закрыть">×</button>
</div>
<div id="reportBody"></div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

19
web/manifest.webmanifest Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "Гараж: учет авто",
"short_name": "Гараж",
"description": "Учет заправок, сервиса, ремонтов и стоимости владения автомобилем.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#eef3f1",
"theme_color": "#16806a",
"icons": [
{
"src": "/static/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

1102
web/static/app.js Normal file

File diff suppressed because it is too large Load Diff

5
web/static/icon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Гараж">
<rect width="512" height="512" rx="108" fill="#16806a"/>
<path fill="#ffffff" d="M126 283h260l-22-76c-7-23-25-37-49-37H197c-24 0-42 14-49 37l-22 76Zm63-78h134c9 0 15 5 18 14l10 34H161l10-34c3-9 9-14 18-14Z"/>
<path fill="#dff3ec" d="M101 277c0-13 10-23 23-23h264c13 0 23 10 23 23v68c0 13-10 23-23 23h-15v17c0 12-10 22-22 22h-24c-12 0-22-10-22-22v-17h-98v17c0 12-10 22-22 22h-24c-12 0-22-10-22-22v-17h-15c-13 0-23-10-23-23v-68Zm58 57a30 30 0 1 0 0-60 30 30 0 0 0 0 60Zm194 0a30 30 0 1 0 0-60 30 30 0 0 0 0 60Z"/>
</svg>

After

Width:  |  Height:  |  Size: 623 B

878
web/static/styles.css Normal file
View File

@@ -0,0 +1,878 @@
:root {
color-scheme: light;
--bg: #eef3f1;
--text: #18211f;
--muted: #73807b;
--line: #d8e1de;
--surface: #ffffff;
--soft: #f7faf8;
--accent: #16806a;
--accent-2: #3f7fba;
--fuel: #36a388;
--service: #3f7fba;
--danger: #b5473e;
--shadow: 0 14px 40px rgba(24, 33, 31, 0.08);
}
.top-actions {
display: flex;
gap: 8px;
}
.ghost-btn {
min-height: 34px;
padding: 0 12px;
background: #edf3f0;
color: var(--accent);
box-shadow: none;
}
.progress-strip {
display: grid;
grid-template-columns: 150px 1fr;
gap: 10px 14px;
align-items: center;
padding: 12px;
margin-bottom: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #f5faf8;
}
.progress-strip span,
.progress-strip small {
color: var(--muted);
}
.progress-strip strong {
display: block;
margin-top: 2px;
}
.progress-strip small {
grid-column: 2;
}
.progress-track {
height: 10px;
overflow: hidden;
border-radius: 999px;
background: #e3ebe7;
}
.progress-track span {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
transition: width 360ms ease;
}
.stat {
display: grid;
width: 100%;
color: var(--text);
text-align: left;
box-shadow: none;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.action-card {
display: grid;
gap: 4px;
min-height: 72px;
color: var(--text);
text-align: left;
background: var(--soft);
border: 1px solid var(--line);
box-shadow: none;
}
.action-card span {
color: var(--muted);
}
.action-card.active {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.action-card.active span {
color: rgba(255, 255, 255, 0.78);
}
.preset-row {
display: flex;
gap: 8px;
align-items: end;
flex-wrap: wrap;
}
.preset-row button {
min-height: 36px;
padding: 0 12px;
background: #edf3f0;
color: var(--text);
box-shadow: none;
}
.drawer,
.report-sheet {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
align-items: end;
background: rgba(17, 25, 22, 0.32);
backdrop-filter: blur(5px);
}
.drawer.hidden,
.report-sheet.hidden {
display: none;
}
.drawer-panel,
.sheet-panel {
max-height: 88vh;
overflow: auto;
padding: 18px;
border-radius: 16px 16px 0 0;
background: var(--surface);
box-shadow: 0 -18px 50px rgba(24, 33, 31, 0.18);
animation: sheetUp 220ms ease both;
}
.drawer-panel {
width: min(520px, 100%);
margin-left: auto;
}
.drawer-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.drawer-form {
grid-template-columns: 1fr;
margin-top: 12px;
}
.menu-row {
display: block;
width: 100%;
margin-bottom: 8px;
background: var(--soft);
color: var(--text);
text-align: left;
border: 1px solid var(--line);
box-shadow: none;
}
.wide-btn {
width: 100%;
margin-top: 10px;
}
.scan-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.scan-actions button {
width: 100%;
}
.hidden-file {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.file-hint {
min-height: 42px;
display: flex;
align-items: center;
color: var(--muted);
font-size: 13px;
word-break: break-word;
}
.sheet-panel {
width: min(720px, 100%);
margin: 0 auto;
}
.report-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.report-metric,
.tip-card {
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.report-metric span {
display: block;
color: var(--muted);
font-size: 12px;
}
.report-metric strong {
display: block;
margin-top: 6px;
font-size: 20px;
}
.report-records {
max-height: 360px;
overflow: auto;
}
@keyframes sheetUp {
from {
transform: translateY(24px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 980px) {
.shell {
padding: 14px 12px 96px;
}
.hero-grid {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 2px;
}
.summary-card {
min-width: 78vw;
scroll-snap-align: start;
}
.cars {
display: flex;
overflow-x: auto;
padding-bottom: 2px;
}
.car-item {
min-width: 230px;
}
.quick-actions {
position: sticky;
top: 0;
z-index: 5;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 6px 0;
background: rgba(238, 243, 241, 0.92);
backdrop-filter: blur(8px);
}
.action-card {
min-height: 58px;
padding: 8px;
}
.progress-strip,
.report-grid {
grid-template-columns: 1fr;
}
.progress-strip small {
grid-column: auto;
}
}
@media (max-width: 980px) {
.shell {
padding: 14px 12px 96px;
}
.hero-grid {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 2px;
}
.summary-card {
min-width: 78vw;
scroll-snap-align: start;
}
.cars {
display: flex;
overflow-x: auto;
padding-bottom: 2px;
}
.car-item {
min-width: 230px;
}
.quick-actions {
position: sticky;
top: 0;
z-index: 5;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 6px 0;
background: rgba(238, 243, 241, 0.92);
backdrop-filter: blur(8px);
}
.action-card {
min-height: 58px;
padding: 8px;
}
.progress-strip,
.report-grid {
grid-template-columns: 1fr;
}
.progress-strip small {
grid-column: auto;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 243, 241, 0)),
var(--bg);
color: var(--text);
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
button {
border: 0;
background: var(--accent);
color: #fff;
min-height: 42px;
border-radius: 7px;
padding: 0 16px;
cursor: pointer;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease,
border-color 160ms ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(22, 128, 106, 0.18);
}
button:active {
transform: translateY(0) scale(0.99);
}
button:disabled {
opacity: 0.55;
}
.shell {
width: min(1200px, 100%);
margin: 0 auto;
padding: 18px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 0 18px;
}
.eyebrow {
margin: 0;
color: var(--accent-2);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: clamp(30px, 4vw, 42px);
}
h2 {
font-size: 18px;
}
.icon-btn {
width: 44px;
padding: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--line);
box-shadow: none;
}
.loading .icon-btn {
animation: spin 800ms linear infinite;
}
.hero-grid {
display: grid;
grid-template-columns: 1.25fr 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.summary-card,
.band,
.panel,
.workspace,
.chart-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.summary-card {
display: grid;
gap: 6px;
min-height: 112px;
padding: 16px;
overflow: hidden;
position: relative;
animation: rise 420ms ease both;
}
.summary-card::after {
content: "";
position: absolute;
inset: auto -20px -30px auto;
width: 130px;
height: 90px;
background: rgba(54, 163, 136, 0.12);
transform: rotate(-14deg);
}
.summary-card.accent::after {
background: rgba(54, 163, 136, 0.16);
}
.summary-card.blue::after {
background: rgba(63, 127, 186, 0.15);
}
.summary-card span,
.summary-card small,
.stat span,
.stat em {
color: var(--muted);
}
.summary-card strong {
font-size: clamp(22px, 4vw, 31px);
line-height: 1.05;
z-index: 1;
}
.summary-card small {
z-index: 1;
}
.band {
padding: 14px;
margin-bottom: 16px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.grid-form,
.entry-form {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr 120px auto;
gap: 12px;
align-items: end;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
input,
select {
width: 100%;
min-height: 42px;
border: 1px solid var(--line);
border-radius: 7px;
background: #fff;
color: var(--text);
padding: 0 11px;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(22, 128, 106, 0.12);
}
select:disabled {
background: #f1f5f3;
color: #9aa4a0;
}
.layout {
display: grid;
grid-template-columns: 310px 1fr;
gap: 16px;
align-items: start;
}
.panel,
.workspace {
padding: 16px;
}
.report-bar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.report-bar .eyebrow {
margin-bottom: 2px;
}
.period-controls {
display: grid;
grid-template-columns: 140px 150px 150px;
gap: 8px;
align-items: center;
}
.panel {
position: sticky;
top: 12px;
}
.cars {
display: grid;
gap: 9px;
}
.car-item {
display: grid;
grid-template-columns: 42px 1fr;
align-items: center;
gap: 10px;
width: 100%;
min-height: 64px;
background: var(--soft);
color: var(--text);
text-align: left;
border: 1px solid transparent;
box-shadow: none;
}
.car-item small {
display: block;
margin-top: 2px;
color: var(--muted);
}
.car-item.active {
border-color: rgba(22, 128, 106, 0.48);
background: #e5f4ef;
}
.car-badge {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: #d7ebe5;
color: #11634f;
font-size: 13px;
font-weight: 800;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.stat {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
min-height: 92px;
background: var(--soft);
}
.stat strong {
display: block;
margin-top: 6px;
font-size: 22px;
}
.stat em {
display: block;
margin-top: 4px;
font-size: 12px;
font-style: normal;
}
.pop {
animation: pop 260ms ease both;
}
.charts {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 12px;
margin-bottom: 14px;
}
.chart-card {
min-height: 320px;
padding: 14px;
box-shadow: none;
}
.chart-card canvas {
display: block;
width: 100%;
height: 250px;
}
.chart-card.compact {
min-width: 0;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
padding: 4px;
width: fit-content;
background: #edf2ef;
border-radius: 8px;
}
.tab {
background: transparent;
color: var(--text);
box-shadow: none;
}
.tab:hover {
box-shadow: none;
}
.tab.active {
background: var(--accent-2);
color: #fff;
}
.entry-form {
grid-template-columns: repeat(3, minmax(140px, 1fr));
border-top: 1px solid var(--line);
padding-top: 14px;
animation: fade 180ms ease both;
}
.check {
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
}
.check input {
width: 18px;
min-height: 18px;
accent-color: var(--accent);
}
.hidden {
display: none;
}
.history {
margin-top: 20px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.record {
display: grid;
grid-template-columns: 110px 1fr auto;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--line);
align-items: center;
animation: fade 220ms ease both;
}
.record small {
color: var(--muted);
}
.record .fuel {
color: var(--fuel);
}
.record .service {
color: var(--service);
}
.empty {
color: var(--muted);
padding: 18px 0;
}
.error {
margin: 12px auto;
width: min(920px, calc(100% - 24px));
padding: 12px 14px;
border-radius: 8px;
background: #fff1ef;
border: 1px solid #f0c8c1;
color: var(--danger);
}
.reveal {
animation: rise 360ms ease both;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pop {
0% {
opacity: 0;
transform: scale(0.98);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.hero-grid,
.layout,
.charts,
.grid-form,
.entry-form,
.stats,
.period-controls {
grid-template-columns: 1fr;
}
.report-bar {
align-items: stretch;
flex-direction: column;
}
.panel {
position: static;
}
.tabs {
width: 100%;
}
.tab {
flex: 1;
}
.record {
grid-template-columns: 1fr;
}
}

55
web/sw.js Normal file
View File

@@ -0,0 +1,55 @@
const CACHE_NAME = "drivers-garage-v1";
const APP_SHELL = [
"/",
"/static/app.js",
"/static/styles.css",
"/static/icon.svg",
"/manifest.webmanifest",
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
if (new URL(request.url).pathname.startsWith("/api/")) return;
event.respondWith(
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return response;
})
.catch(() => caches.match(request).then((cached) => cached || caches.match("/"))),
);
});
self.addEventListener("push", (event) => {
const data = event.data?.json?.() || {};
event.waitUntil(
self.registration.showNotification(data.title || "Гараж", {
body: data.body || "Пора обновить данные по автомобилю.",
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: data.tag || "drivers-garage-reminder",
data: data.url || "/",
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data || "/"));
});