first commit
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
17
Dockerfile
Normal 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
68
README.md
Normal 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
40
alembic.ini
Normal 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
62
alembic/env.py
Normal 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()
|
||||||
123
alembic/versions/202605110001_initial_schema.py
Normal file
123
alembic/versions/202605110001_initial_schema.py
Normal 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)
|
||||||
49
alembic/versions/202605110002_car_catalog.py
Normal file
49
alembic/versions/202605110002_car_catalog.py
Normal 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")
|
||||||
26
alembic/versions/202605110003_user_preferences.py
Normal file
26
alembic/versions/202605110003_user_preferences.py
Normal 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
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
57
app/api/cars.py
Normal file
57
app/api/cars.py
Normal 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
21
app/api/catalog.py
Normal 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
160
app/api/entries.py
Normal 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
41
app/api/ocr.py
Normal 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
46
app/api/users.py
Normal 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
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
22
app/core/config.py
Normal file
22
app/core/config.py
Normal 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
1
app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
app/db/base.py
Normal file
5
app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
214
app/db/seed.py
Normal file
214
app/db/seed.py
Normal 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
13
app/db/session.py
Normal 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
29
app/main.py
Normal 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
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
55
app/models/car.py
Normal file
55
app/models/car.py
Normal 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
58
app/models/expense.py
Normal 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
24
app/models/user.py
Normal 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
1
app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
58
app/schemas/car.py
Normal file
58
app/schemas/car.py
Normal 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
89
app/schemas/expense.py
Normal 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
26
app/schemas/user.py
Normal 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
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
134
app/services/calculations.py
Normal file
134
app/services/calculations.py
Normal 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,
|
||||||
|
)
|
||||||
57
app/services/catalog_data.py
Normal file
57
app/services/catalog_data.py
Normal 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
1
bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
40
bot/api_client.py
Normal file
40
bot/api_client.py
Normal 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
114
bot/main.py
Normal 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
49
docker-compose.yml
Normal 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
37
pyproject.toml
Normal 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
291
web/index.html
Normal 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
19
web/manifest.webmanifest
Normal 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
1102
web/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
5
web/static/icon.svg
Normal file
5
web/static/icon.svg
Normal 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
878
web/static/styles.css
Normal 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
55
web/sw.js
Normal 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 || "/"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user