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/services/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

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

View File

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