first commit

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

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

@@ -0,0 +1 @@

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

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

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

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

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

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

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

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

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

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