Complete CarPass product flows
This commit is contained in:
151
app/api/admin.py
151
app/api/admin.py
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
126
app/api/my.py
126
app/api/my.py
@@ -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
55
app/api/parser.py
Normal 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
|
||||
],
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
"""Shared SQLAlchemy declarative metadata."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
94
app/services/loans.py
Normal 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
157
app/services/odometer.py
Normal 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,
|
||||
)
|
||||
193
app/services/record_parser.py
Normal file
193
app/services/record_parser.py
Normal 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"
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user