Improve CarPass product UX and service flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 19:33:25 +09:00
parent b85db333d8
commit caa5f6d3db
36 changed files with 1836 additions and 366 deletions

View File

@@ -41,9 +41,9 @@ async def verify_service_center(
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 = "verified"
center.verification_status = "approved"
center.verified_at = datetime.now(UTC)
await mark_latest_verification(session, center.id, "verified", current_user.id)
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)
await session.commit()
await session.refresh(center)

View File

@@ -9,9 +9,12 @@ 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.expense import FuelEntry, ServiceEntry
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
from app.models.user import User
from app.schemas.expense import (
ExpenseEntryCreate,
ExpenseEntryRead,
ExpenseEntryUpdate,
FuelEntryCreate,
FuelEntryRead,
FuelEntryUpdate,
@@ -36,8 +39,8 @@ async def ensure_owned_car(session: AsyncSession, car_id: int, user: User) -> Ca
async def ensure_entry_owner(
session: AsyncSession, entry: FuelEntry | ServiceEntry | None, user: User
) -> FuelEntry | ServiceEntry:
session: AsyncSession, entry: FuelEntry | ServiceEntry | ExpenseEntry | None, user: User
) -> FuelEntry | ServiceEntry | ExpenseEntry:
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
await ensure_owned_car(session, entry.car_id, user)
@@ -60,9 +63,19 @@ async def refresh_current_odometer(session: AsyncSession, car_id: int) -> 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())
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
@@ -212,6 +225,79 @@ async def delete_service_entry(
await session.commit()
@router.post("/expenses", response_model=ExpenseEntryRead, status_code=status.HTTP_201_CREATED)
async def create_expense_entry(
payload: ExpenseEntryCreate,
session: AsyncSession = Depends(get_session),
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())
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.commit()
await session.refresh(entry)
return entry
@router.get("/cars/{car_id}/expenses", response_model=list[ExpenseEntryRead])
async def list_expense_entries(
car_id: int,
date_from: date | None = None,
date_to: date | None = None,
category: str | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ExpenseEntry]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
stmt = select(ExpenseEntry).where(ExpenseEntry.car_id == car_id)
if date_from:
stmt = stmt.where(ExpenseEntry.entry_date >= date_from)
if date_to:
stmt = stmt.where(ExpenseEntry.entry_date <= date_to)
if category:
stmt = stmt.where(ExpenseEntry.category == category)
result = await session.execute(
stmt.order_by(ExpenseEntry.entry_date.desc(), ExpenseEntry.id.desc()).limit(limit).offset(offset)
)
return list(result.scalars())
@router.patch("/expenses/{entry_id}", response_model=ExpenseEntryRead)
async def update_expense_entry(
entry_id: int,
payload: ExpenseEntryUpdate,
session: AsyncSession = Depends(get_session),
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():
setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id)
await session.commit()
await session.refresh(entry)
return entry
@router.delete("/expenses/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_expense_entry(
entry_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
entry = await ensure_entry_owner(session, await session.get(ExpenseEntry, entry_id), current_user)
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await session.commit()
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
async def car_stats(
car_id: int,

View File

@@ -29,6 +29,7 @@ class OCRCandidateRead(BaseModel):
class OCRResultRead(BaseModel):
recognized_text: str
candidates: list[OCRCandidateRead]
provider: str = "heuristic"
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
@@ -39,16 +40,23 @@ async def parse_text_receipt(
content = await file.read()
content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf":
return ReceiptSuggestion(
confidence=0,
message="OCR по фото/PDF пока не подключен. Загрузите текстовый чек или заполните поля вручную.",
)
result = await get_ocr_provider().recognize(content, file.filename)
if not result.recognized_text:
return ReceiptSuggestion(
confidence=0,
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
)
return parse_receipt_text(result.recognized_text)
text = " ".join(
[
file.filename or "",
content.decode("utf-8", errors="ignore"),
]
)
return parse_receipt_text(text)
def parse_receipt_text(text: str) -> ReceiptSuggestion:
normalized = text.replace("\xa0", " ").replace(",", ".")
compact = re.sub(r"\s+", " ", normalized).strip()
numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)]
@@ -102,6 +110,7 @@ async def recognize_license_plate(
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
provider=result.provider,
)
@@ -114,6 +123,7 @@ async def recognize_vin(
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
provider=result.provider,
)
@@ -126,6 +136,7 @@ async def recognize_service_document(
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
provider=result.provider,
)

View File

@@ -1,5 +1,7 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import (
@@ -14,6 +16,8 @@ from app.models.car import (
Car,
CarServiceLink,
ServiceCenter,
ServiceCenterReview,
ServiceCenterReviewComment,
ServiceCenterVerification,
ServiceEmployee,
ServiceInboxMessage,
@@ -23,8 +27,14 @@ from app.models.user import User
from app.schemas.service_center import (
CarServiceLinkCreate,
CarServiceLinkRead,
ServiceCenterAccessRequest,
ServiceCenterCreate,
ServiceCenterPublicRead,
ServiceCenterRead,
ServiceCenterReviewCommentCreate,
ServiceCenterReviewCommentRead,
ServiceCenterReviewCreate,
ServiceCenterReviewRead,
ServiceCenterVerificationCreate,
ServiceCenterVerificationRead,
ServiceEmployeeInvite,
@@ -40,6 +50,8 @@ from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
async def create_service_center(
payload: ServiceCenterCreate,
@@ -57,6 +69,13 @@ async def create_service_center(
contact_phone=payload.contact_phone or payload.phone,
telegram_chat_id=payload.telegram_chat_id,
business_registration_number=payload.business_registration_number,
description=payload.description,
specializations=payload.specializations,
working_hours=payload.working_hours,
facade_photo_url=payload.facade_photo_url,
document_photo_urls=payload.document_photo_urls,
additional_photo_urls=payload.additional_photo_urls,
contact_person=payload.contact_person,
owner_user_id=current_user.id,
verification_status="pending",
)
@@ -89,6 +108,51 @@ async def my_service_centers(
return list(result.scalars())
@router.get("/public", response_model=list[ServiceCenterPublicRead])
async def public_service_centers(
city: str | None = None,
specialization: str | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
limit = min(max(limit, 1), 200)
stmt = select(ServiceCenter).where(ServiceCenter.verification_status.in_(APPROVED_SERVICE_STATUSES))
if city:
stmt = stmt.where(ServiceCenter.city.ilike(f"%{city}%"))
result = await session.execute(
stmt.order_by(ServiceCenter.rating_avg.desc().nullslast(), ServiceCenter.display_name.asc())
.limit(limit)
.offset(max(offset, 0))
)
centers = list(result.scalars())
if specialization:
needle = specialization.lower()
centers = [
center
for center in centers
if any(needle in item.lower() for item in (center.specializations or []))
]
return centers
@router.get("/{service_center_id}", response_model=ServiceCenterPublicRead)
async def get_public_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
if center.verification_status not in APPROVED_SERVICE_STATUSES:
is_employee = await service_employee_or_none(session, service_center_id, current_user)
if not is_employee and center.owner_user_id != current_user.id:
raise HTTPException(status_code=404, detail="Service center not found")
return center
@router.get("", response_model=list[ServiceCenterRead])
async def list_service_centers(
session: AsyncSession = Depends(get_session),
@@ -173,6 +237,45 @@ async def service_center_visits(
return list(result.scalars())
async def service_employee_or_none(
session: AsyncSession, service_center_id: int, user: User
) -> ServiceEmployee | None:
result = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.user_id == user.id,
ServiceEmployee.status == "active",
)
)
return result.scalar_one_or_none()
async def ensure_service_center_approved(session: AsyncSession, service_center_id: int) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
if center.verification_status not in APPROVED_SERVICE_STATUSES:
raise HTTPException(status_code=403, detail="Service center is awaiting approval")
return center
async def ensure_center_vehicle_access(
session: AsyncSession, service_center_id: int, vehicle: Car, user: User
) -> None:
if vehicle.owner_id == user.id:
return
result = await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == vehicle.id,
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
)
)
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
@router.post("/{service_center_id}/visits", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
async def create_visit(
service_center_id: int,
@@ -184,9 +287,8 @@ 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")
center = await session.get(ServiceCenter, service_center_id)
if center and center.verification_status not in {"verified", "pending"}:
raise HTTPException(status_code=403, detail="Service center is not allowed to create visits")
await ensure_service_center_approved(session, service_center_id)
await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user)
visit = ServiceVisit(
service_center_id=service_center_id,
vehicle_id=payload.vehicle_id,
@@ -213,6 +315,7 @@ async def request_vehicle_access(
current_user: User = Depends(get_current_telegram_user),
) -> VehicleSearchResult:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
stmt = select(Car)
if payload.vin:
stmt = stmt.where(Car.vin_normalized == payload.vin)
@@ -231,6 +334,17 @@ async def request_vehicle_access(
target_id=vehicle.id if vehicle else None,
metadata={"service_center_id": service_center_id, "found": bool(vehicle)},
)
link = None
if vehicle is not None:
link = await upsert_service_link(
session,
car_id=vehicle.id,
service_center_id=service_center_id,
requested_by_user_id=current_user.id,
access_level="basic",
external_vehicle_ref=None,
status_value="pending",
)
await session.commit()
if vehicle is None:
return VehicleSearchResult(access_status="not_found")
@@ -241,10 +355,242 @@ async def request_vehicle_access(
year=vehicle.year,
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
masked_vin=mask_vin(vehicle.vin_normalized or vehicle.vin),
access_status="request_logged",
access_status="pending_owner_confirmation" if link else "request_logged",
)
@router.post("/{service_center_id}/vehicle-links/request", response_model=CarServiceLinkRead)
async def request_vehicle_link(
service_center_id: int,
payload: ServiceCenterAccessRequest,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
vehicle = await session.get(Car, payload.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
link = await upsert_service_link(
session,
car_id=payload.car_id,
service_center_id=service_center_id,
requested_by_user_id=current_user.id,
access_level=payload.access_level,
external_vehicle_ref=payload.external_vehicle_ref,
status_value="pending",
)
await log_audit(
session,
actor=current_user,
action="car_service_link.request",
target_type="car_service_link",
target_id=link.id,
metadata={"car_id": payload.car_id, "service_center_id": service_center_id},
)
await session.commit()
await session.refresh(link)
return link
@router.post("/links/{link_id}/approve", response_model=CarServiceLinkRead)
async def approve_vehicle_link(
link_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
link = await session.get(CarServiceLink, link_id)
if link is None:
raise HTTPException(status_code=404, detail="Vehicle link not found")
vehicle = await session.get(Car, link.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await ensure_service_center_approved(session, link.service_center_id)
link.status = "approved"
link.is_active = True
link.approved_by_user_id = current_user.id
link.approved_at = datetime.now(UTC)
link.revoked_at = None
await log_audit(session, actor=current_user, action="car_service_link.approve", target_type="car_service_link", target_id=link.id)
await session.commit()
await session.refresh(link)
return link
@router.post("/links/{link_id}/revoke", response_model=CarServiceLinkRead)
async def revoke_vehicle_link(
link_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> CarServiceLink:
link = await session.get(CarServiceLink, link_id)
if link is None:
raise HTTPException(status_code=404, detail="Vehicle link not found")
vehicle = await session.get(Car, link.car_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
link.status = "revoked"
link.is_active = False
link.revoked_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="car_service_link.revoke", target_type="car_service_link", target_id=link.id)
await session.commit()
await session.refresh(link)
return link
async def upsert_service_link(
session: AsyncSession,
*,
car_id: int,
service_center_id: int,
requested_by_user_id: int | None,
access_level: str,
external_vehicle_ref: str | None,
status_value: str,
) -> CarServiceLink:
result = await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == car_id,
CarServiceLink.service_center_id == service_center_id,
)
)
link = result.scalar_one_or_none()
if link is None:
link = CarServiceLink(
car_id=car_id,
service_center_id=service_center_id,
requested_by_user_id=requested_by_user_id,
access_level=access_level,
external_vehicle_ref=external_vehicle_ref,
status=status_value,
is_active=status_value == "approved",
)
session.add(link)
await session.flush()
else:
link.requested_by_user_id = requested_by_user_id
link.access_level = access_level
link.external_vehicle_ref = external_vehicle_ref or link.external_vehicle_ref
link.status = status_value
link.is_active = status_value == "approved"
return link
@router.get("/{service_center_id}/reviews", response_model=list[ServiceCenterReviewRead])
async def service_center_reviews(
service_center_id: int,
sort: str = "new",
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenterReview]:
await get_public_service_center(service_center_id, session, current_user)
limit = min(max(limit, 1), 200)
stmt = select(ServiceCenterReview).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.status == "published",
)
if sort == "high":
stmt = stmt.order_by(ServiceCenterReview.rating.desc(), ServiceCenterReview.created_at.desc())
elif sort == "low":
stmt = stmt.order_by(ServiceCenterReview.rating.asc(), ServiceCenterReview.created_at.desc())
else:
stmt = stmt.order_by(ServiceCenterReview.created_at.desc())
result = await session.execute(stmt.limit(limit).offset(max(offset, 0)))
return list(result.scalars())
@router.post("/{service_center_id}/reviews", response_model=ServiceCenterReviewRead, status_code=status.HTTP_201_CREATED)
async def create_service_center_review(
service_center_id: int,
payload: ServiceCenterReviewCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
await ensure_service_center_approved(session, service_center_id)
result = await session.execute(
select(ServiceCenterReview).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.user_id == current_user.id,
)
)
review = result.scalar_one_or_none()
if review is None:
review = ServiceCenterReview(
service_center_id=service_center_id,
user_id=current_user.id,
**payload.model_dump(),
)
session.add(review)
else:
review.rating = payload.rating
review.text = payload.text
review.photo_urls = payload.photo_urls
review.status = "published"
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
await session.flush()
await refresh_service_rating(session, service_center_id)
await session.commit()
await session.refresh(review)
return review
@router.post("/reviews/{review_id}/comments", response_model=ServiceCenterReviewCommentRead, status_code=status.HTTP_201_CREATED)
async def create_review_comment(
review_id: int,
payload: ServiceCenterReviewCommentCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReviewComment:
review = await session.get(ServiceCenterReview, review_id)
if review is None or review.status != "published":
raise HTTPException(status_code=404, detail="Review not found")
comment = ServiceCenterReviewComment(review_id=review_id, user_id=current_user.id, text=payload.text)
session.add(comment)
await log_audit(session, actor=current_user, action="service_review.comment", target_type="service_review", target_id=review_id)
await session.commit()
await session.refresh(comment)
return comment
@router.post("/reviews/{review_id}/respond", response_model=ServiceCenterReviewRead)
async def respond_to_review(
review_id: int,
payload: ServiceCenterReviewCommentCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
review = await session.get(ServiceCenterReview, review_id)
if review is None:
raise HTTPException(status_code=404, detail="Review not found")
await ensure_service_employee(session, review.service_center_id, current_user, {"owner", "manager"})
review.service_response = payload.text
review.service_responded_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_review.respond", target_type="service_review", target_id=review_id)
await session.commit()
await session.refresh(review)
return review
async def refresh_service_rating(session: AsyncSession, service_center_id: int) -> None:
result = await session.execute(
select(func.avg(ServiceCenterReview.rating), func.count(ServiceCenterReview.id)).where(
ServiceCenterReview.service_center_id == service_center_id,
ServiceCenterReview.status == "published",
)
)
avg_rating, count = result.one()
center = await session.get(ServiceCenter, service_center_id)
if center is not None:
center.rating_avg = round(avg_rating, 2) if avg_rating is not None else None
center.reviews_count = int(count or 0)
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
async def link_car_to_service(
payload: CarServiceLinkCreate,
@@ -256,7 +602,7 @@ async def link_car_to_service(
raise HTTPException(status_code=404, detail="Car not found")
if await session.get(ServiceCenter, payload.service_center_id) is None:
raise HTTPException(status_code=404, detail="Service center not found")
link = CarServiceLink(**payload.model_dump())
link = CarServiceLink(**payload.model_dump(), status="approved", approved_at=datetime.now(UTC))
session.add(link)
await session.commit()
await session.refresh(link)