Improve CarPass product UX and service flows
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,10 @@ class Settings(BaseSettings):
|
||||
internal_api_token: str = ""
|
||||
vapid_public_key: str = ""
|
||||
allow_dev_auth: bool = False
|
||||
ocr_provider: str = "tesseract"
|
||||
ocr_languages: str = "eng+rus+kor"
|
||||
llm_base_url: str = ""
|
||||
llm_model: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
@@ -47,6 +48,8 @@ class Car(Base):
|
||||
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
||||
purchase_date: Mapped[date | None] = mapped_column(Date)
|
||||
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||
include_depreciation: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
current_odometer: Mapped[int | None]
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
@@ -56,6 +59,7 @@ class Car(Base):
|
||||
owner = relationship("User", back_populates="cars")
|
||||
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
|
||||
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")
|
||||
|
||||
|
||||
@@ -115,6 +119,15 @@ class ServiceCenter(Base):
|
||||
phone: Mapped[str | None] = mapped_column(String(40))
|
||||
contact_phone: Mapped[str | None] = mapped_column(String(40))
|
||||
address: Mapped[str | None] = mapped_column(String(240))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
specializations: Mapped[list | None] = mapped_column(JSON)
|
||||
working_hours: Mapped[str | None] = mapped_column(String(240))
|
||||
facade_photo_url: Mapped[str | None] = mapped_column(String(500))
|
||||
document_photo_urls: Mapped[list | None] = mapped_column(JSON)
|
||||
additional_photo_urls: Mapped[list | None] = mapped_column(JSON)
|
||||
contact_person: Mapped[str | None] = mapped_column(String(160))
|
||||
rating_avg: Mapped[Decimal | None] = mapped_column(Numeric(3, 2))
|
||||
reviews_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
business_registration_number: Mapped[str | None] = mapped_column(String(80))
|
||||
verification_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
@@ -126,6 +139,7 @@ class ServiceCenter(Base):
|
||||
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
|
||||
employees = relationship("ServiceEmployee", back_populates="service_center", cascade="all, delete-orphan")
|
||||
visits = relationship("ServiceVisit", back_populates="service_center")
|
||||
reviews = relationship("ServiceCenterReview", back_populates="service_center", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CarServiceLink(Base):
|
||||
@@ -136,6 +150,12 @@ class CarServiceLink(Base):
|
||||
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
external_vehicle_ref: Mapped[str | None] = mapped_column(String(120), index=True)
|
||||
access_level: Mapped[str] = mapped_column(String(32), default="basic", server_default="basic", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", server_default="pending", index=True)
|
||||
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
is_active: Mapped[bool] = mapped_column(default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -243,6 +263,41 @@ class ServiceWorkItem(Base):
|
||||
visit = relationship("ServiceVisit", back_populates="work_items")
|
||||
|
||||
|
||||
class ServiceCenterReview(Base):
|
||||
__tablename__ = "service_center_reviews"
|
||||
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
rating: Mapped[int] = mapped_column(Integer)
|
||||
text: Mapped[str | None] = mapped_column(Text)
|
||||
photo_urls: Mapped[list | None] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(24), default="published", server_default="published", index=True)
|
||||
service_response: Mapped[str | None] = mapped_column(Text)
|
||||
service_responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="reviews")
|
||||
comments = relationship("ServiceCenterReviewComment", back_populates="review", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServiceCenterReviewComment(Base):
|
||||
__tablename__ = "service_center_review_comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
review_id: Mapped[int] = mapped_column(ForeignKey("service_center_reviews.id", ondelete="CASCADE"), index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(24), default="published", server_default="published", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
review = relationship("ServiceCenterReview", back_populates="comments")
|
||||
|
||||
|
||||
class VehicleDataChangeRequest(Base):
|
||||
__tablename__ = "vehicle_data_change_requests"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
|
||||
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -19,6 +19,29 @@ class ServiceType(str, enum.Enum):
|
||||
other = "other"
|
||||
|
||||
|
||||
class ExpenseCategory(str, enum.Enum):
|
||||
insurance = "insurance"
|
||||
tax = "tax"
|
||||
fine = "fine"
|
||||
parking = "parking"
|
||||
car_wash = "car_wash"
|
||||
toll = "toll"
|
||||
tires = "tires"
|
||||
wheels = "wheels"
|
||||
battery = "battery"
|
||||
parts = "parts"
|
||||
repair = "repair"
|
||||
maintenance = "maintenance"
|
||||
diagnostics = "diagnostics"
|
||||
towing = "towing"
|
||||
loan_payment = "loan_payment"
|
||||
loan_interest = "loan_interest"
|
||||
state_fee = "state_fee"
|
||||
registration = "registration"
|
||||
inspection = "inspection"
|
||||
other = "other"
|
||||
|
||||
|
||||
class FuelEntry(Base):
|
||||
__tablename__ = "fuel_entries"
|
||||
|
||||
@@ -56,3 +79,25 @@ class ServiceEntry(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
car = relationship("Car", back_populates="service_entries")
|
||||
|
||||
|
||||
class ExpenseEntry(Base):
|
||||
__tablename__ = "expense_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
entry_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
category: Mapped[ExpenseCategory] = mapped_column(Enum(ExpenseCategory), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
vendor: Mapped[str | None] = mapped_column(String(160))
|
||||
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2))
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||
odometer: Mapped[int | None]
|
||||
period_start: Mapped[date | None] = mapped_column(Date)
|
||||
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")
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
car = relationship("Car", back_populates="expense_entries")
|
||||
|
||||
@@ -25,6 +25,8 @@ class CarBase(BaseModel):
|
||||
tire_pressure_rear_bar: Decimal | None = None
|
||||
purchase_date: date | None = None
|
||||
purchase_price: Decimal | None = None
|
||||
currency: str = "RUB"
|
||||
include_depreciation: bool = False
|
||||
current_odometer: int | None = None
|
||||
|
||||
|
||||
@@ -53,6 +55,8 @@ class CarUpdate(BaseModel):
|
||||
tire_pressure_rear_bar: Decimal | None = None
|
||||
purchase_date: date | None = None
|
||||
purchase_price: Decimal | None = None
|
||||
currency: str | None = None
|
||||
include_depreciation: bool | None = None
|
||||
current_odometer: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.models.expense import ServiceType
|
||||
from app.models.expense import ExpenseCategory, ServiceType
|
||||
|
||||
|
||||
class FuelEntryBase(BaseModel):
|
||||
@@ -87,6 +87,62 @@ class ServiceEntryRead(ServiceEntryBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ExpenseEntryBase(BaseModel):
|
||||
entry_date: date
|
||||
category: ExpenseCategory
|
||||
title: str
|
||||
vendor: str | None = None
|
||||
total_cost: Decimal
|
||||
currency: str = "RUB"
|
||||
odometer: int | None = None
|
||||
period_start: date | None = None
|
||||
period_end: date | None = None
|
||||
period_months: int | None = None
|
||||
is_recurring: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_period(self) -> "ExpenseEntryBase":
|
||||
if self.period_months is not None and self.period_months < 1:
|
||||
raise ValueError("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")
|
||||
return self
|
||||
|
||||
|
||||
class ExpenseEntryCreate(ExpenseEntryBase):
|
||||
car_id: int
|
||||
|
||||
|
||||
class ExpenseEntryUpdate(BaseModel):
|
||||
entry_date: date | None = None
|
||||
category: ExpenseCategory | None = None
|
||||
title: str | None = None
|
||||
vendor: str | None = None
|
||||
total_cost: Decimal | None = None
|
||||
currency: str | None = None
|
||||
odometer: int | None = None
|
||||
period_start: date | None = None
|
||||
period_end: date | None = None
|
||||
period_months: int | None = None
|
||||
is_recurring: bool | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ExpenseEntryRead(ExpenseEntryBase):
|
||||
id: int
|
||||
car_id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class OwnershipCategoryBreakdown(BaseModel):
|
||||
category: str
|
||||
total_cost: Decimal
|
||||
entries_count: int
|
||||
|
||||
|
||||
class OwnershipStats(BaseModel):
|
||||
car_id: int
|
||||
date_from: date
|
||||
@@ -94,6 +150,14 @@ class OwnershipStats(BaseModel):
|
||||
fuel_cost: Decimal
|
||||
service_cost: Decimal
|
||||
total_cost: Decimal
|
||||
expenses_cost: 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")
|
||||
cost_per_month: Decimal = Decimal("0")
|
||||
cost_by_category: dict[str, Decimal] = Field(default_factory=dict)
|
||||
categories: list[OwnershipCategoryBreakdown] = Field(default_factory=list)
|
||||
liters: Decimal
|
||||
distance_km: int
|
||||
avg_consumption_l_per_100km: float | None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
@@ -16,6 +16,13 @@ class ServiceCenterCreate(BaseModel):
|
||||
business_registration_number: str | None = None
|
||||
telegram_chat_id: str | None = None
|
||||
contact_phone: str | None = None
|
||||
description: str | None = None
|
||||
specializations: list[str] | None = None
|
||||
working_hours: str | None = None
|
||||
facade_photo_url: str | None = None
|
||||
document_photo_urls: list[str] | None = None
|
||||
additional_photo_urls: list[str] | None = None
|
||||
contact_person: str | None = None
|
||||
|
||||
|
||||
class ServiceCenterRead(ServiceCenterCreate):
|
||||
@@ -26,6 +33,27 @@ class ServiceCenterRead(ServiceCenterCreate):
|
||||
created_at: datetime
|
||||
verified_at: datetime | None = None
|
||||
suspended_at: datetime | None = None
|
||||
rating_avg: Decimal | None = None
|
||||
reviews_count: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceCenterPublicRead(BaseModel):
|
||||
id: int
|
||||
display_name: str | None = None
|
||||
name: str
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
address: str | None = None
|
||||
phone: str | None = None
|
||||
description: str | None = None
|
||||
specializations: list[str] | None = None
|
||||
working_hours: str | None = None
|
||||
facade_photo_url: str | None = None
|
||||
verification_status: str
|
||||
rating_avg: Decimal | None = None
|
||||
reviews_count: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -91,8 +119,15 @@ class VehicleCreate(BaseModel):
|
||||
license_plate_country: str | None = None
|
||||
vin: str | None = None
|
||||
current_odometer: int | None = None
|
||||
fuel_type: str | None = None
|
||||
engine_oil_type: str | None = None
|
||||
engine_oil_volume_l: Decimal | 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
|
||||
currency: str = "RUB"
|
||||
include_depreciation: bool = False
|
||||
|
||||
@field_validator("vin")
|
||||
@classmethod
|
||||
@@ -109,6 +144,13 @@ class VehicleUpdate(BaseModel):
|
||||
license_plate_country: str | None = None
|
||||
vin: str | None = None
|
||||
current_odometer: int | None = None
|
||||
fuel_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
|
||||
currency: str | None = None
|
||||
include_depreciation: bool | None = None
|
||||
engine_oil_type: str | None = None
|
||||
engine_oil_volume_l: Decimal | None = None
|
||||
|
||||
@@ -129,6 +171,13 @@ class VehicleRead(BaseModel):
|
||||
license_plate_country: str | None = None
|
||||
vin_normalized: str | None = None
|
||||
current_odometer: int | None = None
|
||||
fuel_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
|
||||
currency: str = "RUB"
|
||||
include_depreciation: bool = False
|
||||
engine_oil_type: str | None = None
|
||||
engine_oil_volume_l: Decimal | None = None
|
||||
created_at: datetime
|
||||
@@ -226,11 +275,70 @@ class CarServiceLinkCreate(BaseModel):
|
||||
car_id: int
|
||||
service_center_id: int
|
||||
external_vehicle_ref: str | None = None
|
||||
access_level: str = "basic"
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CarServiceLinkRead(CarServiceLinkCreate):
|
||||
id: int
|
||||
status: str = "pending"
|
||||
requested_by_user_id: int | None = None
|
||||
approved_by_user_id: int | None = None
|
||||
approved_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceCenterAccessRequest(BaseModel):
|
||||
car_id: int
|
||||
access_level: str = "basic"
|
||||
external_vehicle_ref: str | None = None
|
||||
|
||||
@field_validator("access_level")
|
||||
@classmethod
|
||||
def validate_access_level(cls, value: str) -> str:
|
||||
allowed = {"basic", "service_history", "full"}
|
||||
if value not in allowed:
|
||||
raise ValueError(f"access_level must be one of {', '.join(sorted(allowed))}")
|
||||
return value
|
||||
|
||||
|
||||
class ServiceCenterReviewCreate(BaseModel):
|
||||
rating: int = Field(ge=1, le=5)
|
||||
text: str | None = None
|
||||
photo_urls: list[str] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_review(self) -> "ServiceCenterReviewCreate":
|
||||
if self.text is not None and len(self.text.strip()) < 3:
|
||||
raise ValueError("review text is too short")
|
||||
return self
|
||||
|
||||
|
||||
class ServiceCenterReviewRead(ServiceCenterReviewCreate):
|
||||
id: int
|
||||
service_center_id: int
|
||||
user_id: int
|
||||
status: str
|
||||
service_response: str | None = None
|
||||
service_responded_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceCenterReviewCommentCreate(BaseModel):
|
||||
text: str = Field(min_length=2, max_length=2000)
|
||||
|
||||
|
||||
class ServiceCenterReviewCommentRead(ServiceCenterReviewCommentCreate):
|
||||
id: int
|
||||
review_id: int
|
||||
user_id: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from datetime import date
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.expense import FuelEntry, ServiceEntry
|
||||
from app.models.car import Car
|
||||
from app.models.expense import ExpenseCategory, ExpenseEntry, FuelEntry, ServiceEntry
|
||||
from app.schemas.expense import OdometerPrediction, OwnershipStats
|
||||
|
||||
|
||||
@@ -36,10 +38,56 @@ async def get_ownership_stats(
|
||||
)
|
||||
service_cost, service_count = service_totals.one()
|
||||
|
||||
distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0
|
||||
total_cost = Decimal(fuel_cost) + Decimal(service_cost)
|
||||
odometer_values = [min_odo, max_odo]
|
||||
service_odo = await session.execute(
|
||||
select(func.min(ServiceEntry.odometer), func.max(ServiceEntry.odometer)).where(
|
||||
ServiceEntry.car_id == car_id,
|
||||
ServiceEntry.odometer.is_not(None),
|
||||
ServiceEntry.entry_date >= date_from,
|
||||
ServiceEntry.entry_date <= date_to,
|
||||
)
|
||||
)
|
||||
expense_odo = await session.execute(
|
||||
select(func.min(ExpenseEntry.odometer), func.max(ExpenseEntry.odometer)).where(
|
||||
ExpenseEntry.car_id == car_id,
|
||||
ExpenseEntry.odometer.is_not(None),
|
||||
ExpenseEntry.entry_date >= date_from,
|
||||
ExpenseEntry.entry_date <= date_to,
|
||||
)
|
||||
)
|
||||
odometer_values.extend(service_odo.one())
|
||||
odometer_values.extend(expense_odo.one())
|
||||
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(
|
||||
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")
|
||||
|
||||
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)
|
||||
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_month = (total_cost / months).quantize(Decimal("0.01"))
|
||||
recurring_total = (recurring_cost + depreciation_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"))
|
||||
|
||||
cost_by_category = {
|
||||
"fuel": Decimal(fuel_cost),
|
||||
"service": Decimal(service_cost),
|
||||
**expense_categories,
|
||||
}
|
||||
if depreciation_cost:
|
||||
cost_by_category["depreciation"] = depreciation_cost
|
||||
categories = [
|
||||
{"category": key, "total_cost": value, "entries_count": 0}
|
||||
for key, value in sorted(cost_by_category.items())
|
||||
if value
|
||||
]
|
||||
|
||||
return OwnershipStats(
|
||||
car_id=car_id,
|
||||
@@ -47,7 +95,15 @@ async def get_ownership_stats(
|
||||
date_to=date_to,
|
||||
fuel_cost=fuel_cost,
|
||||
service_cost=service_cost,
|
||||
expenses_cost=expense_cost,
|
||||
total_cost=total_cost,
|
||||
recurring_costs=recurring_total,
|
||||
one_time_costs=one_time_costs,
|
||||
forecast_next_month=forecast_next_month,
|
||||
depreciation_cost=depreciation_cost,
|
||||
cost_per_month=cost_per_month,
|
||||
cost_by_category=cost_by_category,
|
||||
categories=categories,
|
||||
liters=liters,
|
||||
distance_km=distance_km,
|
||||
avg_consumption_l_per_100km=avg_consumption,
|
||||
@@ -57,6 +113,92 @@ async def get_ownership_stats(
|
||||
)
|
||||
|
||||
|
||||
def period_days(date_from: date, date_to: date) -> int:
|
||||
return max((date_to - date_from).days + 1, 1)
|
||||
|
||||
|
||||
def add_months(value: date, months: int) -> date:
|
||||
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)
|
||||
|
||||
|
||||
def overlap_days(left_start: date, left_end: date, right_start: date, right_end: date) -> int:
|
||||
start = max(left_start, right_start)
|
||||
end = min(left_end, right_end)
|
||||
if end < start:
|
||||
return 0
|
||||
return period_days(start, end)
|
||||
|
||||
|
||||
def expense_window(entry: ExpenseEntry) -> tuple[date, date]:
|
||||
if entry.period_start and entry.period_end:
|
||||
return entry.period_start, entry.period_end
|
||||
if entry.period_start and entry.period_months:
|
||||
return entry.period_start, add_months(entry.period_start, entry.period_months) - timedelta(days=1)
|
||||
if entry.period_months:
|
||||
return entry.entry_date, add_months(entry.entry_date, entry.period_months) - timedelta(days=1)
|
||||
return entry.entry_date, entry.entry_date
|
||||
|
||||
|
||||
def allocated_expense_cost(entry: ExpenseEntry, date_from: date, date_to: date) -> Decimal:
|
||||
start, end = expense_window(entry)
|
||||
total_days = period_days(start, end)
|
||||
matched_days = overlap_days(start, end, date_from, date_to)
|
||||
if matched_days <= 0:
|
||||
return Decimal("0")
|
||||
if total_days <= 1 and start == entry.entry_date:
|
||||
return Decimal(entry.total_cost)
|
||||
return (Decimal(entry.total_cost) * Decimal(matched_days) / Decimal(total_days)).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]]:
|
||||
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),
|
||||
),
|
||||
)
|
||||
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
|
||||
)
|
||||
total = Decimal("0")
|
||||
recurring = Decimal("0")
|
||||
categories: dict[str, Decimal] = {}
|
||||
count = 0
|
||||
for entry in result.scalars():
|
||||
amount = allocated_expense_cost(entry, date_from, date_to)
|
||||
if amount <= 0:
|
||||
continue
|
||||
count += 1
|
||||
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}:
|
||||
recurring += amount
|
||||
return total.quantize(Decimal("0.01")), recurring.quantize(Decimal("0.01")), count, categories
|
||||
|
||||
|
||||
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)
|
||||
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))
|
||||
return (daily_cost * Decimal(matched_days)).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
async def full_tank_consumption(
|
||||
session: AsyncSession, car_id: int, date_from: date, date_to: date
|
||||
) -> float | None:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
from typing import Protocol
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
|
||||
@@ -15,34 +20,95 @@ class OcrCandidate:
|
||||
class OcrResult:
|
||||
recognized_text: str
|
||||
candidates: list[OcrCandidate]
|
||||
provider: str = "heuristic"
|
||||
|
||||
|
||||
class OCRProvider:
|
||||
class OCRProvider(Protocol):
|
||||
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||
raise NotImplementedError
|
||||
...
|
||||
|
||||
|
||||
class StubOCRProvider(OCRProvider):
|
||||
class TextHeuristicOCRProvider:
|
||||
provider_name = "heuristic"
|
||||
|
||||
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||
text = " ".join(
|
||||
[
|
||||
filename or "",
|
||||
content.decode("utf-8", errors="ignore"),
|
||||
]
|
||||
text = " ".join([filename or "", content.decode("utf-8", errors="ignore")])
|
||||
return build_ocr_result(text, provider=self.provider_name, base_confidence=0.62)
|
||||
|
||||
|
||||
class TesseractOCRProvider:
|
||||
provider_name = "tesseract"
|
||||
|
||||
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||
text = await asyncio.to_thread(self._recognize_sync, content)
|
||||
if not text.strip():
|
||||
fallback = await TextHeuristicOCRProvider().recognize(content, filename)
|
||||
fallback.provider = self.provider_name
|
||||
return fallback
|
||||
return build_ocr_result(text, provider=self.provider_name, base_confidence=0.78)
|
||||
|
||||
def _recognize_sync(self, content: bytes) -> str:
|
||||
try:
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return ""
|
||||
try:
|
||||
image = Image.open(BytesIO(content))
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
return pytesseract.image_to_string(image, lang=settings.ocr_languages)
|
||||
except Exception:
|
||||
return pytesseract.image_to_string(image)
|
||||
|
||||
|
||||
class CompositeOCRProvider:
|
||||
def __init__(self) -> None:
|
||||
provider = settings.ocr_provider.lower()
|
||||
self.primary: OCRProvider = (
|
||||
TextHeuristicOCRProvider() if provider == "heuristic" else TesseractOCRProvider()
|
||||
)
|
||||
compact = re.sub(r"\s+", " ", text).strip()
|
||||
candidates: list[OcrCandidate] = []
|
||||
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", compact.upper()):
|
||||
try:
|
||||
candidates.append(OcrCandidate(type="vin", value=validate_vin(raw) or raw, confidence=0.84))
|
||||
except ValueError:
|
||||
continue
|
||||
for raw in re.findall(r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b", compact.upper()):
|
||||
|
||||
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||
return await self.primary.recognize(content, filename)
|
||||
|
||||
|
||||
def build_ocr_result(text: str, *, provider: str, base_confidence: float) -> OcrResult:
|
||||
compact = re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
|
||||
candidates: list[OcrCandidate] = []
|
||||
upper = compact.upper()
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", upper):
|
||||
try:
|
||||
value = validate_vin(raw) or raw
|
||||
except ValueError:
|
||||
continue
|
||||
key = ("vin", value)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
candidates.append(OcrCandidate(type="vin", value=value, confidence=min(base_confidence + 0.12, 0.95)))
|
||||
|
||||
plate_patterns = [
|
||||
r"\b\d{2,3}\s*[가-힣]\s*\d{4}\b",
|
||||
r"\b[A-ZА-Я]{1}\s?\d{3}\s?[A-ZА-Я]{2}\s?\d{2,3}\b",
|
||||
r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b",
|
||||
]
|
||||
for pattern in plate_patterns:
|
||||
for raw in re.findall(pattern, upper):
|
||||
normalized = normalize_license_plate(raw)
|
||||
if normalized and 5 <= len(normalized) <= 10 and not any(item.value == normalized for item in candidates):
|
||||
candidates.append(OcrCandidate(type="license_plate", value=normalized, confidence=0.62))
|
||||
return OcrResult(recognized_text=compact, candidates=candidates[:8])
|
||||
if normalized and 5 <= len(normalized) <= 10:
|
||||
key = ("license_plate", normalized)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
candidates.append(
|
||||
OcrCandidate(type="license_plate", value=normalized, confidence=base_confidence)
|
||||
)
|
||||
|
||||
return OcrResult(recognized_text=compact, candidates=candidates[:12], provider=provider)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_ocr_provider() -> OCRProvider:
|
||||
return StubOCRProvider()
|
||||
return CompositeOCRProvider()
|
||||
|
||||
@@ -436,13 +436,13 @@ async def compute_service_center_score(session: AsyncSession, center: ServiceCen
|
||||
confirmation_rate = Decimal(len(confirmed) * 100) / Decimal(len(relevant))
|
||||
dispute_rate = Decimal(len(disputed) * 100) / Decimal(len(relevant))
|
||||
|
||||
score = 20 if center.verification_status == "verified" else 5
|
||||
score = 20 if center.verification_status in {"approved", "verified"} else 5
|
||||
score += min(30, len(confirmed) * 5)
|
||||
score += int(min(30, confirmation_rate * Decimal("0.3")))
|
||||
score -= int(min(25, dispute_rate * Decimal("0.5")))
|
||||
score = max(0, min(100, score))
|
||||
|
||||
if center.verification_status != "verified":
|
||||
if center.verification_status not in {"approved", "verified"}:
|
||||
level = "new_service"
|
||||
elif score >= 85:
|
||||
level = "high_confidence_service"
|
||||
|
||||
Reference in New Issue
Block a user