Add STO booking and maintenance automation

This commit is contained in:
VPN SaaS Dev
2026-05-15 05:17:54 +09:00
parent 2be7ba2099
commit fec9635079
12 changed files with 2178 additions and 5 deletions

696
app/api/sto_booking.py Normal file
View File

@@ -0,0 +1,696 @@
from datetime import UTC, date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
from app.db.session import get_session
from app.models.car import (
Car,
CarServiceLink,
MaintenanceRecommendation,
ServiceAppointment,
ServiceCenter,
ServiceCenterBookingSettings,
ServiceCenterHoliday,
ServiceVisit,
)
from app.models.user import User
from app.schemas.service_center import ServiceVisitRead
from app.schemas.sto_booking import (
AppointmentCancel,
AppointmentCreate,
AppointmentCreateWorkOrder,
AppointmentDecision,
AppointmentProposeTime,
AppointmentRead,
AvailableSlotRead,
MaintenanceRecommendationBook,
MaintenanceRecommendationCreate,
MaintenanceRecommendationRead,
ServiceCatalogItem,
ServiceCenterBookingSettingsRead,
ServiceCenterBookingSettingsUpsert,
ServiceCenterHolidayCreate,
ServiceCenterHolidayRead,
STODashboardRead,
)
from app.services.sto_booking import (
calculate_available_slots,
create_service_notification,
ensure_oil_recommendation,
ensure_slot_available,
estimate_duration,
get_booking_settings,
money_to_float,
notify_service_staff,
)
APPROVED_SERVICE_STATUSES = {"verified", "approved"}
router = APIRouter(tags=["sto-booking"])
def _utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
async def _approved_service_center(session: AsyncSession, service_center_id: int) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None or center.verification_status not in APPROVED_SERVICE_STATUSES:
raise HTTPException(status_code=404, detail="Service center not found")
return center
async def _owned_vehicle(session: AsyncSession, vehicle_id: int, user: User) -> Car:
vehicle = await session.get(Car, vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return vehicle
async def _appointment_for_owner(session: AsyncSession, appointment_id: int, user: User) -> ServiceAppointment:
appointment = await session.get(ServiceAppointment, appointment_id)
if appointment is None:
raise HTTPException(status_code=404, detail="Appointment not found")
if appointment.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return appointment
async def _appointment_for_sto(session: AsyncSession, appointment_id: int, user: User) -> ServiceAppointment:
appointment = await session.get(ServiceAppointment, appointment_id)
if appointment is None:
raise HTTPException(status_code=404, detail="Appointment not found")
await ensure_service_employee(session, appointment.service_center_id, user, {"owner", "manager", "receptionist"})
return appointment
@router.get("/sto/catalog", response_model=list[ServiceCatalogItem])
async def get_sto_catalog(
city: str | None = None,
specialization: str | None = None,
open_today: bool = False,
has_slots: bool = False,
session: AsyncSession = Depends(get_session),
) -> list[ServiceCatalogItem]:
stmt = select(ServiceCenter).where(ServiceCenter.verification_status.in_(APPROVED_SERVICE_STATUSES))
if city:
stmt = stmt.where(ServiceCenter.city.ilike(f"%{city}%"))
if specialization:
stmt = stmt.where(ServiceCenter.specializations.is_not(None))
stmt = stmt.order_by(ServiceCenter.rating_avg.desc().nullslast(), ServiceCenter.display_name.asc())
centers = list((await session.execute(stmt)).scalars())
today = date.today()
result: list[ServiceCatalogItem] = []
for center in centers:
settings = await get_booking_settings(session, center.id)
if open_today and today.weekday() not in (settings.working_days or []):
continue
if specialization and specialization.lower() not in " ".join(center.specializations or []).lower():
continue
nearest_slot_at = None
if has_slots or settings.accepts_online_booking:
slots = await calculate_available_slots(
session,
service_center_id=center.id,
date_from=today,
date_to=today + timedelta(days=14),
duration_minutes=60,
limit=1,
)
nearest_slot_at = slots[0][0] if slots else None
if has_slots and nearest_slot_at is None:
continue
result.append(
ServiceCatalogItem(
id=center.id,
display_name=center.display_name,
name=center.name,
city=center.city,
address=center.address,
specializations=center.specializations,
working_hours=center.working_hours,
rating_avg=float(center.rating_avg) if center.rating_avg is not None else None,
reviews_count=center.reviews_count,
nearest_slot_at=nearest_slot_at,
accepts_online_booking=settings.accepts_online_booking,
)
)
return result
@router.get("/sto/{service_center_id}/available-slots", response_model=list[AvailableSlotRead])
async def get_available_slots(
service_center_id: int,
service_type: str = "maintenance",
date_from: date | None = None,
date_to: date | None = None,
duration_minutes: int | None = Query(default=None, ge=10, le=1440),
session: AsyncSession = Depends(get_session),
) -> list[AvailableSlotRead]:
await _approved_service_center(session, service_center_id)
start_date = date_from or date.today()
end_date = date_to or start_date + timedelta(days=14)
if end_date < start_date:
raise HTTPException(status_code=400, detail="date_to must be after date_from")
duration = estimate_duration(service_type, duration_minutes)
slots = await calculate_available_slots(
session,
service_center_id=service_center_id,
date_from=start_date,
date_to=end_date,
duration_minutes=duration,
)
return [AvailableSlotRead(start_at=start, end_at=end) for start, end in slots]
@router.post("/appointments", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED)
async def create_appointment(
payload: AppointmentCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
await _approved_service_center(session, payload.service_center_id)
vehicle = await _owned_vehicle(session, payload.vehicle_id, current_user)
duration = estimate_duration(payload.service_type, payload.estimated_duration_minutes)
start_at = _utc(payload.requested_start_at)
end_at = await ensure_slot_available(
session,
service_center_id=payload.service_center_id,
start_at=start_at,
duration_minutes=duration,
)
if payload.source_recommendation_id:
recommendation = await session.get(MaintenanceRecommendation, payload.source_recommendation_id)
if recommendation is None or recommendation.vehicle_id != vehicle.id:
raise HTTPException(status_code=400, detail="Recommendation does not belong to vehicle")
appointment = ServiceAppointment(
service_center_id=payload.service_center_id,
vehicle_id=vehicle.id,
owner_id=current_user.id,
created_by=current_user.id,
service_type=payload.service_type,
service_name=payload.service_name,
requested_start_at=start_at,
requested_end_at=end_at,
estimated_duration_minutes=duration,
status="requested",
customer_comment=payload.customer_comment,
source_recommendation_id=payload.source_recommendation_id,
)
session.add(appointment)
await session.flush()
if payload.source_recommendation_id:
recommendation.status = "booked"
recommendation.source_appointment_id = appointment.id
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.requested",
title="Новая заявка на запись",
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
appointment_id=appointment.id,
)
await log_audit(
session,
actor=current_user,
action="appointment.create",
target_type="service_appointment",
target_id=appointment.id,
)
await session.commit()
await session.refresh(appointment)
return appointment
@router.get("/appointments/my", response_model=list[AppointmentRead])
async def get_my_appointments(
status_filter: str | None = Query(default=None, alias="status"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceAppointment]:
stmt = (
select(ServiceAppointment)
.where(ServiceAppointment.owner_id == current_user.id)
.order_by(ServiceAppointment.requested_start_at.desc())
)
if status_filter:
stmt = stmt.where(ServiceAppointment.status == status_filter)
return list((await session.execute(stmt)).scalars())
@router.post("/appointments/{appointment_id}/cancel", response_model=AppointmentRead)
async def cancel_appointment(
appointment_id: int,
payload: AppointmentCancel,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_owner(session, appointment_id, current_user)
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be cancelled")
appointment.status = "cancelled_by_customer"
appointment.cancelled_at = datetime.now(UTC)
appointment.cancellation_reason = payload.reason
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.cancelled_by_customer",
title="Клиент отменил запись",
body=payload.reason,
appointment_id=appointment.id,
)
await log_audit(session, actor=current_user, action="appointment.cancel", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/appointments/{appointment_id}/accept-proposed-time", response_model=AppointmentRead)
async def accept_proposed_time(
appointment_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_owner(session, appointment_id, current_user)
if appointment.status != "proposed_new_time" or not appointment.proposed_start_at or not appointment.proposed_end_at:
raise HTTPException(status_code=409, detail="Appointment has no proposed time")
await ensure_slot_available(
session,
service_center_id=appointment.service_center_id,
start_at=appointment.proposed_start_at,
duration_minutes=appointment.estimated_duration_minutes,
exclude_appointment_id=appointment.id,
)
appointment.confirmed_start_at = appointment.proposed_start_at
appointment.confirmed_end_at = appointment.proposed_end_at
appointment.requested_start_at = appointment.proposed_start_at
appointment.requested_end_at = appointment.proposed_end_at
appointment.status = "confirmed"
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.proposed_time_accepted",
title="Клиент принял новое время",
appointment_id=appointment.id,
)
await log_audit(session, actor=current_user, action="appointment.accept_proposed_time", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/appointments/{appointment_id}/reject-proposed-time", response_model=AppointmentRead)
async def reject_proposed_time(
appointment_id: int,
payload: AppointmentDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_owner(session, appointment_id, current_user)
if appointment.status != "proposed_new_time":
raise HTTPException(status_code=409, detail="Appointment has no proposed time")
appointment.status = "rejected"
appointment.service_center_comment = payload.comment
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.proposed_time_rejected",
title="Клиент отклонил новое время",
body=payload.comment,
appointment_id=appointment.id,
)
await log_audit(session, actor=current_user, action="appointment.reject_proposed_time", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.get("/sto/dashboard", response_model=STODashboardRead)
async def get_sto_dashboard(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> STODashboardRead:
await ensure_service_employee(session, service_center_id, current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
month_start = date.today().replace(day=1)
connected_vehicles = int(
(await session.execute(select(func.count(CarServiceLink.id)).where(
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
))).scalar_one() or 0
)
pending_links = int(
(await session.execute(select(func.count(CarServiceLink.id)).where(
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "pending",
))).scalar_one() or 0
)
pending_appointments = int(
(await session.execute(select(func.count(ServiceAppointment.id)).where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.status == "requested",
))).scalar_one() or 0
)
confirmed_appointments = int(
(await session.execute(select(func.count(ServiceAppointment.id)).where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.status == "confirmed",
))).scalar_one() or 0
)
active_work_orders = int(
(await session.execute(select(func.count(ServiceVisit.id)).where(
ServiceVisit.service_center_id == service_center_id,
ServiceVisit.status.in_(["draft", "pending_owner_confirmation"]),
))).scalar_one() or 0
)
completed_result = await session.execute(
select(func.count(ServiceVisit.id), func.coalesce(func.sum(ServiceVisit.total_cost), 0)).where(
ServiceVisit.service_center_id == service_center_id,
ServiceVisit.status == "confirmed",
ServiceVisit.visit_date >= month_start,
)
)
completed_count, revenue_month = completed_result.one()
average_check = money_to_float(revenue_month) / int(completed_count or 1)
warnings = []
settings = await get_booking_settings(session, service_center_id)
if not settings.accepts_online_booking:
warnings.append("online_booking_disabled")
if pending_links:
warnings.append("pending_vehicle_links")
if pending_appointments:
warnings.append("pending_appointments")
if not center.facade_photo_url:
warnings.append("missing_facade_photo")
return STODashboardRead(
service_center_id=service_center_id,
connected_vehicles=connected_vehicles,
pending_vehicle_links=pending_links,
active_appointments=pending_appointments + confirmed_appointments,
pending_appointments=pending_appointments,
confirmed_appointments=confirmed_appointments,
active_work_orders=active_work_orders,
completed_work_orders_month=int(completed_count or 0),
revenue_month=money_to_float(revenue_month),
average_check_month=average_check,
rating_avg=float(center.rating_avg) if center.rating_avg is not None else None,
reviews_count=center.reviews_count,
warnings=warnings,
)
@router.get("/sto/calendar", response_model=list[AppointmentRead])
async def get_sto_calendar(
service_center_id: int,
date_from: datetime | None = None,
date_to: datetime | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceAppointment]:
await ensure_service_employee(session, service_center_id, current_user)
start_at = _utc(date_from or datetime.now(UTC) - timedelta(days=1))
end_at = _utc(date_to or start_at + timedelta(days=30))
stmt = (
select(ServiceAppointment)
.where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.requested_start_at >= start_at,
ServiceAppointment.requested_start_at <= end_at,
)
.order_by(ServiceAppointment.requested_start_at.asc())
)
return list((await session.execute(stmt)).scalars())
@router.get("/sto/appointments", response_model=list[AppointmentRead])
async def get_sto_appointments(
service_center_id: int,
status_filter: str | None = Query(default=None, alias="status"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceAppointment]:
await ensure_service_employee(session, service_center_id, current_user)
stmt = select(ServiceAppointment).where(ServiceAppointment.service_center_id == service_center_id)
if status_filter:
stmt = stmt.where(ServiceAppointment.status == status_filter)
stmt = stmt.order_by(ServiceAppointment.requested_start_at.desc())
return list((await session.execute(stmt)).scalars())
@router.post("/sto/appointments/{appointment_id}/confirm", response_model=AppointmentRead)
async def confirm_appointment(
appointment_id: int,
payload: AppointmentDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
if appointment.status not in {"requested", "proposed_new_time"}:
raise HTTPException(status_code=409, detail="Appointment cannot be confirmed")
await ensure_slot_available(
session,
service_center_id=appointment.service_center_id,
start_at=appointment.requested_start_at,
duration_minutes=appointment.estimated_duration_minutes,
exclude_appointment_id=appointment.id,
)
appointment.status = "confirmed"
appointment.confirmed_start_at = appointment.requested_start_at
appointment.confirmed_end_at = appointment.requested_end_at
appointment.service_center_comment = payload.comment
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=appointment.id,
notification_type="appointment.confirmed",
title="СТО подтвердило запись",
body=f"{appointment.service_name}: {appointment.confirmed_start_at:%Y-%m-%d %H:%M}",
)
await log_audit(session, actor=current_user, action="appointment.confirm", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/sto/appointments/{appointment_id}/reject", response_model=AppointmentRead)
async def reject_appointment(
appointment_id: int,
payload: AppointmentDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be rejected")
appointment.status = "rejected"
appointment.service_center_comment = payload.comment
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=appointment.id,
notification_type="appointment.rejected",
title="СТО отклонило запись",
body=payload.comment,
)
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/sto/appointments/{appointment_id}/propose-time", response_model=AppointmentRead)
async def propose_appointment_time(
appointment_id: int,
payload: AppointmentProposeTime,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be changed")
duration = estimate_duration(appointment.service_type, payload.estimated_duration_minutes or appointment.estimated_duration_minutes)
proposed_start = _utc(payload.proposed_start_at)
proposed_end = await ensure_slot_available(
session,
service_center_id=appointment.service_center_id,
start_at=proposed_start,
duration_minutes=duration,
exclude_appointment_id=appointment.id,
)
appointment.estimated_duration_minutes = duration
appointment.proposed_start_at = proposed_start
appointment.proposed_end_at = proposed_end
appointment.status = "proposed_new_time"
appointment.service_center_comment = payload.comment
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=appointment.id,
notification_type="appointment.proposed_new_time",
title="СТО предложило другое время",
body=f"{proposed_start:%Y-%m-%d %H:%M}",
)
await log_audit(session, actor=current_user, action="appointment.propose_time", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/sto/appointments/{appointment_id}/create-work-order", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_from_appointment(
appointment_id: int,
payload: AppointmentCreateWorkOrder,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
employee = await ensure_service_employee(session, appointment.service_center_id, current_user, {"owner", "manager", "receptionist"})
if appointment.status != "confirmed":
raise HTTPException(status_code=409, detail="Only confirmed appointment can become work order")
if appointment.linked_work_order_id:
visit = await session.get(ServiceVisit, appointment.linked_work_order_id)
if visit is None:
raise HTTPException(status_code=404, detail="Linked work order not found")
return visit
visit = ServiceVisit(
service_center_id=appointment.service_center_id,
vehicle_id=appointment.vehicle_id,
created_by_employee_id=employee.id,
visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(),
odometer=payload.odometer,
status="draft",
notes=payload.notes or appointment.customer_comment,
)
session.add(visit)
await session.flush()
appointment.linked_work_order_id = visit.id
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
await session.commit()
await session.refresh(visit)
return visit
@router.post("/sto/settings/booking", response_model=ServiceCenterBookingSettingsRead)
async def upsert_booking_settings(
payload: ServiceCenterBookingSettingsUpsert,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterBookingSettings:
await ensure_service_employee(session, payload.service_center_id, current_user, {"owner", "manager"})
settings = await get_booking_settings(session, payload.service_center_id)
for field, value in payload.model_dump(exclude={"service_center_id"}).items():
setattr(settings, field, value)
await log_audit(session, actor=current_user, action="sto_booking_settings.update", target_type="service_center", target_id=payload.service_center_id)
await session.commit()
await session.refresh(settings)
return settings
@router.post("/sto/settings/holidays", response_model=ServiceCenterHolidayRead, status_code=status.HTTP_201_CREATED)
async def create_holiday(
payload: ServiceCenterHolidayCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterHoliday:
await ensure_service_employee(session, payload.service_center_id, current_user, {"owner", "manager"})
holiday = ServiceCenterHoliday(**payload.model_dump())
session.add(holiday)
await log_audit(session, actor=current_user, action="sto_holiday.create", target_type="service_center", target_id=payload.service_center_id)
await session.commit()
await session.refresh(holiday)
return holiday
@router.get("/vehicles/{vehicle_id}/maintenance-recommendations", response_model=list[MaintenanceRecommendationRead])
async def get_maintenance_recommendations(
vehicle_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[MaintenanceRecommendation]:
vehicle = await _owned_vehicle(session, vehicle_id, current_user)
await ensure_oil_recommendation(session, vehicle)
await session.commit()
stmt = (
select(MaintenanceRecommendation)
.where(MaintenanceRecommendation.vehicle_id == vehicle_id)
.order_by(MaintenanceRecommendation.status.asc(), MaintenanceRecommendation.due_date.asc().nullslast())
)
return list((await session.execute(stmt)).scalars())
@router.post("/vehicles/{vehicle_id}/maintenance-recommendations", response_model=MaintenanceRecommendationRead, status_code=status.HTTP_201_CREATED)
async def create_maintenance_recommendation(
vehicle_id: int,
payload: MaintenanceRecommendationCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> MaintenanceRecommendation:
await _owned_vehicle(session, vehicle_id, current_user)
recommendation = MaintenanceRecommendation(vehicle_id=vehicle_id, status="active", **payload.model_dump())
session.add(recommendation)
await create_service_notification(
session,
recipient_user_id=current_user.id,
notification_type="maintenance_recommendation.created",
title="Добавлена рекомендация ТО",
body=payload.title,
send_telegram=False,
)
await log_audit(session, actor=current_user, action="maintenance_recommendation.create", target_type="vehicle", target_id=vehicle_id)
await session.commit()
await session.refresh(recommendation)
return recommendation
@router.post("/maintenance-recommendations/{recommendation_id}/dismiss", response_model=MaintenanceRecommendationRead)
async def dismiss_maintenance_recommendation(
recommendation_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> MaintenanceRecommendation:
recommendation = await session.get(MaintenanceRecommendation, recommendation_id)
if recommendation is None:
raise HTTPException(status_code=404, detail="Recommendation not found")
await _owned_vehicle(session, recommendation.vehicle_id, current_user)
recommendation.status = "dismissed"
await log_audit(session, actor=current_user, action="maintenance_recommendation.dismiss", target_type="maintenance_recommendation", target_id=recommendation_id)
await session.commit()
await session.refresh(recommendation)
return recommendation
@router.post("/maintenance-recommendations/{recommendation_id}/book", response_model=MaintenanceRecommendationRead)
async def mark_recommendation_booked(
recommendation_id: int,
payload: MaintenanceRecommendationBook,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> MaintenanceRecommendation:
recommendation = await session.get(MaintenanceRecommendation, recommendation_id)
if recommendation is None:
raise HTTPException(status_code=404, detail="Recommendation not found")
await _owned_vehicle(session, recommendation.vehicle_id, current_user)
appointment = await _appointment_for_owner(session, payload.appointment_id, current_user)
if appointment.vehicle_id != recommendation.vehicle_id:
raise HTTPException(status_code=400, detail="Appointment does not match recommendation vehicle")
recommendation.status = "booked"
recommendation.source_appointment_id = appointment.id
appointment.source_recommendation_id = recommendation.id
await log_audit(session, actor=current_user, action="maintenance_recommendation.book", target_type="maintenance_recommendation", target_id=recommendation_id, metadata={"appointment_id": appointment.id})
await session.commit()
await session.refresh(recommendation)
return recommendation

View File

@@ -14,6 +14,7 @@ from app.api import (
parser,
service_centers,
service_visits,
sto_booking,
users,
)
from app.core.config import settings
@@ -40,6 +41,7 @@ app.include_router(gamification.router, prefix="/api")
app.include_router(ocr.router, prefix="/api")
app.include_router(parser.router, prefix="/api")
app.include_router(service_centers.router, prefix="/api")
app.include_router(sto_booking.router, prefix="/api")
app.include_router(service_visits.router, prefix="/api")
app.include_router(change_requests.router, prefix="/api")
app.include_router(admin.router, prefix="/api")

View File

@@ -1,4 +1,4 @@
from datetime import date, datetime
from datetime import date, datetime, time
from decimal import Decimal
from sqlalchemy import (
@@ -11,6 +11,7 @@ from sqlalchemy import (
Numeric,
String,
Text,
Time,
UniqueConstraint,
func,
)
@@ -163,6 +164,14 @@ class ServiceCenter(Base):
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")
booking_settings = relationship(
"ServiceCenterBookingSettings",
back_populates="service_center",
cascade="all, delete-orphan",
uselist=False,
)
holidays = relationship("ServiceCenterHoliday", back_populates="service_center", cascade="all, delete-orphan")
appointments = relationship("ServiceAppointment", back_populates="service_center", cascade="all, delete-orphan")
class CarServiceLink(Base):
@@ -260,6 +269,44 @@ class ServiceEmployee(Base):
service_center = relationship("ServiceCenter", back_populates="employees")
class ServiceCenterBookingSettings(Base):
__tablename__ = "service_center_booking_settings"
id: Mapped[int] = mapped_column(primary_key=True)
service_center_id: Mapped[int] = mapped_column(
ForeignKey("service_centers.id", ondelete="CASCADE"), unique=True, index=True
)
working_days: Mapped[list] = mapped_column(JSON, default=lambda: [0, 1, 2, 3, 4], server_default="[0,1,2,3,4]")
open_time: Mapped[time] = mapped_column(Time, default=time(9, 0), server_default="09:00:00")
close_time: Mapped[time] = mapped_column(Time, default=time(18, 0), server_default="18:00:00")
lunch_break_start: Mapped[time | None] = mapped_column(Time)
lunch_break_end: Mapped[time | None] = mapped_column(Time)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Seoul", server_default="Asia/Seoul")
slot_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, server_default="30")
booking_buffer_minutes: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
max_parallel_bookings: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
accepts_online_booking: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
service_center = relationship("ServiceCenter", back_populates="booking_settings")
class ServiceCenterHoliday(Base):
__tablename__ = "service_center_holidays"
__table_args__ = (UniqueConstraint("service_center_id", "holiday_date", name="uq_service_center_holiday"),)
id: Mapped[int] = mapped_column(primary_key=True)
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
holiday_date: Mapped[date] = mapped_column(Date, index=True)
reason: Mapped[str | None] = mapped_column(String(240))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
service_center = relationship("ServiceCenter", back_populates="holidays")
class ServiceVisit(Base):
__tablename__ = "service_visits"
@@ -283,6 +330,75 @@ class ServiceVisit(Base):
work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan")
class MaintenanceRecommendation(Base):
__tablename__ = "maintenance_recommendations"
id: Mapped[int] = mapped_column(primary_key=True)
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
recommendation_type: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str] = mapped_column(String(180))
description: Mapped[str | None] = mapped_column(Text)
due_odometer_km: Mapped[int | None] = mapped_column(Integer, index=True)
due_date: Mapped[date | None] = mapped_column(Date, index=True)
priority: Mapped[str] = mapped_column(String(24), default="medium", server_default="medium", index=True)
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
source: Mapped[str] = mapped_column(String(40), default="system_analysis", server_default="system_analysis", index=True)
source_service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
source_appointment_id: Mapped[int | None] = mapped_column(Integer, index=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()
)
class ServiceAppointment(Base):
__tablename__ = "service_appointments"
id: Mapped[int] = mapped_column(primary_key=True)
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
service_type: Mapped[str] = mapped_column(String(64), index=True)
service_name: Mapped[str] = mapped_column(String(180))
requested_start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
requested_end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
confirmed_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
confirmed_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
proposed_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
proposed_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
estimated_duration_minutes: Mapped[int] = mapped_column(Integer, default=60, server_default="60")
status: Mapped[str] = mapped_column(String(40), default="requested", server_default="requested", index=True)
customer_comment: Mapped[str | None] = mapped_column(Text)
service_center_comment: Mapped[str | None] = mapped_column(Text)
source_recommendation_id: Mapped[int | None] = mapped_column(
ForeignKey("maintenance_recommendations.id", ondelete="SET NULL"), index=True
)
linked_work_order_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
cancellation_reason: Mapped[str | None] = mapped_column(Text)
cancelled_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="appointments")
class ServiceNotification(Base):
__tablename__ = "service_notifications"
id: Mapped[int] = mapped_column(primary_key=True)
recipient_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
appointment_id: Mapped[int | None] = mapped_column(ForeignKey("service_appointments.id", ondelete="SET NULL"), index=True)
notification_type: Mapped[str] = mapped_column(String(80), index=True)
title: Mapped[str] = mapped_column(String(180))
body: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class ServiceWorkItem(Base):
__tablename__ = "service_work_items"

187
app/schemas/sto_booking.py Normal file
View File

@@ -0,0 +1,187 @@
from datetime import date, datetime, time
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
APPOINTMENT_STATUSES = {
"draft",
"requested",
"confirmed",
"proposed_new_time",
"rejected",
"cancelled_by_customer",
"cancelled_by_sto",
"completed",
"no_show",
}
class AvailableSlotRead(BaseModel):
start_at: datetime
end_at: datetime
class ServiceCatalogItem(BaseModel):
id: int
display_name: str | None = None
name: str
city: str | None = None
address: str | None = None
specializations: list[str] | None = None
working_hours: str | None = None
rating_avg: float | None = None
reviews_count: int = 0
nearest_slot_at: datetime | None = None
accepts_online_booking: bool = True
class ServiceCenterBookingSettingsUpsert(BaseModel):
service_center_id: int
working_days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4])
open_time: time = time(9, 0)
close_time: time = time(18, 0)
lunch_break_start: time | None = None
lunch_break_end: time | None = None
timezone: str = "Asia/Seoul"
slot_duration_minutes: int = Field(default=30, ge=10, le=240)
booking_buffer_minutes: int = Field(default=0, ge=0, le=240)
max_parallel_bookings: int = Field(default=1, ge=1, le=20)
accepts_online_booking: bool = True
@field_validator("working_days")
@classmethod
def validate_working_days(cls, value: list[int]) -> list[int]:
days = sorted(set(value))
if any(day < 0 or day > 6 for day in days):
raise ValueError("working_days must contain ISO weekdays 0..6")
return days
@model_validator(mode="after")
def validate_times(self) -> "ServiceCenterBookingSettingsUpsert":
if self.open_time >= self.close_time:
raise ValueError("open_time must be before close_time")
if bool(self.lunch_break_start) != bool(self.lunch_break_end):
raise ValueError("both lunch break boundaries are required")
if self.lunch_break_start and self.lunch_break_end and self.lunch_break_start >= self.lunch_break_end:
raise ValueError("lunch_break_start must be before lunch_break_end")
return self
class ServiceCenterBookingSettingsRead(ServiceCenterBookingSettingsUpsert):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ServiceCenterHolidayCreate(BaseModel):
service_center_id: int
holiday_date: date
reason: str | None = None
class ServiceCenterHolidayRead(ServiceCenterHolidayCreate):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AppointmentCreate(BaseModel):
service_center_id: int
vehicle_id: int
service_type: str = Field(default="maintenance", max_length=64)
service_name: str = Field(default="Обслуживание", max_length=180)
requested_start_at: datetime
estimated_duration_minutes: int = Field(default=60, ge=10, le=1440)
customer_comment: str | None = Field(default=None, max_length=4000)
source_recommendation_id: int | None = None
class AppointmentRead(BaseModel):
id: int
service_center_id: int
vehicle_id: int
owner_id: int
created_by: int
service_type: str
service_name: str
requested_start_at: datetime
requested_end_at: datetime
confirmed_start_at: datetime | None = None
confirmed_end_at: datetime | None = None
proposed_start_at: datetime | None = None
proposed_end_at: datetime | None = None
estimated_duration_minutes: int
status: str
customer_comment: str | None = None
service_center_comment: str | None = None
source_recommendation_id: int | None = None
linked_work_order_id: int | None = None
cancellation_reason: str | None = None
cancelled_at: datetime | None = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class AppointmentDecision(BaseModel):
comment: str | None = Field(default=None, max_length=4000)
class AppointmentProposeTime(BaseModel):
proposed_start_at: datetime
estimated_duration_minutes: int | None = Field(default=None, ge=10, le=1440)
comment: str | None = Field(default=None, max_length=4000)
class AppointmentCancel(BaseModel):
reason: str | None = Field(default=None, max_length=1000)
class AppointmentCreateWorkOrder(BaseModel):
odometer: int | None = None
notes: str | None = Field(default=None, max_length=4000)
class MaintenanceRecommendationCreate(BaseModel):
recommendation_type: str = Field(max_length=64)
title: str = Field(max_length=180)
description: str | None = None
due_odometer_km: int | None = None
due_date: date | None = None
priority: str = "medium"
source: str = "user_rule"
class MaintenanceRecommendationRead(MaintenanceRecommendationCreate):
id: int
vehicle_id: int
status: str
source_service_center_id: int | None = None
source_appointment_id: int | None = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class MaintenanceRecommendationBook(BaseModel):
appointment_id: int
class STODashboardRead(BaseModel):
service_center_id: int
connected_vehicles: int
pending_vehicle_links: int
active_appointments: int
pending_appointments: int
confirmed_appointments: int
active_work_orders: int
completed_work_orders_month: int
revenue_month: float
average_check_month: float
rating_avg: float | None = None
reviews_count: int
warnings: list[str]

338
app/services/sto_booking.py Normal file
View File

@@ -0,0 +1,338 @@
from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.car import (
Car,
MaintenanceRecommendation,
ServiceAppointment,
ServiceCenter,
ServiceCenterBookingSettings,
ServiceCenterHoliday,
ServiceEmployee,
ServiceNotification,
ServiceVisit,
ServiceWorkItem,
)
from app.models.expense import ServiceEntry
from app.models.user import User
from app.services.notifications import notify_user
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "proposed_new_time"}
DEFAULT_SERVICE_DURATIONS = {
"oil_change": 60,
"diagnostics": 60,
"repair": 120,
"tire_service": 60,
"brakes": 90,
"fluids": 60,
"maintenance": 90,
"other": 60,
}
def _as_aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def estimate_duration(service_type: str, requested: int | None = None) -> int:
return requested or DEFAULT_SERVICE_DURATIONS.get(service_type, DEFAULT_SERVICE_DURATIONS["other"])
async def get_booking_settings(
session: AsyncSession, service_center_id: int
) -> ServiceCenterBookingSettings:
result = await session.execute(
select(ServiceCenterBookingSettings).where(
ServiceCenterBookingSettings.service_center_id == service_center_id
)
)
settings = result.scalar_one_or_none()
if settings is not None:
return settings
settings = ServiceCenterBookingSettings(service_center_id=service_center_id)
session.add(settings)
await session.flush()
return settings
def _slot_overlaps_lunch(start_time: time, end_time: time, settings: ServiceCenterBookingSettings) -> bool:
if not settings.lunch_break_start or not settings.lunch_break_end:
return False
return start_time < settings.lunch_break_end and end_time > settings.lunch_break_start
async def count_overlapping_appointments(
session: AsyncSession,
*,
service_center_id: int,
start_at: datetime,
end_at: datetime,
exclude_appointment_id: int | None = None,
) -> int:
start_at = _as_aware_utc(start_at)
end_at = _as_aware_utc(end_at)
stmt = select(func.count(ServiceAppointment.id)).where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.status.in_(ACTIVE_APPOINTMENT_STATUSES),
ServiceAppointment.requested_start_at < end_at,
ServiceAppointment.requested_end_at > start_at,
)
if exclude_appointment_id:
stmt = stmt.where(ServiceAppointment.id != exclude_appointment_id)
return int((await session.execute(stmt)).scalar_one() or 0)
async def ensure_slot_available(
session: AsyncSession,
*,
service_center_id: int,
start_at: datetime,
duration_minutes: int,
exclude_appointment_id: int | None = None,
) -> datetime:
start_at = _as_aware_utc(start_at)
now = datetime.now(UTC)
if start_at <= now:
raise HTTPException(status_code=409, detail="Cannot book past time")
end_at = start_at + timedelta(minutes=duration_minutes)
settings = await get_booking_settings(session, service_center_id)
if not settings.accepts_online_booking:
raise HTTPException(status_code=409, detail="Online booking is disabled")
holiday = await session.execute(
select(ServiceCenterHoliday).where(
ServiceCenterHoliday.service_center_id == service_center_id,
ServiceCenterHoliday.holiday_date == start_at.date(),
)
)
if holiday.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Service center is closed on this date")
working_days = settings.working_days or [0, 1, 2, 3, 4]
if start_at.weekday() not in working_days:
raise HTTPException(status_code=409, detail="Service center is closed on this weekday")
if start_at.time() < settings.open_time or end_at.time() > settings.close_time:
raise HTTPException(status_code=409, detail="Slot is outside working hours")
if _slot_overlaps_lunch(start_at.time(), end_at.time(), settings):
raise HTTPException(status_code=409, detail="Slot overlaps lunch break")
overlapping = await count_overlapping_appointments(
session,
service_center_id=service_center_id,
start_at=start_at,
end_at=end_at,
exclude_appointment_id=exclude_appointment_id,
)
if overlapping >= settings.max_parallel_bookings:
raise HTTPException(status_code=409, detail="Slot is already full")
return end_at
async def calculate_available_slots(
session: AsyncSession,
*,
service_center_id: int,
date_from: date,
date_to: date,
duration_minutes: int,
limit: int = 80,
) -> list[tuple[datetime, datetime]]:
settings = await get_booking_settings(session, service_center_id)
holidays = {
item.holiday_date
for item in (
await session.execute(
select(ServiceCenterHoliday).where(
ServiceCenterHoliday.service_center_id == service_center_id,
ServiceCenterHoliday.holiday_date >= date_from,
ServiceCenterHoliday.holiday_date <= date_to,
)
)
).scalars()
}
slots: list[tuple[datetime, datetime]] = []
now = datetime.now(UTC)
current = date_from
step = timedelta(minutes=settings.slot_duration_minutes + settings.booking_buffer_minutes)
working_days = settings.working_days or [0, 1, 2, 3, 4]
while current <= date_to and len(slots) < limit:
if current.weekday() not in working_days or current in holidays:
current += timedelta(days=1)
continue
cursor = datetime.combine(current, settings.open_time, tzinfo=UTC)
day_close = datetime.combine(current, settings.close_time, tzinfo=UTC)
while cursor + timedelta(minutes=duration_minutes) <= day_close and len(slots) < limit:
end_at = cursor + timedelta(minutes=duration_minutes)
if cursor > now and not _slot_overlaps_lunch(cursor.time(), end_at.time(), settings):
overlapping = await count_overlapping_appointments(
session,
service_center_id=service_center_id,
start_at=cursor,
end_at=end_at,
)
if overlapping < settings.max_parallel_bookings:
slots.append((cursor, end_at))
cursor += step
current += timedelta(days=1)
return slots
async def create_service_notification(
session: AsyncSession,
*,
recipient_user_id: int,
notification_type: str,
title: str,
body: str | None = None,
service_center_id: int | None = None,
appointment_id: int | None = None,
send_telegram: bool = True,
) -> ServiceNotification:
notification = ServiceNotification(
recipient_user_id=recipient_user_id,
service_center_id=service_center_id,
appointment_id=appointment_id,
notification_type=notification_type,
title=title,
body=body,
)
session.add(notification)
if send_telegram:
user = await session.get(User, recipient_user_id)
if user is not None:
await notify_user(user, f"{title}\n{body}" if body else title)
return notification
async def notify_service_staff(
session: AsyncSession,
*,
service_center_id: int,
notification_type: str,
title: str,
body: str | None = None,
appointment_id: int | None = None,
) -> None:
center = await session.get(ServiceCenter, service_center_id)
recipient_ids: set[int] = set()
if center and center.owner_user_id:
recipient_ids.add(center.owner_user_id)
employees = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.status == "active",
)
)
recipient_ids.update(employee.user_id for employee in employees.scalars())
for user_id in recipient_ids:
await create_service_notification(
session,
recipient_user_id=user_id,
notification_type=notification_type,
title=title,
body=body,
service_center_id=service_center_id,
appointment_id=appointment_id,
)
def _recommendation_priority(car: Car, due_odometer: int | None, due_date: date | None) -> str:
today = date.today()
current_odometer = car.current_odometer or 0
if due_odometer is not None and current_odometer >= due_odometer:
return "overdue"
if due_date is not None and today >= due_date:
return "overdue"
if due_odometer is not None and due_odometer - current_odometer <= 1000:
return "high"
if due_date is not None and (due_date - today).days <= 14:
return "high"
return "medium"
async def ensure_oil_recommendation(session: AsyncSession, car: Car) -> MaintenanceRecommendation | None:
interval_km = car.oil_change_interval_km or 10_000
interval_months = car.oil_change_interval_months or 12
latest_entry = (
await session.execute(
select(ServiceEntry)
.where(
ServiceEntry.car_id == car.id,
or_(
ServiceEntry.title.ilike("%масл%"),
ServiceEntry.title.ilike("%oil%"),
ServiceEntry.category.ilike("%масл%"),
ServiceEntry.category.ilike("%oil%"),
),
)
.order_by(ServiceEntry.entry_date.desc(), ServiceEntry.id.desc())
)
).scalars().first()
latest_work_item = (
await session.execute(
select(ServiceWorkItem, ServiceVisit)
.join(ServiceVisit, ServiceVisit.id == ServiceWorkItem.service_visit_id)
.where(
ServiceVisit.vehicle_id == car.id,
or_(
ServiceWorkItem.work_type.in_(["oil_change", "fluids"]),
ServiceWorkItem.title.ilike("%масл%"),
ServiceWorkItem.title.ilike("%oil%"),
),
)
.order_by(ServiceVisit.visit_date.desc(), ServiceWorkItem.id.desc())
)
).first()
base_odometer = car.current_odometer or 0
base_date = date.today()
if latest_entry:
base_odometer = latest_entry.odometer or base_odometer
base_date = latest_entry.entry_date or base_date
if latest_work_item:
work_item, visit = latest_work_item
base_odometer = visit.odometer or base_odometer
base_date = visit.visit_date or base_date
if work_item.next_due_odometer:
due_odometer = work_item.next_due_odometer
else:
due_odometer = base_odometer + interval_km
due_date = work_item.next_due_date or (base_date + timedelta(days=interval_months * 30))
else:
due_odometer = base_odometer + interval_km
due_date = base_date + timedelta(days=interval_months * 30)
existing = (
await session.execute(
select(MaintenanceRecommendation).where(
MaintenanceRecommendation.vehicle_id == car.id,
MaintenanceRecommendation.recommendation_type == "oil_change",
MaintenanceRecommendation.status == "active",
)
)
).scalar_one_or_none()
priority = _recommendation_priority(car, due_odometer, due_date)
payload = {
"vehicle_id": car.id,
"recommendation_type": "oil_change",
"title": "Замена моторного масла",
"description": "Плановая рекомендация по интервалу пробега и времени.",
"due_odometer_km": due_odometer,
"due_date": due_date,
"priority": priority,
"source": "mileage_interval",
}
if existing:
for key, value in payload.items():
setattr(existing, key, value)
await session.flush()
return existing
recommendation = MaintenanceRecommendation(**payload)
session.add(recommendation)
await session.flush()
return recommendation
def money_to_float(value: Decimal | None) -> float:
return float(value or 0)