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)

View File

@@ -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")

View File

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

View File

@@ -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")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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