Complete CarPass product flows

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

View File

@@ -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,