Complete CarPass product flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 21:19:37 +09:00
parent a83f55c646
commit c0014ab4ea
28 changed files with 3006 additions and 159 deletions

View File

@@ -1,14 +1,22 @@
from datetime import UTC, datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit, require_platform_role
from app.core.config import settings
from app.db.session import get_session
from app.models.car import AuditLog, ServiceCenter, ServiceCenterVerification, ServiceVisit
from app.models.car import (
AuditLog,
ServiceCenter,
ServiceCenterVerification,
ServiceEmployee,
ServiceVisit,
)
from app.models.user import User
from app.schemas.service_center import ServiceCenterRead, ServiceVisitRead
from app.schemas.service_center import AdminModerationDecision, ServiceCenterRead, ServiceVisitRead
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -31,9 +39,23 @@ async def pending_service_centers(
return list(result.scalars())
@router.get("/service-centers/{service_center_id}", response_model=ServiceCenterRead)
async def admin_service_center_detail(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
return center
@router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead)
async def verify_service_center(
service_center_id: int,
payload: AdminModerationDecision | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
@@ -43,8 +65,21 @@ async def verify_service_center(
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "approved"
center.verified_at = datetime.now(UTC)
await mark_latest_verification(session, center.id, "approved", current_user.id)
await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id)
if center.owner_user_id:
owner = await session.get(User, center.owner_user_id)
if owner:
owner.platform_role = "service_owner"
await ensure_owner_employee(session, center.id, owner.id)
await notify_user(owner, f"Заявка СТО «{center.display_name or center.name}» одобрена. Панель СТО доступна в CarPass.")
await mark_latest_verification(session, center.id, "approved", current_user.id, payload)
await log_audit(
session,
actor=current_user,
action="service_center.verify",
target_type="service_center",
target_id=center.id,
metadata={"comment": payload.comment if payload else None},
)
await session.commit()
await session.refresh(center)
return center
@@ -53,6 +88,7 @@ async def verify_service_center(
@router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead)
async def reject_service_center(
service_center_id: int,
payload: AdminModerationDecision | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
@@ -61,8 +97,20 @@ async def reject_service_center(
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "rejected"
await mark_latest_verification(session, center.id, "rejected", current_user.id)
await log_audit(session, actor=current_user, action="service_center.reject", target_type="service_center", target_id=center.id)
if center.owner_user_id:
owner = await session.get(User, center.owner_user_id)
if owner:
reason = payload.reason or payload.comment if payload else None
await notify_user(owner, f"Заявка СТО «{center.display_name or center.name}» отклонена.{f' Причина: {reason}' if reason else ''}")
await mark_latest_verification(session, center.id, "rejected", current_user.id, payload)
await log_audit(
session,
actor=current_user,
action="service_center.reject",
target_type="service_center",
target_id=center.id,
metadata={"reason": payload.reason if payload else None, "comment": payload.comment if payload else None},
)
await session.commit()
await session.refresh(center)
return center
@@ -71,6 +119,7 @@ async def reject_service_center(
@router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead)
async def suspend_service_center(
service_center_id: int,
payload: AdminModerationDecision | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
@@ -80,7 +129,50 @@ async def suspend_service_center(
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "suspended"
center.suspended_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_center.suspend", target_type="service_center", target_id=center.id)
if center.owner_user_id:
owner = await session.get(User, center.owner_user_id)
if owner:
reason = payload.reason or payload.comment if payload else None
await notify_user(owner, f"СТО «{center.display_name or center.name}» временно заблокировано.{f' Причина: {reason}' if reason else ''}")
await log_audit(
session,
actor=current_user,
action="service_center.suspend",
target_type="service_center",
target_id=center.id,
metadata={"reason": payload.reason if payload else None, "comment": payload.comment if payload else None},
)
await session.commit()
await session.refresh(center)
return center
@router.post("/service-centers/{service_center_id}/request-changes", response_model=ServiceCenterRead)
async def request_service_center_changes(
service_center_id: int,
payload: AdminModerationDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "needs_changes"
if center.owner_user_id:
owner = await session.get(User, center.owner_user_id)
if owner:
reason = payload.reason or payload.comment or "Администратор попросил уточнить данные заявки."
await notify_user(owner, f"По заявке СТО «{center.display_name or center.name}» нужны правки: {reason}")
await mark_latest_verification(session, center.id, "needs_changes", current_user.id, payload)
await log_audit(
session,
actor=current_user,
action="service_center.request_changes",
target_type="service_center",
target_id=center.id,
metadata={"reason": payload.reason, "comment": payload.comment},
)
await session.commit()
await session.refresh(center)
return center
@@ -126,7 +218,11 @@ async def disputes(
async def mark_latest_verification(
session: AsyncSession, service_center_id: int, status: str, reviewed_by: int
session: AsyncSession,
service_center_id: int,
status: str,
reviewed_by: int,
payload: AdminModerationDecision | None = None,
) -> None:
result = await session.execute(
select(ServiceCenterVerification)
@@ -139,3 +235,42 @@ async def mark_latest_verification(
verification.status = status
verification.reviewed_by = reviewed_by
verification.reviewed_at = datetime.now(UTC)
if payload and (payload.reason or payload.comment):
verification.comment = "\n".join(
item for item in [payload.reason, payload.comment] if item
)
async def ensure_owner_employee(session: AsyncSession, service_center_id: int, owner_user_id: int) -> None:
result = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.user_id == owner_user_id,
)
)
employee = result.scalar_one_or_none()
if employee is None:
session.add(
ServiceEmployee(
service_center_id=service_center_id,
user_id=owner_user_id,
role="owner",
status="active",
)
)
else:
employee.role = "owner"
employee.status = "active"
async def notify_user(user: User, text: str) -> None:
if not settings.bot_token:
return
try:
async with httpx.AsyncClient(timeout=5) as client:
await client.post(
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
data={"chat_id": str(user.telegram_id), "text": text},
)
except Exception:
return

View File

@@ -4,9 +4,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.car import Car
from app.models.car import Car, VehicleAccess
from app.models.user import User
from app.schemas.car import CarCreate, CarRead, CarUpdate
from app.services.odometer import add_odometer_history, validate_odometer_change
from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(prefix="/cars", tags=["cars"])
@@ -30,6 +31,17 @@ async def create_car(
data = apply_identity_fields(payload.model_dump(exclude={"owner_id"}))
car = Car(**data, owner_id=current_user.id)
session.add(car)
await session.flush()
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
if car.current_odometer is not None:
add_odometer_history(
session,
car,
new_odometer=car.current_odometer,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
)
await session.commit()
await session.refresh(car)
return car
@@ -75,8 +87,23 @@ async def update_car(
raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
for field, value in apply_identity_fields(payload.model_dump(exclude_unset=True)).items():
raw = apply_identity_fields(payload.model_dump(exclude_unset=True))
odometer_value = raw.pop("current_odometer", None) if "current_odometer" in raw else None
if odometer_value is not None:
validate_odometer_change(car, odometer_value, source_record_type="manual", confirm_lower_odometer=True)
for field, value in raw.items():
setattr(car, field, value)
if odometer_value is not None and odometer_value != car.current_odometer:
add_odometer_history(
session,
car,
new_odometer=odometer_value,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
confirmation_required=car.current_odometer is not None and odometer_value < car.current_odometer,
user_confirmed=True,
)
await session.commit()
await session.refresh(car)
return car

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.car import Car
from app.models.car import Car, OdometerHistory
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
from app.models.user import User
from app.schemas.expense import (
@@ -18,6 +18,7 @@ from app.schemas.expense import (
FuelEntryCreate,
FuelEntryRead,
FuelEntryUpdate,
OdometerHistoryRead,
OdometerPrediction,
OwnershipStats,
ServiceEntryCreate,
@@ -25,6 +26,11 @@ from app.schemas.expense import (
ServiceEntryUpdate,
)
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
from app.services.odometer import (
apply_odometer_from_record,
recalculate_current_odometer,
validate_odometer_change,
)
router = APIRouter(tags=["entries"])
@@ -47,40 +53,6 @@ async def ensure_entry_owner(
return entry
async def refresh_current_odometer(session: AsyncSession, car_id: int) -> None:
car = await session.get(Car, car_id)
if car is None:
return
fuel_result = await session.execute(
select(FuelEntry.odometer)
.where(FuelEntry.car_id == car_id)
.order_by(FuelEntry.odometer.desc())
.limit(1)
)
service_result = await session.execute(
select(ServiceEntry.odometer)
.where(ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None))
.order_by(ServiceEntry.odometer.desc())
.limit(1)
)
expense_result = await session.execute(
select(ExpenseEntry.odometer)
.where(ExpenseEntry.car_id == car_id, ExpenseEntry.odometer.is_not(None))
.order_by(ExpenseEntry.odometer.desc())
.limit(1)
)
values = [
value
for value in (
fuel_result.scalar_one_or_none(),
service_result.scalar_one_or_none(),
expense_result.scalar_one_or_none(),
)
if value is not None
]
car.current_odometer = max(values) if values else None
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
async def create_fuel_entry(
payload: FuelEntryCreate,
@@ -88,10 +60,24 @@ async def create_fuel_entry(
current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry:
car = await ensure_owned_car(session, payload.car_id, current_user)
entry = FuelEntry(**payload.model_dump())
validate_odometer_change(
car,
payload.odometer,
source_record_type="fuel",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
entry = FuelEntry(**payload.model_dump(exclude={"confirm_lower_odometer"}))
session.add(entry)
if car.current_odometer is None or payload.odometer > car.current_odometer:
car.current_odometer = payload.odometer
await session.flush()
await apply_odometer_from_record(
session,
car,
new_odometer=payload.odometer,
source_record_type="fuel",
source_record_id=entry.id,
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await session.commit()
await session.refresh(entry)
return entry
@@ -129,13 +115,21 @@ async def update_fuel_entry(
current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry:
entry = await ensure_entry_owner(session, await session.get(FuelEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
car = await session.get(Car, entry.car_id)
if car is not None and payload.odometer is not None:
validate_odometer_change(
car,
payload.odometer,
source_record_type="fuel",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
for field, value in payload.model_dump(exclude_unset=True, exclude={"confirm_lower_odometer"}).items():
setattr(entry, field, value)
if payload.total_cost is None and (
payload.liters is not None or payload.price_per_liter is not None
):
entry.total_cost = entry.liters * entry.price_per_liter
await refresh_current_odometer(session, entry.car_id)
await recalculate_current_odometer(session, entry.car_id, changed_by=current_user.id, source_record_type="fuel_update")
await session.commit()
await session.refresh(entry)
return entry
@@ -151,7 +145,7 @@ async def delete_fuel_entry(
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await recalculate_current_odometer(session, car_id, changed_by=current_user.id, source_record_type="fuel_delete")
await session.commit()
@@ -162,10 +156,24 @@ async def create_service_entry(
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry:
car = await ensure_owned_car(session, payload.car_id, current_user)
entry = ServiceEntry(**payload.model_dump())
validate_odometer_change(
car,
payload.odometer,
source_record_type="service",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
entry = ServiceEntry(**payload.model_dump(exclude={"confirm_lower_odometer"}))
session.add(entry)
if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer):
car.current_odometer = payload.odometer
await session.flush()
await apply_odometer_from_record(
session,
car,
new_odometer=payload.odometer,
source_record_type="service",
source_record_id=entry.id,
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await session.commit()
await session.refresh(entry)
return entry
@@ -203,9 +211,17 @@ async def update_service_entry(
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry:
entry = await ensure_entry_owner(session, await session.get(ServiceEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
car = await session.get(Car, entry.car_id)
if car is not None and payload.odometer is not None:
validate_odometer_change(
car,
payload.odometer,
source_record_type="service",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
for field, value in payload.model_dump(exclude_unset=True, exclude={"confirm_lower_odometer"}).items():
setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id)
await recalculate_current_odometer(session, entry.car_id, changed_by=current_user.id, source_record_type="service_update")
await session.commit()
await session.refresh(entry)
return entry
@@ -221,7 +237,7 @@ async def delete_service_entry(
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await recalculate_current_odometer(session, car_id, changed_by=current_user.id, source_record_type="service_delete")
await session.commit()
@@ -232,10 +248,24 @@ async def create_expense_entry(
current_user: User = Depends(get_current_telegram_user),
) -> ExpenseEntry:
car = await ensure_owned_car(session, payload.car_id, current_user)
entry = ExpenseEntry(**payload.model_dump())
validate_odometer_change(
car,
payload.odometer,
source_record_type="expense",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
entry = ExpenseEntry(**payload.model_dump(exclude={"confirm_lower_odometer"}))
session.add(entry)
if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer):
car.current_odometer = payload.odometer
await session.flush()
await apply_odometer_from_record(
session,
car,
new_odometer=payload.odometer,
source_record_type="expense",
source_record_id=entry.id,
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await session.commit()
await session.refresh(entry)
return entry
@@ -276,9 +306,17 @@ async def update_expense_entry(
current_user: User = Depends(get_current_telegram_user),
) -> ExpenseEntry:
entry = await ensure_entry_owner(session, await session.get(ExpenseEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
car = await session.get(Car, entry.car_id)
if car is not None and payload.odometer is not None:
validate_odometer_change(
car,
payload.odometer,
source_record_type="expense",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
for field, value in payload.model_dump(exclude_unset=True, exclude={"confirm_lower_odometer"}).items():
setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id)
await recalculate_current_odometer(session, entry.car_id, changed_by=current_user.id, source_record_type="expense_update")
await session.commit()
await session.refresh(entry)
return entry
@@ -294,10 +332,30 @@ async def delete_expense_entry(
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await recalculate_current_odometer(session, car_id, changed_by=current_user.id, source_record_type="expense_delete")
await session.commit()
@router.get("/cars/{car_id}/odometer-history", response_model=list[OdometerHistoryRead])
async def odometer_history(
car_id: int,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[OdometerHistory]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
result = await session.execute(
select(OdometerHistory)
.where(OdometerHistory.car_id == car_id)
.order_by(OdometerHistory.changed_at.desc(), OdometerHistory.id.desc())
.limit(limit)
.offset(max(offset, 0))
)
return list(result.scalars())
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
async def car_stats(
car_id: int,

View File

@@ -1,11 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit
from app.db.session import get_session
from app.models.car import Car, ServiceVisit, VehicleAccess
from app.models.car import (
Car,
CarServiceLink,
ServiceCenter,
ServiceVisit,
VehicleAccess,
VehicleDataChangeRequest,
)
from app.models.user import User
from app.schemas.service_center import (
VehicleAccessGrant,
@@ -15,6 +22,7 @@ from app.schemas.service_center import (
VehicleUpdate,
)
from app.schemas.user import UserRead
from app.services.odometer import add_odometer_history, validate_odometer_change
from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(tags=["my"])
@@ -53,8 +61,13 @@ async def my_vehicles(
) -> list[Car]:
result = await session.execute(
select(Car)
.join(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
.where(VehicleAccess.user_id == current_user.id, VehicleAccess.status == "active")
.outerjoin(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
.where(
or_(
Car.owner_id == current_user.id,
(VehicleAccess.user_id == current_user.id) & (VehicleAccess.status == "active"),
)
)
.order_by(Car.created_at.desc())
)
return list(result.scalars())
@@ -70,6 +83,15 @@ async def create_vehicle(
session.add(car)
await session.flush()
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
if car.current_odometer is not None:
add_odometer_history(
session,
car,
new_odometer=car.current_odometer,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
)
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
await session.commit()
await session.refresh(car)
@@ -88,8 +110,23 @@ async def update_vehicle(
raise HTTPException(status_code=404, detail="Vehicle not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
for field, value in vehicle_data(payload, partial=True).items():
raw = vehicle_data(payload, partial=True)
odometer_value = raw.pop("current_odometer", None) if "current_odometer" in raw else None
if odometer_value is not None:
validate_odometer_change(car, odometer_value, source_record_type="manual", confirm_lower_odometer=True)
for field, value in raw.items():
setattr(car, field, value)
if odometer_value is not None and odometer_value != car.current_odometer:
add_odometer_history(
session,
car,
new_odometer=odometer_value,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
confirmation_required=car.current_odometer is not None and odometer_value < car.current_odometer,
user_confirmed=True,
)
await log_audit(session, actor=current_user, action="vehicle.update", target_type="vehicle", target_id=car.id)
await session.commit()
await session.refresh(car)
@@ -116,6 +153,85 @@ async def vehicle_service_history(
return {"vehicle_id": vehicle_id, "service_visits": jsonable_encoder(visits)}
@router.get("/my/confirmations")
async def my_confirmations(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> dict:
owner_cars = select(Car.id).where(Car.owner_id == current_user.id)
visits = list(
(
await session.execute(
select(ServiceVisit)
.where(
ServiceVisit.vehicle_id.in_(owner_cars),
ServiceVisit.status == "pending_owner_confirmation",
)
.order_by(ServiceVisit.updated_at.desc(), ServiceVisit.id.desc())
)
).scalars()
)
change_requests = list(
(
await session.execute(
select(VehicleDataChangeRequest)
.where(
VehicleDataChangeRequest.owner_user_id == current_user.id,
VehicleDataChangeRequest.status == "pending",
)
.order_by(VehicleDataChangeRequest.created_at.desc())
)
).scalars()
)
links = list(
(
await session.execute(
select(CarServiceLink)
.where(
CarServiceLink.car_id.in_(owner_cars),
CarServiceLink.status == "pending",
CarServiceLink.is_active.is_(False),
)
.order_by(CarServiceLink.created_at.desc())
)
).scalars()
)
return {
"service_visits": jsonable_encoder(visits),
"change_requests": jsonable_encoder(change_requests),
"service_links": jsonable_encoder(links),
}
@router.get("/my/service-links")
async def my_service_links(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[dict]:
result = await session.execute(
select(CarServiceLink, Car, ServiceCenter)
.join(Car, Car.id == CarServiceLink.car_id)
.join(ServiceCenter, ServiceCenter.id == CarServiceLink.service_center_id)
.where(Car.owner_id == current_user.id)
.order_by(CarServiceLink.created_at.desc())
)
return [
{
"id": link.id,
"status": link.status,
"access_level": link.access_level,
"car_id": car.id,
"car_name": car.name,
"service_center_id": center.id,
"service_center_name": center.display_name or center.name,
"created_at": link.created_at,
"approved_at": link.approved_at,
"revoked_at": link.revoked_at,
}
for link, car, center in result.all()
]
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
async def grant_vehicle_access(
vehicle_id: int,

55
app/api/parser.py Normal file
View File

@@ -0,0 +1,55 @@
from decimal import Decimal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.deps import get_current_telegram_user
from app.models.user import User
from app.services.loans import generate_annuity_schedule, loan_summary
from app.services.record_parser import ParsedRecord, parse_record_text
router = APIRouter(tags=["parser"])
class ParseRecordRequest(BaseModel):
text: str = Field(min_length=1, max_length=4000)
class LoanCalculateRequest(BaseModel):
principal: Decimal = Field(gt=0)
term_months: int = Field(gt=0, le=600)
annual_interest_rate: Decimal = Field(ge=0)
@router.post("/parse/record", response_model=ParsedRecord)
async def parse_record(
payload: ParseRecordRequest,
current_user: User = Depends(get_current_telegram_user),
) -> ParsedRecord:
return parse_record_text(payload.text)
@router.post("/loans/calculate")
async def calculate_loan(
payload: LoanCalculateRequest,
current_user: User = Depends(get_current_telegram_user),
) -> dict:
summary = loan_summary(payload.principal, payload.term_months, payload.annual_interest_rate)
schedule = generate_annuity_schedule(
principal=payload.principal,
months=payload.term_months,
annual_rate=payload.annual_interest_rate,
)
return {
**summary,
"schedule": [
{
"number": row.number,
"payment": row.payment,
"principal": row.principal,
"interest": row.interest,
"remaining_principal": row.remaining_principal,
}
for row in schedule
],
}

View File

@@ -46,6 +46,7 @@ from app.schemas.service_center import (
VehicleSearchRequest,
VehicleSearchResult,
)
from app.services.odometer import validate_odometer_change
from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@@ -81,6 +82,18 @@ async def create_service_center(
)
session.add(center)
await session.flush()
session.add(
ServiceCenterVerification(
service_center_id=center.id,
submitted_documents=[
{"type": "registration", "urls": payload.document_photo_urls or []},
{"type": "facade", "url": payload.facade_photo_url},
{"type": "additional", "urls": payload.additional_photo_urls or []},
],
comment="Initial service center application",
status="pending",
)
)
employee = ServiceEmployee(
service_center_id=center.id,
user_id=current_user.id,
@@ -94,6 +107,44 @@ async def create_service_center(
return center
@router.patch("/{service_center_id}", response_model=ServiceCenterRead)
async def update_service_center_application(
service_center_id: int,
payload: ServiceCenterCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
data = payload.model_dump(exclude_unset=True)
for field, value in data.items():
if field == "display_name":
center.display_name = value
center.name = value
elif hasattr(center, field):
setattr(center, field, value)
if center.verification_status in {"draft", "needs_changes", "rejected"}:
center.verification_status = "pending"
session.add(
ServiceCenterVerification(
service_center_id=center.id,
submitted_documents=[
{"type": "registration", "urls": center.document_photo_urls or []},
{"type": "facade", "url": center.facade_photo_url},
{"type": "additional", "urls": center.additional_photo_urls or []},
],
comment="Resubmitted service center application",
status="pending",
)
)
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.get("/my", response_model=list[ServiceCenterRead])
async def my_service_centers(
session: AsyncSession = Depends(get_session),
@@ -287,6 +338,7 @@ async def create_visit(
vehicle = await session.get(Car, payload.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
validate_odometer_change(vehicle, payload.odometer, source_record_type="service_visit")
await ensure_service_center_approved(session, service_center_id)
await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user)
visit = ServiceVisit(

View File

@@ -14,6 +14,7 @@ from app.schemas.service_center import (
VehicleDataChangeRequestCreate,
VehicleDataChangeRequestRead,
)
from app.services.odometer import apply_odometer_from_record
from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(prefix="/service-visits", tags=["service-visits"])
@@ -83,8 +84,14 @@ async def confirm_visit(
raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "confirmed"
visit.owner_resolved_at = datetime.now(UTC)
if visit.odometer and (vehicle.current_odometer is None or visit.odometer > vehicle.current_odometer):
vehicle.current_odometer = visit.odometer
await apply_odometer_from_record(
session,
vehicle,
new_odometer=visit.odometer,
source_record_type="service_visit",
source_record_id=visit.id,
changed_by=current_user.id,
)
await log_audit(session, actor=current_user, action="service_visit.confirm", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(visit)

View File

@@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
"""Shared SQLAlchemy declarative metadata."""

View File

@@ -11,6 +11,7 @@ from app.api import (
gamification,
my,
ocr,
parser,
service_centers,
service_visits,
users,
@@ -37,6 +38,7 @@ app.include_router(cars.router, prefix="/api")
app.include_router(entries.router, prefix="/api")
app.include_router(gamification.router, prefix="/api")
app.include_router(ocr.router, prefix="/api")
app.include_router(parser.router, prefix="/api")
app.include_router(service_centers.router, prefix="/api")
app.include_router(service_visits.router, prefix="/api")
app.include_router(change_requests.router, prefix="/api")

View File

@@ -28,6 +28,8 @@ class Car(Base):
make: Mapped[str | None] = mapped_column(String(80))
model: Mapped[str | None] = mapped_column(String(80))
trim: Mapped[str | None] = mapped_column(String(120))
generation: Mapped[str | None] = mapped_column(String(120))
body_type: 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))
@@ -36,6 +38,9 @@ class Car(Base):
license_plate_country: Mapped[str | None] = mapped_column(String(2), index=True)
vin_normalized: Mapped[str | None] = mapped_column(String(17), unique=True, index=True)
fuel_type: Mapped[str | None] = mapped_column(String(32))
engine_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
transmission: Mapped[str | None] = mapped_column(String(40))
drive_type: Mapped[str | None] = mapped_column(String(40))
target_consumption_l_per_100km: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
engine_oil_type: Mapped[str | None] = mapped_column(String(80))
@@ -46,11 +51,28 @@ class Car(Base):
brake_fluid_type: Mapped[str | None] = mapped_column(String(80))
tire_pressure_front_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
tire_size: Mapped[str | None] = mapped_column(String(80))
oil_change_interval_km: Mapped[int | None] = mapped_column(Integer)
oil_change_interval_months: Mapped[int | None] = mapped_column(Integer)
purchase_date: Mapped[date | None] = mapped_column(Date)
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
purchase_currency: Mapped[str | None] = mapped_column(String(3))
purchase_type: Mapped[str] = mapped_column(String(24), default="unknown", server_default="unknown")
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
include_depreciation: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
expected_ownership_months: Mapped[int | None] = mapped_column(Integer)
expected_residual_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
loan_principal: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
loan_down_payment: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
loan_term_months: Mapped[int | None] = mapped_column(Integer)
loan_annual_interest_rate: Mapped[Decimal | None] = mapped_column(Numeric(6, 3))
loan_first_payment_date: Mapped[date | None] = mapped_column(Date)
loan_payment_day: Mapped[int | None] = mapped_column(Integer)
loan_payment_type: Mapped[str] = mapped_column(String(24), default="annuity", server_default="annuity")
loan_currency: Mapped[str | None] = mapped_column(String(3))
loan_comment: Mapped[str | None] = mapped_column(Text)
current_odometer: Mapped[int | None]
notes: Mapped[str | None] = mapped_column(Text)
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()
@@ -61,6 +83,7 @@ class Car(Base):
service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan")
expense_entries = relationship("ExpenseEntry", back_populates="car", cascade="all, delete-orphan")
service_links = relationship("CarServiceLink", back_populates="car", cascade="all, delete-orphan")
odometer_history = relationship("OdometerHistory", back_populates="car", cascade="all, delete-orphan")
class CarMake(Base):
@@ -163,6 +186,23 @@ class CarServiceLink(Base):
service_center = relationship("ServiceCenter", back_populates="car_links")
class OdometerHistory(Base):
__tablename__ = "odometer_history"
id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
previous_odometer: Mapped[int | None] = mapped_column(Integer)
new_odometer: Mapped[int] = mapped_column(Integer)
source_record_type: Mapped[str] = mapped_column(String(40), index=True)
source_record_id: Mapped[int | None] = mapped_column(Integer, index=True)
changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
changed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
confirmation_required: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
user_confirmed: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
car = relationship("Car", back_populates="odometer_history")
class ServiceInboxMessage(Base):
__tablename__ = "service_inbox_messages"

View File

@@ -2,7 +2,7 @@ import enum
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy import JSON, Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
@@ -54,7 +54,7 @@ class FuelEntry(Base):
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)
is_full_tank: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=None)
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -97,6 +97,11 @@ class ExpenseEntry(Base):
period_end: Mapped[date | None] = mapped_column(Date)
period_months: Mapped[int | None]
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
policy_number: Mapped[str | None] = mapped_column(String(120))
insurance_type: Mapped[str | None] = mapped_column(String(40))
payment_period_months: Mapped[int | None] = mapped_column()
document_urls: Mapped[list | None] = mapped_column(JSON)
metadata_json: Mapped[dict | None] = mapped_column(JSON)
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,7 +1,9 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from app.services.vehicle_identity import validate_vin
class CarBase(BaseModel):
@@ -9,10 +11,15 @@ class CarBase(BaseModel):
make: str | None = None
model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None
plate_number: str | None = None
vin: str | None = None
fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
target_consumption_l_per_100km: Decimal | None = None
fuel_tank_volume_l: Decimal | None = None
engine_oil_type: str | None = None
@@ -23,11 +30,33 @@ class CarBase(BaseModel):
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str = "unknown"
currency: str = "RUB"
include_depreciation: bool = False
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str = "annuity"
loan_currency: str | None = None
loan_comment: str | None = None
current_odometer: int | None = None
notes: str | None = None
@field_validator("vin")
@classmethod
def validate_vin_field(cls, value: str | None) -> str | None:
return validate_vin(value)
class CarCreate(CarBase):
@@ -39,10 +68,15 @@ class CarUpdate(BaseModel):
make: str | None = None
model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None
plate_number: str | None = None
vin: str | None = None
fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
target_consumption_l_per_100km: Decimal | None = None
fuel_tank_volume_l: Decimal | None = None
engine_oil_type: str | None = None
@@ -53,11 +87,33 @@ class CarUpdate(BaseModel):
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str | None = None
currency: str | None = None
include_depreciation: bool | None = None
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str | None = None
loan_currency: str | None = None
loan_comment: str | None = None
current_odometer: int | None = None
notes: str | None = None
@field_validator("vin")
@classmethod
def validate_vin_field(cls, value: str | None) -> str | None:
return validate_vin(value)
class CarRead(CarBase):

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.models.expense import ExpenseCategory, ServiceType
@@ -14,18 +14,27 @@ class FuelEntryBase(BaseModel):
total_cost: Decimal | None = None
station: str | None = None
fuel_brand: str | None = None
is_full_tank: bool = True
is_full_tank: bool | None = None
notes: str | None = None
@model_validator(mode="after")
def fill_total_cost(self) -> "FuelEntryBase":
if self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.liters <= 0:
raise ValueError("liters must be positive")
if self.price_per_liter <= 0:
raise ValueError("price_per_liter must be positive")
if self.total_cost is None:
self.total_cost = self.liters * self.price_per_liter
if self.total_cost <= 0:
raise ValueError("total_cost must be positive")
return self
class FuelEntryCreate(FuelEntryBase):
car_id: int
confirm_lower_odometer: bool = False
class FuelEntryUpdate(BaseModel):
@@ -38,6 +47,7 @@ class FuelEntryUpdate(BaseModel):
fuel_brand: str | None = None
is_full_tank: bool | None = None
notes: str | None = None
confirm_lower_odometer: bool = False
class FuelEntryRead(FuelEntryBase):
@@ -61,9 +71,20 @@ class ServiceEntryBase(BaseModel):
next_due_odometer: int | None = None
notes: str | None = None
@model_validator(mode="after")
def validate_service(self) -> "ServiceEntryBase":
if self.odometer is not None and self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.total_cost < 0:
raise ValueError("total_cost must be non-negative")
if not self.title.strip():
raise ValueError("title is required")
return self
class ServiceEntryCreate(ServiceEntryBase):
car_id: int
confirm_lower_odometer: bool = False
class ServiceEntryUpdate(BaseModel):
@@ -77,6 +98,7 @@ class ServiceEntryUpdate(BaseModel):
next_due_date: date | None = None
next_due_odometer: int | None = None
notes: str | None = None
confirm_lower_odometer: bool = False
class ServiceEntryRead(ServiceEntryBase):
@@ -99,19 +121,36 @@ class ExpenseEntryBase(BaseModel):
period_end: date | None = None
period_months: int | None = None
is_recurring: bool = False
policy_number: str | None = None
insurance_type: str | None = None
payment_period_months: int | None = None
document_urls: list[str] | None = None
metadata_json: dict | None = None
notes: str | None = None
@model_validator(mode="after")
def validate_period(self) -> "ExpenseEntryBase":
if self.total_cost <= 0:
raise ValueError("total_cost must be positive")
if self.odometer is not None and self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.period_months is not None and self.period_months < 1:
raise ValueError("period_months must be positive")
if self.payment_period_months is not None and self.payment_period_months < 1:
raise ValueError("payment_period_months must be positive")
if self.period_start and self.period_end and self.period_end < self.period_start:
raise ValueError("period_end must be after period_start")
if self.category == ExpenseCategory.insurance:
if self.period_start and self.period_end:
return self
if self.period_months or self.payment_period_months:
return self
return self
class ExpenseEntryCreate(ExpenseEntryBase):
car_id: int
confirm_lower_odometer: bool = False
class ExpenseEntryUpdate(BaseModel):
@@ -126,7 +165,20 @@ class ExpenseEntryUpdate(BaseModel):
period_end: date | None = None
period_months: int | None = None
is_recurring: bool | None = None
policy_number: str | None = None
insurance_type: str | None = None
payment_period_months: int | None = None
document_urls: list[str] | None = None
metadata_json: dict | None = None
notes: str | None = None
confirm_lower_odometer: bool = False
@field_validator("total_cost")
@classmethod
def validate_total_cost(cls, value: Decimal | None) -> Decimal | None:
if value is not None and value <= 0:
raise ValueError("total_cost must be positive")
return value
class ExpenseEntryRead(ExpenseEntryBase):
@@ -151,11 +203,23 @@ class OwnershipStats(BaseModel):
service_cost: Decimal
total_cost: Decimal
expenses_cost: Decimal = Decimal("0")
repair_cost: Decimal = Decimal("0")
fixed_costs: Decimal = Decimal("0")
variable_costs: Decimal = Decimal("0")
recurring_costs: Decimal = Decimal("0")
one_time_costs: Decimal = Decimal("0")
forecast_next_month: Decimal = Decimal("0")
depreciation_cost: Decimal = Decimal("0")
loan_principal_cost: Decimal = Decimal("0")
loan_interest_cost: Decimal = Decimal("0")
total_cost_without_credit: Decimal = Decimal("0")
total_cost_with_credit: Decimal = Decimal("0")
cost_per_day: Decimal = Decimal("0")
cost_per_month: Decimal = Decimal("0")
current_month_cost: Decimal = Decimal("0")
previous_month_cost: Decimal = Decimal("0")
month_over_month_change_pct: float | None = None
cost_warning: str | None = None
cost_by_category: dict[str, Decimal] = Field(default_factory=dict)
categories: list[OwnershipCategoryBreakdown] = Field(default_factory=list)
liters: Decimal
@@ -179,5 +243,25 @@ class OdometerPrediction(BaseModel):
avg_price_per_liter: float | None = None
price_samples: int = 0
price_confidence: float = 0
average_full_tank_distance: float | None = None
average_fuel_consumption_full_tank: float | None = None
average_cost_per_full_tank: float | None = None
last_full_tank_distance: int | None = None
full_tank_warning: str | None = None
confidence: float
insight: str
class OdometerHistoryRead(BaseModel):
id: int
car_id: int
previous_odometer: int | None = None
new_odometer: int
source_record_type: str
source_record_id: int | None = None
changed_at: datetime
changed_by: int | None = None
confirmation_required: bool
user_confirmed: bool
model_config = ConfigDict(from_attributes=True)

View File

@@ -114,20 +114,49 @@ class VehicleCreate(BaseModel):
name: str
make: str | None = None
model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None
license_plate: str | None = None
license_plate_country: str | None = None
vin: str | None = None
current_odometer: int | None = None
fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str = "unknown"
currency: str = "RUB"
include_depreciation: bool = False
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str = "annuity"
loan_currency: str | None = None
loan_comment: str | None = None
notes: str | None = None
@field_validator("vin")
@classmethod
@@ -139,20 +168,49 @@ class VehicleUpdate(BaseModel):
name: str | None = None
make: str | None = None
model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None
license_plate: str | None = None
license_plate_country: str | None = None
vin: str | None = None
current_odometer: int | None = None
fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str | None = None
currency: str | None = None
include_depreciation: bool | None = None
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str | None = None
loan_currency: str | None = None
loan_comment: str | None = None
notes: str | None = None
@field_validator("vin")
@classmethod
@@ -166,20 +224,49 @@ class VehicleRead(BaseModel):
name: str
make: str | None = None
model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None
license_plate_display: str | None = None
license_plate_country: str | None = None
vin_normalized: str | None = None
current_odometer: int | None = None
fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str = "unknown"
currency: str = "RUB"
include_depreciation: bool = False
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str = "annuity"
loan_currency: str | None = None
loan_comment: str | None = None
notes: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@@ -358,4 +445,9 @@ class ServiceInboxRead(ServiceInboxCreate):
error: str | None = None
created_at: datetime
class AdminModerationDecision(BaseModel):
comment: str | None = None
reason: str | None = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -3,12 +3,38 @@ from datetime import date, timedelta
from decimal import Decimal
import pandas as pd
from sqlalchemy import Select, func, or_, select
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.car import Car
from app.models.expense import ExpenseCategory, ExpenseEntry, FuelEntry, ServiceEntry
from app.schemas.expense import OdometerPrediction, OwnershipStats
from app.services.loans import generate_annuity_schedule
FIXED_EXPENSE_CATEGORIES = {
ExpenseCategory.insurance,
ExpenseCategory.tax,
ExpenseCategory.loan_payment,
ExpenseCategory.loan_interest,
ExpenseCategory.parking,
}
VARIABLE_EXPENSE_CATEGORIES = {
ExpenseCategory.fine,
ExpenseCategory.car_wash,
ExpenseCategory.toll,
ExpenseCategory.tires,
ExpenseCategory.wheels,
ExpenseCategory.battery,
ExpenseCategory.parts,
ExpenseCategory.repair,
ExpenseCategory.maintenance,
ExpenseCategory.diagnostics,
ExpenseCategory.towing,
ExpenseCategory.state_fee,
ExpenseCategory.registration,
ExpenseCategory.inspection,
ExpenseCategory.other,
}
async def get_ownership_stats(
@@ -60,21 +86,39 @@ async def get_ownership_stats(
odometer_values = [value for value in odometer_values if value is not None]
distance_km = int(max(odometer_values) - min(odometer_values)) if len(odometer_values) >= 2 else 0
expense_cost, recurring_cost, _expense_count, expense_categories = await expense_period_totals(
(
expense_cost,
recurring_cost,
_expense_count,
expense_categories,
fixed_expense_cost,
variable_expense_cost,
) = await expense_period_totals(
session, car_id, date_from, date_to
)
car = await session.get(Car, car_id)
depreciation_cost = calculate_depreciation(car, date_from, date_to) if car else Decimal("0")
loan_principal_cost, loan_interest_cost = calculate_loan_costs(car, date_from, date_to) if car else (Decimal("0"), Decimal("0"))
total_cost = Decimal(fuel_cost) + Decimal(service_cost) + expense_cost + depreciation_cost
avg_consumption = await full_tank_consumption(session, car_id, date_from, date_to)
total_cost = Decimal(fuel_cost) + Decimal(service_cost) + expense_cost + depreciation_cost + loan_principal_cost + loan_interest_cost
tank_metrics = await full_tank_metrics(session, car_id, date_from, date_to)
avg_consumption = tank_metrics["average_fuel_consumption_full_tank"]
cost_per_km = float(total_cost / distance_km) if distance_km else None
months = max(Decimal(period_days(date_from, date_to)) / Decimal("30.4375"), Decimal("0.033"))
cost_per_day = (total_cost / Decimal(period_days(date_from, date_to))).quantize(Decimal("0.01"))
cost_per_month = (total_cost / months).quantize(Decimal("0.01"))
recurring_total = (recurring_cost + depreciation_cost).quantize(Decimal("0.01"))
recurring_total = (recurring_cost + depreciation_cost + loan_principal_cost + loan_interest_cost).quantize(Decimal("0.01"))
one_time_costs = max(total_cost - recurring_total, Decimal("0")).quantize(Decimal("0.01"))
recurring_monthly = (recurring_total / months).quantize(Decimal("0.01"))
forecast_next_month = max(cost_per_month, recurring_monthly).quantize(Decimal("0.01"))
repair_cost = (
Decimal(service_cost)
+ expense_categories.get("repair", Decimal("0"))
+ expense_categories.get("maintenance", Decimal("0"))
+ expense_categories.get("diagnostics", Decimal("0"))
).quantize(Decimal("0.01"))
fixed_costs = (fixed_expense_cost + depreciation_cost + loan_principal_cost + loan_interest_cost).quantize(Decimal("0.01"))
variable_costs = (Decimal(fuel_cost) + Decimal(service_cost) + variable_expense_cost).quantize(Decimal("0.01"))
cost_by_category = {
"fuel": Decimal(fuel_cost),
@@ -83,11 +127,22 @@ async def get_ownership_stats(
}
if depreciation_cost:
cost_by_category["depreciation"] = depreciation_cost
if loan_principal_cost:
cost_by_category["loan_payment"] = cost_by_category.get("loan_payment", Decimal("0")) + loan_principal_cost
if loan_interest_cost:
cost_by_category["loan_interest"] = cost_by_category.get("loan_interest", Decimal("0")) + loan_interest_cost
categories = [
{"category": key, "total_cost": value, "entries_count": 0}
for key, value in sorted(cost_by_category.items())
if value
]
current_month_cost, previous_month_cost = await month_comparison_totals(session, car_id, date_to)
month_change = None
cost_warning = None
if previous_month_cost > 0:
month_change = float((current_month_cost - previous_month_cost) * Decimal("100") / previous_month_cost)
if month_change >= 35:
cost_warning = "Расходы заметно выше прошлого месяца. Проверьте крупные ремонты, штрафы или регулярные платежи."
return OwnershipStats(
car_id=car_id,
@@ -97,11 +152,23 @@ async def get_ownership_stats(
service_cost=service_cost,
expenses_cost=expense_cost,
total_cost=total_cost,
repair_cost=repair_cost,
fixed_costs=fixed_costs,
variable_costs=variable_costs,
recurring_costs=recurring_total,
one_time_costs=one_time_costs,
forecast_next_month=forecast_next_month,
depreciation_cost=depreciation_cost,
loan_principal_cost=loan_principal_cost,
loan_interest_cost=loan_interest_cost,
total_cost_without_credit=(total_cost - loan_principal_cost - loan_interest_cost).quantize(Decimal("0.01")),
total_cost_with_credit=total_cost.quantize(Decimal("0.01")),
cost_per_day=cost_per_day,
cost_per_month=cost_per_month,
current_month_cost=current_month_cost,
previous_month_cost=previous_month_cost,
month_over_month_change_pct=round(month_change, 2) if month_change is not None else None,
cost_warning=cost_warning,
cost_by_category=cost_by_category,
categories=categories,
liters=liters,
@@ -144,6 +211,9 @@ def expense_window(entry: ExpenseEntry) -> tuple[date, date]:
def allocated_expense_cost(entry: ExpenseEntry, date_from: date, date_to: date) -> Decimal:
monthly_period = entry.payment_period_months or entry.period_months or inferred_monthly_period(entry)
if monthly_period and (entry.period_start or entry.entry_date):
return allocated_monthly_expense_cost(entry, date_from, date_to, monthly_period)
start, end = expense_window(entry)
total_days = period_days(start, end)
matched_days = overlap_days(start, end, date_from, date_to)
@@ -154,24 +224,49 @@ def allocated_expense_cost(entry: ExpenseEntry, date_from: date, date_to: date)
return (Decimal(entry.total_cost) * Decimal(matched_days) / Decimal(total_days)).quantize(Decimal("0.01"))
def inferred_monthly_period(entry: ExpenseEntry) -> int | None:
if entry.category != ExpenseCategory.insurance or not entry.period_start or not entry.period_end:
return None
for months in (1, 3, 6, 12):
if add_months(entry.period_start, months) - timedelta(days=1) == entry.period_end:
return months
return None
def allocated_monthly_expense_cost(
entry: ExpenseEntry, date_from: date, date_to: date, months: int
) -> Decimal:
start = entry.period_start or entry.entry_date
if months <= 0:
return Decimal("0")
monthly_cost = Decimal(entry.total_cost) / Decimal(months)
total = Decimal("0")
for month_index in range(months):
month_start = add_months(start, month_index)
month_end = add_months(start, month_index + 1) - timedelta(days=1)
matched = overlap_days(month_start, month_end, date_from, date_to)
if matched <= 0:
continue
total_days = period_days(month_start, month_end)
total += monthly_cost * Decimal(matched) / Decimal(total_days)
return total.quantize(Decimal("0.01"))
async def expense_period_totals(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> tuple[Decimal, Decimal, int, dict[str, Decimal]]:
) -> tuple[Decimal, Decimal, int, dict[str, Decimal], Decimal, Decimal]:
result = await session.execute(
select(ExpenseEntry)
.where(
ExpenseEntry.car_id == car_id,
or_(
ExpenseEntry.entry_date.between(date_from, date_to),
ExpenseEntry.period_start.between(date_from, date_to),
ExpenseEntry.period_end.between(date_from, date_to),
(ExpenseEntry.period_start <= date_from) & (ExpenseEntry.period_end >= date_to),
),
ExpenseEntry.entry_date <= date_to,
)
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
)
total = Decimal("0")
recurring = Decimal("0")
fixed = Decimal("0")
variable = Decimal("0")
categories: dict[str, Decimal] = {}
count = 0
for entry in result.scalars():
@@ -182,26 +277,104 @@ async def expense_period_totals(
total += amount
category = entry.category.value if isinstance(entry.category, ExpenseCategory) else str(entry.category)
categories[category] = categories.get(category, Decimal("0")) + amount
if entry.is_recurring or entry.category in {ExpenseCategory.insurance, ExpenseCategory.loan_payment, ExpenseCategory.loan_interest}:
if entry.is_recurring or entry.category in FIXED_EXPENSE_CATEGORIES:
recurring += amount
return total.quantize(Decimal("0.01")), recurring.quantize(Decimal("0.01")), count, categories
if entry.category in FIXED_EXPENSE_CATEGORIES or entry.is_recurring:
fixed += amount
else:
variable += amount
return (
total.quantize(Decimal("0.01")),
recurring.quantize(Decimal("0.01")),
count,
categories,
fixed.quantize(Decimal("0.01")),
variable.quantize(Decimal("0.01")),
)
def calculate_depreciation(car: Car, date_from: date, date_to: date) -> Decimal:
if not car.include_depreciation or not car.purchase_price or not car.purchase_date:
return Decimal("0")
depreciation_start = car.purchase_date
depreciation_end = add_months(car.purchase_date, 60) - timedelta(days=1)
months = car.expected_ownership_months or 60
residual = Decimal(car.expected_residual_value or 0)
depreciable = max(Decimal(car.purchase_price) - residual, Decimal("0"))
depreciation_end = add_months(car.purchase_date, months) - timedelta(days=1)
matched_days = overlap_days(depreciation_start, depreciation_end, date_from, date_to)
if matched_days <= 0:
return Decimal("0")
daily_cost = Decimal(car.purchase_price) / Decimal(period_days(depreciation_start, depreciation_end))
daily_cost = depreciable / Decimal(period_days(depreciation_start, depreciation_end))
return (daily_cost * Decimal(matched_days)).quantize(Decimal("0.01"))
def calculate_loan_costs(car: Car, date_from: date, date_to: date) -> tuple[Decimal, Decimal]:
if not car.loan_principal or not car.loan_term_months:
return Decimal("0"), Decimal("0")
first_payment = car.loan_first_payment_date or car.purchase_date
if not first_payment:
return Decimal("0"), Decimal("0")
annual_rate = Decimal(car.loan_annual_interest_rate or 0)
schedule = generate_annuity_schedule(
principal=Decimal(car.loan_principal),
months=car.loan_term_months,
annual_rate=annual_rate,
first_payment_date=first_payment,
)
principal = Decimal("0")
interest = Decimal("0")
for row in schedule:
if row.payment_date and date_from <= row.payment_date <= date_to:
principal += row.principal
interest += row.interest
return principal.quantize(Decimal("0.01")), interest.quantize(Decimal("0.01"))
async def raw_period_total(session: AsyncSession, car_id: int, date_from: date, date_to: date) -> Decimal:
fuel = (
await session.execute(
select(func.coalesce(func.sum(FuelEntry.total_cost), 0)).where(
FuelEntry.car_id == car_id,
FuelEntry.entry_date >= date_from,
FuelEntry.entry_date <= date_to,
)
)
).scalar_one()
service = (
await session.execute(
select(func.coalesce(func.sum(ServiceEntry.total_cost), 0)).where(
ServiceEntry.car_id == car_id,
ServiceEntry.entry_date >= date_from,
ServiceEntry.entry_date <= date_to,
)
)
).scalar_one()
expenses, _, _, _, _, _ = await expense_period_totals(session, car_id, date_from, date_to)
car = await session.get(Car, car_id)
depreciation = calculate_depreciation(car, date_from, date_to) if car else Decimal("0")
loan_principal, loan_interest = calculate_loan_costs(car, date_from, date_to) if car else (Decimal("0"), Decimal("0"))
return (Decimal(fuel) + Decimal(service) + expenses + depreciation + loan_principal + loan_interest).quantize(Decimal("0.01"))
async def month_comparison_totals(session: AsyncSession, car_id: int, today: date) -> tuple[Decimal, Decimal]:
current_from = today.replace(day=1)
previous_to = current_from - timedelta(days=1)
previous_from = previous_to.replace(day=1)
return (
await raw_period_total(session, car_id, current_from, today),
await raw_period_total(session, car_id, previous_from, previous_to),
)
async def full_tank_consumption(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> float | None:
return (await full_tank_metrics(session, car_id, date_from, date_to))["average_fuel_consumption_full_tank"]
async def full_tank_metrics(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> dict[str, float | int | str | None]:
result = await session.execute(
select(FuelEntry)
.where(
@@ -213,10 +386,15 @@ async def full_tank_consumption(
entries = list(result.scalars())
full_indexes = [index for index, entry in enumerate(entries) if entry.is_full_tank]
if len(full_indexes) < 2:
return None
return {
"average_full_tank_distance": None,
"average_fuel_consumption_full_tank": None,
"average_cost_per_full_tank": None,
"last_full_tank_distance": None,
"full_tank_warning": None,
}
total_liters = Decimal("0")
total_distance = 0
intervals: list[dict] = []
previous_full_index = full_indexes[0]
for current_full_index in full_indexes[1:]:
previous = entries[previous_full_index]
@@ -232,13 +410,45 @@ async def full_tank_consumption(
Decimal(entry.liters) for entry in entries[previous_full_index + 1 : current_full_index + 1]
)
if interval_liters > 0:
total_liters += interval_liters
total_distance += distance
interval_cost = sum(
Decimal(entry.total_cost) for entry in entries[previous_full_index + 1 : current_full_index + 1]
)
intervals.append({"distance": distance, "liters": interval_liters, "cost": interval_cost})
previous_full_index = current_full_index
if total_distance <= 0 or total_liters <= 0:
return None
return float(total_liters * Decimal(100) / Decimal(total_distance))
if not intervals:
return {
"average_full_tank_distance": None,
"average_fuel_consumption_full_tank": None,
"average_cost_per_full_tank": None,
"last_full_tank_distance": None,
"full_tank_warning": None,
}
total_distance = sum(item["distance"] for item in intervals)
total_liters = sum((item["liters"] for item in intervals), Decimal("0"))
total_cost = sum((item["cost"] for item in intervals), Decimal("0"))
avg_distance = float(Decimal(total_distance) / Decimal(len(intervals)))
avg_consumption = float(total_liters * Decimal(100) / Decimal(total_distance))
avg_cost = float(total_cost / Decimal(len(intervals)))
last_distance = int(intervals[-1]["distance"])
warning = None
previous = intervals[:-1]
if previous:
previous_avg = float(Decimal(sum(item["distance"] for item in previous)) / Decimal(len(previous)))
if previous_avg > 0 and last_distance < previous_avg * 0.75:
drop = round((1 - last_distance / previous_avg) * 100)
warning = (
f"Обычно на полном баке получается около {previous_avg:.0f} км. "
f"Последний интервал {last_distance} км, это на {drop}% меньше. "
"Проверьте режим поездок, давление шин, качество топлива или техническое состояние."
)
return {
"average_full_tank_distance": round(avg_distance, 1),
"average_fuel_consumption_full_tank": round(avg_consumption, 2),
"average_cost_per_full_tank": round(avg_cost, 2),
"last_full_tank_distance": last_distance,
"full_tank_warning": warning,
}
async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame:
@@ -249,6 +459,7 @@ async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFr
async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction:
price_prediction = await predict_fuel_price(session, car_id)
tank_prediction = await full_tank_metrics(session, car_id, date.min, date.today())
fuel = await dataframe_from_query(
session,
select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where(
@@ -271,6 +482,7 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
avg_km_per_day=None,
avg_km_per_month=None,
**price_prediction,
**tank_prediction,
confidence=0,
insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.",
)
@@ -291,6 +503,7 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
avg_km_per_day=None,
avg_km_per_month=None,
**price_prediction,
**tank_prediction,
confidence=0.2,
insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.",
)
@@ -337,6 +550,7 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
avg_km_per_day=round(km_per_day, 1),
avg_km_per_month=round(km_per_day * 30.4, 1),
**price_prediction,
**tank_prediction,
confidence=round(confidence, 2),
insight=insight,
)

94
app/services/loans.py Normal file
View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
MONEY = Decimal("0.01")
@dataclass(frozen=True)
class LoanPayment:
number: int
payment_date: date | None
payment: Decimal
principal: Decimal
interest: Decimal
remaining_principal: Decimal
def quantize_money(value: Decimal) -> Decimal:
return value.quantize(MONEY, rounding=ROUND_HALF_UP)
def annuity_payment(principal: Decimal, months: int, annual_rate: Decimal) -> Decimal:
if principal <= 0:
raise ValueError("principal must be positive")
if months <= 0:
raise ValueError("months must be positive")
if annual_rate < 0:
raise ValueError("annual_rate must be non-negative")
if annual_rate == 0:
return quantize_money(principal / Decimal(months))
monthly_rate = annual_rate / Decimal("12") / Decimal("100")
factor = (Decimal("1") + monthly_rate) ** months
payment = principal * monthly_rate * factor / (factor - Decimal("1"))
return quantize_money(payment)
def loan_summary(principal: Decimal, months: int, annual_rate: Decimal) -> dict:
payment = annuity_payment(principal, months, annual_rate)
total_payment = quantize_money(payment * Decimal(months))
total_interest = max(total_payment - principal, Decimal("0")).quantize(MONEY)
return {
"monthly_payment": payment,
"total_payment": total_payment,
"overpayment": total_interest,
"total_interest": total_interest,
"principal": principal,
"months": months,
"annual_rate": annual_rate,
}
def generate_annuity_schedule(
*,
principal: Decimal,
months: int,
annual_rate: Decimal,
first_payment_date: date | None = None,
) -> list[LoanPayment]:
payment = annuity_payment(principal, months, annual_rate)
monthly_rate = annual_rate / Decimal("12") / Decimal("100")
remaining = principal
rows: list[LoanPayment] = []
for number in range(1, months + 1):
interest = quantize_money(remaining * monthly_rate) if annual_rate else Decimal("0.00")
principal_part = payment - interest
if number == months or principal_part > remaining:
principal_part = remaining
payment_for_row = principal_part + interest
else:
payment_for_row = payment
remaining = max(remaining - principal_part, Decimal("0"))
rows.append(
LoanPayment(
number=number,
payment_date=None if first_payment_date is None else add_months(first_payment_date, number - 1),
payment=quantize_money(payment_for_row),
principal=quantize_money(principal_part),
interest=quantize_money(interest),
remaining_principal=quantize_money(remaining),
)
)
return rows
def add_months(value: date, months: int) -> date:
import calendar
month = value.month - 1 + months
year = value.year + month // 12
month = month % 12 + 1
day = min(value.day, calendar.monthrange(year, month)[1])
return date(year, month, day)

157
app/services/odometer.py Normal file
View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.car import Car, OdometerHistory
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
def validate_odometer_change(
car: Car,
new_odometer: int | None,
*,
source_record_type: str,
confirm_lower_odometer: bool = False,
) -> None:
if new_odometer is None:
return
if new_odometer < 0:
raise HTTPException(status_code=422, detail="Odometer must be non-negative")
current = car.current_odometer
if current is not None and new_odometer < current and not confirm_lower_odometer:
raise HTTPException(
status_code=409,
detail={
"code": "odometer_lower_than_current",
"message": "Новый пробег меньше текущего. Подтвердите ручную корректировку или проверьте запись.",
"current_odometer": current,
"new_odometer": new_odometer,
"source": source_record_type,
},
)
if current is not None and new_odometer > current + 100000 and not confirm_lower_odometer:
raise HTTPException(
status_code=409,
detail={
"code": "odometer_jump_requires_confirmation",
"message": "Пробег сильно отличается от текущего. Проверьте число перед сохранением.",
"current_odometer": current,
"new_odometer": new_odometer,
"source": source_record_type,
},
)
def add_odometer_history(
session: AsyncSession,
car: Car,
*,
new_odometer: int,
source_record_type: str,
source_record_id: int | None,
changed_by: int | None,
confirmation_required: bool = False,
user_confirmed: bool = True,
) -> None:
previous = car.current_odometer
session.add(
OdometerHistory(
car_id=car.id,
previous_odometer=previous,
new_odometer=new_odometer,
source_record_type=source_record_type,
source_record_id=source_record_id,
changed_by=changed_by,
confirmation_required=confirmation_required,
user_confirmed=user_confirmed,
)
)
car.current_odometer = new_odometer
async def apply_odometer_from_record(
session: AsyncSession,
car: Car,
*,
new_odometer: int | None,
source_record_type: str,
source_record_id: int | None,
changed_by: int | None,
confirm_lower_odometer: bool = False,
) -> None:
if new_odometer is None:
return
validate_odometer_change(
car,
new_odometer,
source_record_type=source_record_type,
confirm_lower_odometer=confirm_lower_odometer,
)
current = car.current_odometer
if current is None or new_odometer > current or confirm_lower_odometer:
add_odometer_history(
session,
car,
new_odometer=new_odometer,
source_record_type=source_record_type,
source_record_id=source_record_id,
changed_by=changed_by,
confirmation_required=current is not None and new_odometer < current,
user_confirmed=True,
)
async def recalculate_current_odometer(
session: AsyncSession,
car_id: int,
*,
changed_by: int | None = None,
source_record_type: str = "recalculate",
) -> None:
car = await session.get(Car, car_id)
if car is None:
return
fuel_result = await session.execute(
select(FuelEntry.odometer)
.where(FuelEntry.car_id == car_id)
.order_by(FuelEntry.odometer.desc())
.limit(1)
)
service_result = await session.execute(
select(ServiceEntry.odometer)
.where(ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None))
.order_by(ServiceEntry.odometer.desc())
.limit(1)
)
expense_result = await session.execute(
select(ExpenseEntry.odometer)
.where(ExpenseEntry.car_id == car_id, ExpenseEntry.odometer.is_not(None))
.order_by(ExpenseEntry.odometer.desc())
.limit(1)
)
values = [
value
for value in (
fuel_result.scalar_one_or_none(),
service_result.scalar_one_or_none(),
expense_result.scalar_one_or_none(),
)
if value is not None
]
new_value = max(values) if values else None
if new_value != car.current_odometer:
if new_value is None:
car.current_odometer = None
return
add_odometer_history(
session,
car,
new_odometer=new_value,
source_record_type=source_record_type,
source_record_id=None,
changed_by=changed_by,
confirmation_required=False,
user_confirmed=True,
)

View File

@@ -0,0 +1,193 @@
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, Field
from app.services.vehicle_identity import normalize_license_plate, validate_vin
FULL_TANK_RE = re.compile(r"(до\s+полного|полный\s+бак|залил\s+полный|full\s+tank)", re.I)
NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
class ParsedRecord(BaseModel):
event_type: str
confidence: float = Field(ge=0, le=1)
missing_fields: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
data: dict[str, Any] = Field(default_factory=dict)
def decimal_from_match(value: str | None) -> Decimal | None:
if not value:
return None
return Decimal(value.replace(",", "."))
def parse_record_text(text: str) -> ParsedRecord:
source = " ".join(text.strip().split())
lower = source.lower()
if not source:
return ParsedRecord(event_type="unknown", confidence=0, missing_fields=["text"])
vin = extract_vin(source)
plate = extract_license_plate(source)
if any(word in lower for word in ("купил", "покупка", "кредит", "loan", "lease")):
return parse_purchase(source, vin, plate)
if any(word in lower for word in ("заправ", "литр", "л ", "full tank", "бак")):
return parse_fuel(source, vin, plate)
if any(word in lower for word in ("страхов", "полис", "osago", "каско")):
return parse_expense(source, "insurance", vin, plate)
if any(word in lower for word in ("штраф", "fine")):
return parse_expense(source, "fine", vin, plate)
if any(word in lower for word in ("налог", "tax")):
return parse_expense(source, "tax", vin, plate)
if any(word in lower for word in ("то", "сервис", "ремонт", "масл", "diagnostics", "repair")):
return parse_service(source, vin, plate)
return ParsedRecord(
event_type="unknown",
confidence=0.2,
warnings=["Не удалось надежно определить тип записи. Откройте ручной ввод."],
data=identity_payload(vin, plate),
)
def parse_fuel(source: str, vin: str | None, plate: str | None) -> ParsedRecord:
liters = find_decimal(r"(\d+(?:[.,]\d+)?)\s*(?:л|литр|liter|l)\b", source)
amount = find_decimal(r"(?:на|сумма|total|amount)\s*(\d+(?:[.,]\d+)?)", source)
if amount is None:
amount = largest_money_like_number(source, exclude={liters})
odometer = find_int(r"(?:пробег|одометр|odo|km|км)\s*(\d{2,7})", source)
price_per_liter = None
if liters and amount:
price_per_liter = (amount / liters).quantize(Decimal("0.01"))
missing = []
if liters is None:
missing.append("fuel_liters")
if amount is None:
missing.append("amount")
if odometer is None:
missing.append("odometer_km")
return ParsedRecord(
event_type="fuel",
confidence=0.9 if not missing else 0.55,
missing_fields=missing,
data={
**identity_payload(vin, plate),
"is_full_tank": bool(FULL_TANK_RE.search(source)),
"fuel_liters": float(liters) if liters is not None else None,
"amount": float(amount) if amount is not None else None,
"price_per_liter": float(price_per_liter) if price_per_liter is not None else None,
"odometer_km": odometer,
},
)
def parse_purchase(source: str, vin: str | None, plate: str | None) -> ParsedRecord:
purchase_price = find_decimal(r"(?:за|стоимость|цена)\s*(\d+(?:[.,]\d+)?)", source)
loan_principal = find_decimal(r"(?:кредит|loan)\s*(\d+(?:[.,]\d+)?)", source)
term = find_int(r"(?:на|срок)\s*(\d{1,3})\s*(?:мес|месяц|months)", source)
rate = find_decimal(r"(?:под|ставк[аи]|rate)\s*(\d+(?:[.,]\d+)?)\s*%?", source)
currency = detect_currency(source)
missing = []
if purchase_price is None:
missing.append("purchase_price")
return ParsedRecord(
event_type="vehicle_purchase",
confidence=0.86 if purchase_price is not None else 0.45,
missing_fields=missing,
data={
**identity_payload(vin, plate),
"purchase_price": float(purchase_price) if purchase_price is not None else None,
"purchase_currency": currency,
"purchase_type": "credit" if loan_principal else "cash",
"loan_principal": float(loan_principal) if loan_principal is not None else None,
"loan_term_months": term,
"annual_interest_rate": float(rate) if rate is not None else None,
},
)
def parse_expense(source: str, category: str, vin: str | None, plate: str | None) -> ParsedRecord:
amount = find_decimal(r"(?:на|сумма|оплатил|total|amount)\s*(\d+(?:[.,]\d+)?)", source) or largest_money_like_number(source)
return ParsedRecord(
event_type=category,
confidence=0.75 if amount is not None else 0.5,
missing_fields=[] if amount is not None else ["amount"],
data={
**identity_payload(vin, plate),
"category": category,
"amount": float(amount) if amount is not None else None,
"currency": detect_currency(source),
},
)
def parse_service(source: str, vin: str | None, plate: str | None) -> ParsedRecord:
amount = find_decimal(r"(?:на|сумма|стоимость|total|amount)\s*(\d+(?:[.,]\d+)?)", source)
odometer = find_int(r"(?:пробег|одометр|odo|km|км)\s*(\d{2,7})", source)
title = "Замена масла" if re.search(r"масл", source, re.I) else "Сервисная запись"
return ParsedRecord(
event_type="service",
confidence=0.72,
missing_fields=[] if odometer is not None else ["odometer_km"],
data={
**identity_payload(vin, plate),
"title": title,
"amount": float(amount) if amount is not None else 0,
"odometer_km": odometer,
"service_type": "maintenance" if title == "Замена масла" else "repair",
},
)
def identity_payload(vin: str | None, plate: str | None) -> dict[str, str | None]:
return {"vin": vin, "license_plate": plate}
def extract_vin(source: str) -> str | None:
for candidate in re.findall(r"[A-HJ-NPR-Z0-9][A-HJ-NPR-Z0-9\s-]{15,25}[A-HJ-NPR-Z0-9]", source.upper()):
try:
return validate_vin(candidate)
except ValueError:
continue
return None
def extract_license_plate(source: str) -> str | None:
match = re.search(r"(?:номер|госномер|plate)\s*[:#]?\s*([A-ZА-Я0-9가-힣\-\s]{4,14})", source, re.I)
return normalize_license_plate(match.group(1)) if match else None
def find_decimal(pattern: str, source: str) -> Decimal | None:
match = re.search(pattern, source, re.I)
return decimal_from_match(match.group(1)) if match else None
def find_int(pattern: str, source: str) -> int | None:
match = re.search(pattern, source, re.I)
return int(match.group(1)) if match else None
def largest_money_like_number(source: str, exclude: set[Decimal | None] | None = None) -> Decimal | None:
excluded = {item for item in (exclude or set()) if item is not None}
values = [decimal_from_match(match.group(1)) for match in NUMBER_RE.finditer(source)]
candidates = [value for value in values if value is not None and value not in excluded]
if not candidates:
return None
return max(candidates)
def detect_currency(source: str) -> str:
lower = source.lower()
if "вон" in lower or "krw" in lower or "" in lower:
return "KRW"
if "usd" in lower or "$" in lower:
return "USD"
if "eur" in lower or "" in lower:
return "EUR"
return "RUB"

View File

@@ -36,6 +36,54 @@ class MissingItem:
DEFAULT_ACHIEVEMENTS = [
{
"code": "vehicle_added",
"scope": "vehicle",
"title": "Авто добавлено",
"description": "В гараже появилась первая карточка автомобиля.",
"icon": "car",
"category": "profile",
},
{
"code": "vin_added",
"scope": "vehicle",
"title": "VIN указан",
"description": "Идентификация автомобиля стала надежнее.",
"icon": "vin",
"category": "profile",
},
{
"code": "license_plate_added",
"scope": "vehicle",
"title": "Госномер указан",
"description": "Карточку проще связать с сервисными визитами.",
"icon": "plate",
"category": "profile",
},
{
"code": "vehicle_profile_half",
"scope": "vehicle",
"title": "Карточка авто заполнена на 50%",
"description": "Данных уже достаточно для базовой аналитики.",
"icon": "progress",
"category": "profile",
},
{
"code": "vehicle_profile_full",
"scope": "vehicle",
"title": "Карточка авто заполнена полностью",
"description": "Цифровой паспорт автомобиля готов к эксплуатации.",
"icon": "passport",
"category": "profile",
},
{
"code": "first_fuel_record",
"scope": "vehicle",
"title": "Первая заправка",
"description": "Расход топлива начал формировать историю владения.",
"icon": "fuel",
"category": "tracking",
},
{
"code": "first_service_record",
"scope": "vehicle",
@@ -371,6 +419,47 @@ async def evaluate_vehicle_achievements(
visits: list[ServiceVisit],
) -> None:
achievements = await ensure_default_achievements(session)
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vehicle_added"],
)
if car.vin_normalized:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vin_added"],
)
if car.license_plate_normalized:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["license_plate_added"],
)
if vehicle_score.completeness_score >= 50:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vehicle_profile_half"],
)
if vehicle_score.completeness_score >= 95:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vehicle_profile_full"],
)
if fuel_entries:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["first_fuel_record"],
)
if service_entries or visits:
await unlock_achievement(
session,