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