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)
|
||||
|
||||
Reference in New Issue
Block a user