first commit
This commit is contained in:
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"],
|
||||
"УАЗ": ["Буханка", "Патриот", "Пикап", "Хантер"],
|
||||
}
|
||||
Reference in New Issue
Block a user