Complete CarPass product flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 21:19:37 +09:00
parent a83f55c646
commit c0014ab4ea
28 changed files with 3006 additions and 159 deletions

View File

@@ -7,16 +7,20 @@ CarPass — цифровой паспорт автомобиля в Telegram. О
- Все автомобили в одном гараже. - Все автомобили в одном гараже.
- Заправки, ТО, ремонт, страховка, налоги, штрафы, парковки, мойки и другие расходы. - Заправки, ТО, ремонт, страховка, налоги, штрафы, парковки, мойки и другие расходы.
- Стоимость владения за период, стоимость 1 км и прогноз ближайших расходов. - Стоимость владения за период, стоимость 1 км и прогноз ближайших расходов.
- Расход топлива по корректным полным бакам. - Разделение расходов на фиксированные и переменные: топливо, ремонт, страховка, налоги, штрафы, кредит и другие категории.
- Кредитный калькулятор: ежемесячный платеж, проценты, переплата и влияние кредита на стоимость владения.
- Расход топлива по корректным интервалам между полными баками и мягкие предупреждения, если запас хода резко снизился.
- Мягкий прогресс заполнения профиля авто: VIN, госномер, пробег, масло, параметры обслуживания. - Мягкий прогресс заполнения профиля авто: VIN, госномер, пробег, масло, параметры обслуживания.
- Бейджи качества истории без игровых очков и токсичных рейтингов. - Бейджи качества истории без игровых очков и токсичных рейтингов.
- Напоминания о ТО, страховке и важных событиях. - Напоминания о ТО, страховке и важных событиях.
- OCR чеков: фото или файл распознается, затем пользователь проверяет данные перед сохранением. - OCR чеков и разбор свободного текста: пользователь проверяет найденные данные перед сохранением.
- История одометра: пробег обновляется из записей, а спорные значения требуют подтверждения.
## Для СТО ## Для СТО
- Регистрация автосервиса через Mini App. - Регистрация автосервиса через Mini App.
- Заявка на проверку и статус модерации. - Заявка на проверку и статус модерации.
- Модерация заявок: approved, rejected, needs changes, suspended.
- Публичная карточка СТО после подтверждения. - Публичная карточка СТО после подтверждения.
- Отзывы, рейтинг и ответы сервиса. - Отзывы, рейтинг и ответы сервиса.
- Запрос доступа к конкретному автомобилю только с подтверждением владельца. - Запрос доступа к конкретному автомобилю только с подтверждением владельца.
@@ -30,6 +34,15 @@ CarPass не раскрывает историю автомобиля по од
Mini App открывается через кнопку внутри Telegram-бота. Так Telegram передает защищенную авторизацию, а гараж привязывается к аккаунту пользователя. Если страницу открыть напрямую в браузере, CarPass покажет понятное приглашение открыть приложение через Telegram. Mini App открывается через кнопку внутри Telegram-бота. Так Telegram передает защищенную авторизацию, а гараж привязывается к аккаунту пользователя. Если страницу открыть напрямую в браузере, CarPass покажет понятное приглашение открыть приложение через Telegram.
## Команды бота
- `/start` и `/menu` — правильный вход в Mini App.
- `/garage` — список автомобилей.
- `/add_car` — быстрое добавление авто.
- `/fuel`, `/service`, `/insurance`, `/tax`, `/fine` — быстрые записи текстом.
- `/analytics` — стоимость владения и расход.
- `/sto`, `/register_sto` — каталог и регистрация СТО.
## Почему это полезно ## Почему это полезно
Для владельца CarPass превращает хаотичные чеки и заметки в понятную картину расходов и обслуживания. Для сервиса это аккуратный канал взаимодействия с клиентом, подтвержденная история работ и доверие без лишнего доступа к персональным данным. Для владельца CarPass превращает хаотичные чеки и заметки в понятную картину расходов и обслуживания. Для сервиса это аккуратный канал взаимодействия с клиентом, подтвержденная история работ и доверие без лишнего доступа к персональным данным.

View File

@@ -0,0 +1,112 @@
"""vehicle finance odometer moderation
Revision ID: 202605140002
Revises: 202605140001
Create Date: 2026-05-14 08:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605140002"
down_revision: str | None = "202605140001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("cars", sa.Column("generation", sa.String(length=120), nullable=True))
op.add_column("cars", sa.Column("body_type", sa.String(length=80), nullable=True))
op.add_column("cars", sa.Column("engine_volume_l", sa.Numeric(5, 2), nullable=True))
op.add_column("cars", sa.Column("transmission", sa.String(length=40), nullable=True))
op.add_column("cars", sa.Column("drive_type", sa.String(length=40), nullable=True))
op.add_column("cars", sa.Column("tire_size", sa.String(length=80), nullable=True))
op.add_column("cars", sa.Column("oil_change_interval_km", sa.Integer(), nullable=True))
op.add_column("cars", sa.Column("oil_change_interval_months", sa.Integer(), nullable=True))
op.add_column("cars", sa.Column("purchase_currency", sa.String(length=3), nullable=True))
op.add_column("cars", sa.Column("purchase_type", sa.String(length=24), server_default="unknown", nullable=False))
op.add_column("cars", sa.Column("expected_ownership_months", sa.Integer(), nullable=True))
op.add_column("cars", sa.Column("expected_residual_value", sa.Numeric(12, 2), nullable=True))
op.add_column("cars", sa.Column("loan_principal", sa.Numeric(12, 2), nullable=True))
op.add_column("cars", sa.Column("loan_down_payment", sa.Numeric(12, 2), nullable=True))
op.add_column("cars", sa.Column("loan_term_months", sa.Integer(), nullable=True))
op.add_column("cars", sa.Column("loan_annual_interest_rate", sa.Numeric(6, 3), nullable=True))
op.add_column("cars", sa.Column("loan_first_payment_date", sa.Date(), nullable=True))
op.add_column("cars", sa.Column("loan_payment_day", sa.Integer(), nullable=True))
op.add_column("cars", sa.Column("loan_payment_type", sa.String(length=24), server_default="annuity", nullable=False))
op.add_column("cars", sa.Column("loan_currency", sa.String(length=3), nullable=True))
op.add_column("cars", sa.Column("loan_comment", sa.Text(), nullable=True))
op.add_column("cars", sa.Column("notes", sa.Text(), nullable=True))
op.alter_column("fuel_entries", "is_full_tank", existing_type=sa.Boolean(), nullable=True)
op.add_column("expense_entries", sa.Column("policy_number", sa.String(length=120), nullable=True))
op.add_column("expense_entries", sa.Column("insurance_type", sa.String(length=40), nullable=True))
op.add_column("expense_entries", sa.Column("payment_period_months", sa.Integer(), nullable=True))
op.add_column("expense_entries", sa.Column("document_urls", sa.JSON(), nullable=True))
op.add_column("expense_entries", sa.Column("metadata_json", sa.JSON(), nullable=True))
op.create_table(
"odometer_history",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("car_id", sa.Integer(), nullable=False),
sa.Column("previous_odometer", sa.Integer(), nullable=True),
sa.Column("new_odometer", sa.Integer(), nullable=False),
sa.Column("source_record_type", sa.String(length=40), nullable=False),
sa.Column("source_record_id", sa.Integer(), nullable=True),
sa.Column("changed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("changed_by", sa.Integer(), nullable=True),
sa.Column("confirmation_required", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("user_confirmed", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["changed_by"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_odometer_history_car_id", "odometer_history", ["car_id"])
op.create_index("ix_odometer_history_changed_at", "odometer_history", ["changed_at"])
op.create_index("ix_odometer_history_changed_by", "odometer_history", ["changed_by"])
op.create_index("ix_odometer_history_source_record_id", "odometer_history", ["source_record_id"])
op.create_index("ix_odometer_history_source_record_type", "odometer_history", ["source_record_type"])
def downgrade() -> None:
op.drop_index("ix_odometer_history_source_record_type", table_name="odometer_history")
op.drop_index("ix_odometer_history_source_record_id", table_name="odometer_history")
op.drop_index("ix_odometer_history_changed_by", table_name="odometer_history")
op.drop_index("ix_odometer_history_changed_at", table_name="odometer_history")
op.drop_index("ix_odometer_history_car_id", table_name="odometer_history")
op.drop_table("odometer_history")
op.drop_column("expense_entries", "metadata_json")
op.drop_column("expense_entries", "document_urls")
op.drop_column("expense_entries", "payment_period_months")
op.drop_column("expense_entries", "insurance_type")
op.drop_column("expense_entries", "policy_number")
op.alter_column("fuel_entries", "is_full_tank", existing_type=sa.Boolean(), nullable=False)
op.drop_column("cars", "notes")
op.drop_column("cars", "loan_comment")
op.drop_column("cars", "loan_currency")
op.drop_column("cars", "loan_payment_type")
op.drop_column("cars", "loan_payment_day")
op.drop_column("cars", "loan_first_payment_date")
op.drop_column("cars", "loan_annual_interest_rate")
op.drop_column("cars", "loan_term_months")
op.drop_column("cars", "loan_down_payment")
op.drop_column("cars", "loan_principal")
op.drop_column("cars", "expected_residual_value")
op.drop_column("cars", "expected_ownership_months")
op.drop_column("cars", "purchase_type")
op.drop_column("cars", "purchase_currency")
op.drop_column("cars", "oil_change_interval_months")
op.drop_column("cars", "oil_change_interval_km")
op.drop_column("cars", "tire_size")
op.drop_column("cars", "drive_type")
op.drop_column("cars", "transmission")
op.drop_column("cars", "engine_volume_l")
op.drop_column("cars", "body_type")
op.drop_column("cars", "generation")

View File

@@ -1,14 +1,22 @@
from datetime import UTC, datetime from datetime import UTC, datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit, require_platform_role from app.api.deps import get_current_telegram_user, log_audit, require_platform_role
from app.core.config import settings
from app.db.session import get_session from app.db.session import get_session
from app.models.car import AuditLog, ServiceCenter, ServiceCenterVerification, ServiceVisit from app.models.car import (
AuditLog,
ServiceCenter,
ServiceCenterVerification,
ServiceEmployee,
ServiceVisit,
)
from app.models.user import User from app.models.user import User
from app.schemas.service_center import ServiceCenterRead, ServiceVisitRead from app.schemas.service_center import AdminModerationDecision, ServiceCenterRead, ServiceVisitRead
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -31,9 +39,23 @@ async def pending_service_centers(
return list(result.scalars()) return list(result.scalars())
@router.get("/service-centers/{service_center_id}", response_model=ServiceCenterRead)
async def admin_service_center_detail(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
return center
@router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead) @router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead)
async def verify_service_center( async def verify_service_center(
service_center_id: int, service_center_id: int,
payload: AdminModerationDecision | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter: ) -> ServiceCenter:
@@ -43,8 +65,21 @@ async def verify_service_center(
raise HTTPException(status_code=404, detail="Service center not found") raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "approved" center.verification_status = "approved"
center.verified_at = datetime.now(UTC) center.verified_at = datetime.now(UTC)
await mark_latest_verification(session, center.id, "approved", current_user.id) if center.owner_user_id:
await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id) owner = await session.get(User, center.owner_user_id)
if owner:
owner.platform_role = "service_owner"
await ensure_owner_employee(session, center.id, owner.id)
await notify_user(owner, f"Заявка СТО «{center.display_name or center.name}» одобрена. Панель СТО доступна в CarPass.")
await mark_latest_verification(session, center.id, "approved", current_user.id, payload)
await log_audit(
session,
actor=current_user,
action="service_center.verify",
target_type="service_center",
target_id=center.id,
metadata={"comment": payload.comment if payload else None},
)
await session.commit() await session.commit()
await session.refresh(center) await session.refresh(center)
return center return center
@@ -53,6 +88,7 @@ async def verify_service_center(
@router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead) @router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead)
async def reject_service_center( async def reject_service_center(
service_center_id: int, service_center_id: int,
payload: AdminModerationDecision | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter: ) -> ServiceCenter:
@@ -61,8 +97,20 @@ async def reject_service_center(
if center is None: if center is None:
raise HTTPException(status_code=404, detail="Service center not found") raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "rejected" center.verification_status = "rejected"
await mark_latest_verification(session, center.id, "rejected", current_user.id) if center.owner_user_id:
await log_audit(session, actor=current_user, action="service_center.reject", target_type="service_center", target_id=center.id) owner = await session.get(User, center.owner_user_id)
if owner:
reason = payload.reason or payload.comment if payload else None
await notify_user(owner, f"Заявка СТО «{center.display_name or center.name}» отклонена.{f' Причина: {reason}' if reason else ''}")
await mark_latest_verification(session, center.id, "rejected", current_user.id, payload)
await log_audit(
session,
actor=current_user,
action="service_center.reject",
target_type="service_center",
target_id=center.id,
metadata={"reason": payload.reason if payload else None, "comment": payload.comment if payload else None},
)
await session.commit() await session.commit()
await session.refresh(center) await session.refresh(center)
return center return center
@@ -71,6 +119,7 @@ async def reject_service_center(
@router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead) @router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead)
async def suspend_service_center( async def suspend_service_center(
service_center_id: int, service_center_id: int,
payload: AdminModerationDecision | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter: ) -> ServiceCenter:
@@ -80,7 +129,50 @@ async def suspend_service_center(
raise HTTPException(status_code=404, detail="Service center not found") raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "suspended" center.verification_status = "suspended"
center.suspended_at = datetime.now(UTC) center.suspended_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_center.suspend", target_type="service_center", target_id=center.id) if center.owner_user_id:
owner = await session.get(User, center.owner_user_id)
if owner:
reason = payload.reason or payload.comment if payload else None
await notify_user(owner, f"СТО «{center.display_name or center.name}» временно заблокировано.{f' Причина: {reason}' if reason else ''}")
await log_audit(
session,
actor=current_user,
action="service_center.suspend",
target_type="service_center",
target_id=center.id,
metadata={"reason": payload.reason if payload else None, "comment": payload.comment if payload else None},
)
await session.commit()
await session.refresh(center)
return center
@router.post("/service-centers/{service_center_id}/request-changes", response_model=ServiceCenterRead)
async def request_service_center_changes(
service_center_id: int,
payload: AdminModerationDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "needs_changes"
if center.owner_user_id:
owner = await session.get(User, center.owner_user_id)
if owner:
reason = payload.reason or payload.comment or "Администратор попросил уточнить данные заявки."
await notify_user(owner, f"По заявке СТО «{center.display_name or center.name}» нужны правки: {reason}")
await mark_latest_verification(session, center.id, "needs_changes", current_user.id, payload)
await log_audit(
session,
actor=current_user,
action="service_center.request_changes",
target_type="service_center",
target_id=center.id,
metadata={"reason": payload.reason, "comment": payload.comment},
)
await session.commit() await session.commit()
await session.refresh(center) await session.refresh(center)
return center return center
@@ -126,7 +218,11 @@ async def disputes(
async def mark_latest_verification( async def mark_latest_verification(
session: AsyncSession, service_center_id: int, status: str, reviewed_by: int session: AsyncSession,
service_center_id: int,
status: str,
reviewed_by: int,
payload: AdminModerationDecision | None = None,
) -> None: ) -> None:
result = await session.execute( result = await session.execute(
select(ServiceCenterVerification) select(ServiceCenterVerification)
@@ -139,3 +235,42 @@ async def mark_latest_verification(
verification.status = status verification.status = status
verification.reviewed_by = reviewed_by verification.reviewed_by = reviewed_by
verification.reviewed_at = datetime.now(UTC) verification.reviewed_at = datetime.now(UTC)
if payload and (payload.reason or payload.comment):
verification.comment = "\n".join(
item for item in [payload.reason, payload.comment] if item
)
async def ensure_owner_employee(session: AsyncSession, service_center_id: int, owner_user_id: int) -> None:
result = await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.service_center_id == service_center_id,
ServiceEmployee.user_id == owner_user_id,
)
)
employee = result.scalar_one_or_none()
if employee is None:
session.add(
ServiceEmployee(
service_center_id=service_center_id,
user_id=owner_user_id,
role="owner",
status="active",
)
)
else:
employee.role = "owner"
employee.status = "active"
async def notify_user(user: User, text: str) -> None:
if not settings.bot_token:
return
try:
async with httpx.AsyncClient(timeout=5) as client:
await client.post(
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
data={"chat_id": str(user.telegram_id), "text": text},
)
except Exception:
return

View File

@@ -4,9 +4,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user from app.api.deps import get_current_telegram_user
from app.db.session import get_session from app.db.session import get_session
from app.models.car import Car from app.models.car import Car, VehicleAccess
from app.models.user import User from app.models.user import User
from app.schemas.car import CarCreate, CarRead, CarUpdate from app.schemas.car import CarCreate, CarRead, CarUpdate
from app.services.odometer import add_odometer_history, validate_odometer_change
from app.services.vehicle_identity import normalize_license_plate, validate_vin from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(prefix="/cars", tags=["cars"]) router = APIRouter(prefix="/cars", tags=["cars"])
@@ -30,6 +31,17 @@ async def create_car(
data = apply_identity_fields(payload.model_dump(exclude={"owner_id"})) data = apply_identity_fields(payload.model_dump(exclude={"owner_id"}))
car = Car(**data, owner_id=current_user.id) car = Car(**data, owner_id=current_user.id)
session.add(car) session.add(car)
await session.flush()
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
if car.current_odometer is not None:
add_odometer_history(
session,
car,
new_odometer=car.current_odometer,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
)
await session.commit() await session.commit()
await session.refresh(car) await session.refresh(car)
return car return car
@@ -75,8 +87,23 @@ async def update_car(
raise HTTPException(status_code=404, detail="Car not found") raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id: if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
for field, value in apply_identity_fields(payload.model_dump(exclude_unset=True)).items(): raw = apply_identity_fields(payload.model_dump(exclude_unset=True))
odometer_value = raw.pop("current_odometer", None) if "current_odometer" in raw else None
if odometer_value is not None:
validate_odometer_change(car, odometer_value, source_record_type="manual", confirm_lower_odometer=True)
for field, value in raw.items():
setattr(car, field, value) setattr(car, field, value)
if odometer_value is not None and odometer_value != car.current_odometer:
add_odometer_history(
session,
car,
new_odometer=odometer_value,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
confirmation_required=car.current_odometer is not None and odometer_value < car.current_odometer,
user_confirmed=True,
)
await session.commit() await session.commit()
await session.refresh(car) await session.refresh(car)
return car return car

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user from app.api.deps import get_current_telegram_user
from app.db.session import get_session from app.db.session import get_session
from app.models.car import Car from app.models.car import Car, OdometerHistory
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
from app.models.user import User from app.models.user import User
from app.schemas.expense import ( from app.schemas.expense import (
@@ -18,6 +18,7 @@ from app.schemas.expense import (
FuelEntryCreate, FuelEntryCreate,
FuelEntryRead, FuelEntryRead,
FuelEntryUpdate, FuelEntryUpdate,
OdometerHistoryRead,
OdometerPrediction, OdometerPrediction,
OwnershipStats, OwnershipStats,
ServiceEntryCreate, ServiceEntryCreate,
@@ -25,6 +26,11 @@ from app.schemas.expense import (
ServiceEntryUpdate, ServiceEntryUpdate,
) )
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
from app.services.odometer import (
apply_odometer_from_record,
recalculate_current_odometer,
validate_odometer_change,
)
router = APIRouter(tags=["entries"]) router = APIRouter(tags=["entries"])
@@ -47,40 +53,6 @@ async def ensure_entry_owner(
return entry return entry
async def refresh_current_odometer(session: AsyncSession, car_id: int) -> None:
car = await session.get(Car, car_id)
if car is None:
return
fuel_result = await session.execute(
select(FuelEntry.odometer)
.where(FuelEntry.car_id == car_id)
.order_by(FuelEntry.odometer.desc())
.limit(1)
)
service_result = await session.execute(
select(ServiceEntry.odometer)
.where(ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None))
.order_by(ServiceEntry.odometer.desc())
.limit(1)
)
expense_result = await session.execute(
select(ExpenseEntry.odometer)
.where(ExpenseEntry.car_id == car_id, ExpenseEntry.odometer.is_not(None))
.order_by(ExpenseEntry.odometer.desc())
.limit(1)
)
values = [
value
for value in (
fuel_result.scalar_one_or_none(),
service_result.scalar_one_or_none(),
expense_result.scalar_one_or_none(),
)
if value is not None
]
car.current_odometer = max(values) if values else None
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED) @router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
async def create_fuel_entry( async def create_fuel_entry(
payload: FuelEntryCreate, payload: FuelEntryCreate,
@@ -88,10 +60,24 @@ async def create_fuel_entry(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry: ) -> FuelEntry:
car = await ensure_owned_car(session, payload.car_id, current_user) car = await ensure_owned_car(session, payload.car_id, current_user)
entry = FuelEntry(**payload.model_dump()) validate_odometer_change(
car,
payload.odometer,
source_record_type="fuel",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
entry = FuelEntry(**payload.model_dump(exclude={"confirm_lower_odometer"}))
session.add(entry) session.add(entry)
if car.current_odometer is None or payload.odometer > car.current_odometer: await session.flush()
car.current_odometer = payload.odometer await apply_odometer_from_record(
session,
car,
new_odometer=payload.odometer,
source_record_type="fuel",
source_record_id=entry.id,
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -129,13 +115,21 @@ async def update_fuel_entry(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry: ) -> FuelEntry:
entry = await ensure_entry_owner(session, await session.get(FuelEntry, entry_id), current_user) entry = await ensure_entry_owner(session, await session.get(FuelEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items(): car = await session.get(Car, entry.car_id)
if car is not None and payload.odometer is not None:
validate_odometer_change(
car,
payload.odometer,
source_record_type="fuel",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
for field, value in payload.model_dump(exclude_unset=True, exclude={"confirm_lower_odometer"}).items():
setattr(entry, field, value) setattr(entry, field, value)
if payload.total_cost is None and ( if payload.total_cost is None and (
payload.liters is not None or payload.price_per_liter is not None payload.liters is not None or payload.price_per_liter is not None
): ):
entry.total_cost = entry.liters * entry.price_per_liter entry.total_cost = entry.liters * entry.price_per_liter
await refresh_current_odometer(session, entry.car_id) await recalculate_current_odometer(session, entry.car_id, changed_by=current_user.id, source_record_type="fuel_update")
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -151,7 +145,7 @@ async def delete_fuel_entry(
car_id = entry.car_id car_id = entry.car_id
await session.delete(entry) await session.delete(entry)
await session.flush() await session.flush()
await refresh_current_odometer(session, car_id) await recalculate_current_odometer(session, car_id, changed_by=current_user.id, source_record_type="fuel_delete")
await session.commit() await session.commit()
@@ -162,10 +156,24 @@ async def create_service_entry(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry: ) -> ServiceEntry:
car = await ensure_owned_car(session, payload.car_id, current_user) car = await ensure_owned_car(session, payload.car_id, current_user)
entry = ServiceEntry(**payload.model_dump()) validate_odometer_change(
car,
payload.odometer,
source_record_type="service",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
entry = ServiceEntry(**payload.model_dump(exclude={"confirm_lower_odometer"}))
session.add(entry) session.add(entry)
if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer): await session.flush()
car.current_odometer = payload.odometer await apply_odometer_from_record(
session,
car,
new_odometer=payload.odometer,
source_record_type="service",
source_record_id=entry.id,
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -203,9 +211,17 @@ async def update_service_entry(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry: ) -> ServiceEntry:
entry = await ensure_entry_owner(session, await session.get(ServiceEntry, entry_id), current_user) entry = await ensure_entry_owner(session, await session.get(ServiceEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items(): car = await session.get(Car, entry.car_id)
if car is not None and payload.odometer is not None:
validate_odometer_change(
car,
payload.odometer,
source_record_type="service",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
for field, value in payload.model_dump(exclude_unset=True, exclude={"confirm_lower_odometer"}).items():
setattr(entry, field, value) setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id) await recalculate_current_odometer(session, entry.car_id, changed_by=current_user.id, source_record_type="service_update")
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -221,7 +237,7 @@ async def delete_service_entry(
car_id = entry.car_id car_id = entry.car_id
await session.delete(entry) await session.delete(entry)
await session.flush() await session.flush()
await refresh_current_odometer(session, car_id) await recalculate_current_odometer(session, car_id, changed_by=current_user.id, source_record_type="service_delete")
await session.commit() await session.commit()
@@ -232,10 +248,24 @@ async def create_expense_entry(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ExpenseEntry: ) -> ExpenseEntry:
car = await ensure_owned_car(session, payload.car_id, current_user) car = await ensure_owned_car(session, payload.car_id, current_user)
entry = ExpenseEntry(**payload.model_dump()) validate_odometer_change(
car,
payload.odometer,
source_record_type="expense",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
entry = ExpenseEntry(**payload.model_dump(exclude={"confirm_lower_odometer"}))
session.add(entry) session.add(entry)
if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer): await session.flush()
car.current_odometer = payload.odometer await apply_odometer_from_record(
session,
car,
new_odometer=payload.odometer,
source_record_type="expense",
source_record_id=entry.id,
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -276,9 +306,17 @@ async def update_expense_entry(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ExpenseEntry: ) -> ExpenseEntry:
entry = await ensure_entry_owner(session, await session.get(ExpenseEntry, entry_id), current_user) entry = await ensure_entry_owner(session, await session.get(ExpenseEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items(): car = await session.get(Car, entry.car_id)
if car is not None and payload.odometer is not None:
validate_odometer_change(
car,
payload.odometer,
source_record_type="expense",
confirm_lower_odometer=payload.confirm_lower_odometer,
)
for field, value in payload.model_dump(exclude_unset=True, exclude={"confirm_lower_odometer"}).items():
setattr(entry, field, value) setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id) await recalculate_current_odometer(session, entry.car_id, changed_by=current_user.id, source_record_type="expense_update")
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -294,10 +332,30 @@ async def delete_expense_entry(
car_id = entry.car_id car_id = entry.car_id
await session.delete(entry) await session.delete(entry)
await session.flush() await session.flush()
await refresh_current_odometer(session, car_id) await recalculate_current_odometer(session, car_id, changed_by=current_user.id, source_record_type="expense_delete")
await session.commit() await session.commit()
@router.get("/cars/{car_id}/odometer-history", response_model=list[OdometerHistoryRead])
async def odometer_history(
car_id: int,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[OdometerHistory]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
result = await session.execute(
select(OdometerHistory)
.where(OdometerHistory.car_id == car_id)
.order_by(OdometerHistory.changed_at.desc(), OdometerHistory.id.desc())
.limit(limit)
.offset(max(offset, 0))
)
return list(result.scalars())
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats) @router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
async def car_stats( async def car_stats(
car_id: int, car_id: int,

View File

@@ -1,11 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit from app.api.deps import get_current_telegram_user, log_audit
from app.db.session import get_session from app.db.session import get_session
from app.models.car import Car, ServiceVisit, VehicleAccess from app.models.car import (
Car,
CarServiceLink,
ServiceCenter,
ServiceVisit,
VehicleAccess,
VehicleDataChangeRequest,
)
from app.models.user import User from app.models.user import User
from app.schemas.service_center import ( from app.schemas.service_center import (
VehicleAccessGrant, VehicleAccessGrant,
@@ -15,6 +22,7 @@ from app.schemas.service_center import (
VehicleUpdate, VehicleUpdate,
) )
from app.schemas.user import UserRead from app.schemas.user import UserRead
from app.services.odometer import add_odometer_history, validate_odometer_change
from app.services.vehicle_identity import normalize_license_plate, validate_vin from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(tags=["my"]) router = APIRouter(tags=["my"])
@@ -53,8 +61,13 @@ async def my_vehicles(
) -> list[Car]: ) -> list[Car]:
result = await session.execute( result = await session.execute(
select(Car) select(Car)
.join(VehicleAccess, VehicleAccess.vehicle_id == Car.id) .outerjoin(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
.where(VehicleAccess.user_id == current_user.id, VehicleAccess.status == "active") .where(
or_(
Car.owner_id == current_user.id,
(VehicleAccess.user_id == current_user.id) & (VehicleAccess.status == "active"),
)
)
.order_by(Car.created_at.desc()) .order_by(Car.created_at.desc())
) )
return list(result.scalars()) return list(result.scalars())
@@ -70,6 +83,15 @@ async def create_vehicle(
session.add(car) session.add(car)
await session.flush() await session.flush()
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active")) session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
if car.current_odometer is not None:
add_odometer_history(
session,
car,
new_odometer=car.current_odometer,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
)
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id) await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
await session.commit() await session.commit()
await session.refresh(car) await session.refresh(car)
@@ -88,8 +110,23 @@ async def update_vehicle(
raise HTTPException(status_code=404, detail="Vehicle not found") raise HTTPException(status_code=404, detail="Vehicle not found")
if car.owner_id != current_user.id: if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
for field, value in vehicle_data(payload, partial=True).items(): raw = vehicle_data(payload, partial=True)
odometer_value = raw.pop("current_odometer", None) if "current_odometer" in raw else None
if odometer_value is not None:
validate_odometer_change(car, odometer_value, source_record_type="manual", confirm_lower_odometer=True)
for field, value in raw.items():
setattr(car, field, value) setattr(car, field, value)
if odometer_value is not None and odometer_value != car.current_odometer:
add_odometer_history(
session,
car,
new_odometer=odometer_value,
source_record_type="manual",
source_record_id=None,
changed_by=current_user.id,
confirmation_required=car.current_odometer is not None and odometer_value < car.current_odometer,
user_confirmed=True,
)
await log_audit(session, actor=current_user, action="vehicle.update", target_type="vehicle", target_id=car.id) await log_audit(session, actor=current_user, action="vehicle.update", target_type="vehicle", target_id=car.id)
await session.commit() await session.commit()
await session.refresh(car) await session.refresh(car)
@@ -116,6 +153,85 @@ async def vehicle_service_history(
return {"vehicle_id": vehicle_id, "service_visits": jsonable_encoder(visits)} return {"vehicle_id": vehicle_id, "service_visits": jsonable_encoder(visits)}
@router.get("/my/confirmations")
async def my_confirmations(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> dict:
owner_cars = select(Car.id).where(Car.owner_id == current_user.id)
visits = list(
(
await session.execute(
select(ServiceVisit)
.where(
ServiceVisit.vehicle_id.in_(owner_cars),
ServiceVisit.status == "pending_owner_confirmation",
)
.order_by(ServiceVisit.updated_at.desc(), ServiceVisit.id.desc())
)
).scalars()
)
change_requests = list(
(
await session.execute(
select(VehicleDataChangeRequest)
.where(
VehicleDataChangeRequest.owner_user_id == current_user.id,
VehicleDataChangeRequest.status == "pending",
)
.order_by(VehicleDataChangeRequest.created_at.desc())
)
).scalars()
)
links = list(
(
await session.execute(
select(CarServiceLink)
.where(
CarServiceLink.car_id.in_(owner_cars),
CarServiceLink.status == "pending",
CarServiceLink.is_active.is_(False),
)
.order_by(CarServiceLink.created_at.desc())
)
).scalars()
)
return {
"service_visits": jsonable_encoder(visits),
"change_requests": jsonable_encoder(change_requests),
"service_links": jsonable_encoder(links),
}
@router.get("/my/service-links")
async def my_service_links(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[dict]:
result = await session.execute(
select(CarServiceLink, Car, ServiceCenter)
.join(Car, Car.id == CarServiceLink.car_id)
.join(ServiceCenter, ServiceCenter.id == CarServiceLink.service_center_id)
.where(Car.owner_id == current_user.id)
.order_by(CarServiceLink.created_at.desc())
)
return [
{
"id": link.id,
"status": link.status,
"access_level": link.access_level,
"car_id": car.id,
"car_name": car.name,
"service_center_id": center.id,
"service_center_name": center.display_name or center.name,
"created_at": link.created_at,
"approved_at": link.approved_at,
"revoked_at": link.revoked_at,
}
for link, car, center in result.all()
]
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead) @router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
async def grant_vehicle_access( async def grant_vehicle_access(
vehicle_id: int, vehicle_id: int,

55
app/api/parser.py Normal file
View File

@@ -0,0 +1,55 @@
from decimal import Decimal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.deps import get_current_telegram_user
from app.models.user import User
from app.services.loans import generate_annuity_schedule, loan_summary
from app.services.record_parser import ParsedRecord, parse_record_text
router = APIRouter(tags=["parser"])
class ParseRecordRequest(BaseModel):
text: str = Field(min_length=1, max_length=4000)
class LoanCalculateRequest(BaseModel):
principal: Decimal = Field(gt=0)
term_months: int = Field(gt=0, le=600)
annual_interest_rate: Decimal = Field(ge=0)
@router.post("/parse/record", response_model=ParsedRecord)
async def parse_record(
payload: ParseRecordRequest,
current_user: User = Depends(get_current_telegram_user),
) -> ParsedRecord:
return parse_record_text(payload.text)
@router.post("/loans/calculate")
async def calculate_loan(
payload: LoanCalculateRequest,
current_user: User = Depends(get_current_telegram_user),
) -> dict:
summary = loan_summary(payload.principal, payload.term_months, payload.annual_interest_rate)
schedule = generate_annuity_schedule(
principal=payload.principal,
months=payload.term_months,
annual_rate=payload.annual_interest_rate,
)
return {
**summary,
"schedule": [
{
"number": row.number,
"payment": row.payment,
"principal": row.principal,
"interest": row.interest,
"remaining_principal": row.remaining_principal,
}
for row in schedule
],
}

View File

@@ -46,6 +46,7 @@ from app.schemas.service_center import (
VehicleSearchRequest, VehicleSearchRequest,
VehicleSearchResult, VehicleSearchResult,
) )
from app.services.odometer import validate_odometer_change
from app.services.vehicle_identity import mask_license_plate, mask_vin from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"]) router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@@ -81,6 +82,18 @@ async def create_service_center(
) )
session.add(center) session.add(center)
await session.flush() await session.flush()
session.add(
ServiceCenterVerification(
service_center_id=center.id,
submitted_documents=[
{"type": "registration", "urls": payload.document_photo_urls or []},
{"type": "facade", "url": payload.facade_photo_url},
{"type": "additional", "urls": payload.additional_photo_urls or []},
],
comment="Initial service center application",
status="pending",
)
)
employee = ServiceEmployee( employee = ServiceEmployee(
service_center_id=center.id, service_center_id=center.id,
user_id=current_user.id, user_id=current_user.id,
@@ -94,6 +107,44 @@ async def create_service_center(
return center return center
@router.patch("/{service_center_id}", response_model=ServiceCenterRead)
async def update_service_center_application(
service_center_id: int,
payload: ServiceCenterCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
data = payload.model_dump(exclude_unset=True)
for field, value in data.items():
if field == "display_name":
center.display_name = value
center.name = value
elif hasattr(center, field):
setattr(center, field, value)
if center.verification_status in {"draft", "needs_changes", "rejected"}:
center.verification_status = "pending"
session.add(
ServiceCenterVerification(
service_center_id=center.id,
submitted_documents=[
{"type": "registration", "urls": center.document_photo_urls or []},
{"type": "facade", "url": center.facade_photo_url},
{"type": "additional", "urls": center.additional_photo_urls or []},
],
comment="Resubmitted service center application",
status="pending",
)
)
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.get("/my", response_model=list[ServiceCenterRead]) @router.get("/my", response_model=list[ServiceCenterRead])
async def my_service_centers( async def my_service_centers(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -287,6 +338,7 @@ async def create_visit(
vehicle = await session.get(Car, payload.vehicle_id) vehicle = await session.get(Car, payload.vehicle_id)
if vehicle is None: if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found") raise HTTPException(status_code=404, detail="Vehicle not found")
validate_odometer_change(vehicle, payload.odometer, source_record_type="service_visit")
await ensure_service_center_approved(session, service_center_id) await ensure_service_center_approved(session, service_center_id)
await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user) await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user)
visit = ServiceVisit( visit = ServiceVisit(

View File

@@ -14,6 +14,7 @@ from app.schemas.service_center import (
VehicleDataChangeRequestCreate, VehicleDataChangeRequestCreate,
VehicleDataChangeRequestRead, VehicleDataChangeRequestRead,
) )
from app.services.odometer import apply_odometer_from_record
from app.services.vehicle_identity import normalize_license_plate, validate_vin from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(prefix="/service-visits", tags=["service-visits"]) router = APIRouter(prefix="/service-visits", tags=["service-visits"])
@@ -83,8 +84,14 @@ async def confirm_visit(
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "confirmed" visit.status = "confirmed"
visit.owner_resolved_at = datetime.now(UTC) visit.owner_resolved_at = datetime.now(UTC)
if visit.odometer and (vehicle.current_odometer is None or visit.odometer > vehicle.current_odometer): await apply_odometer_from_record(
vehicle.current_odometer = visit.odometer session,
vehicle,
new_odometer=visit.odometer,
source_record_type="service_visit",
source_record_id=visit.id,
changed_by=current_user.id,
)
await log_audit(session, actor=current_user, action="service_visit.confirm", target_type="service_visit", target_id=visit_id) await log_audit(session, actor=current_user, action="service_visit.confirm", target_type="service_visit", target_id=visit_id)
await session.commit() await session.commit()
await session.refresh(visit) await session.refresh(visit)

View File

@@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass """Shared SQLAlchemy declarative metadata."""

View File

@@ -11,6 +11,7 @@ from app.api import (
gamification, gamification,
my, my,
ocr, ocr,
parser,
service_centers, service_centers,
service_visits, service_visits,
users, users,
@@ -37,6 +38,7 @@ app.include_router(cars.router, prefix="/api")
app.include_router(entries.router, prefix="/api") app.include_router(entries.router, prefix="/api")
app.include_router(gamification.router, prefix="/api") app.include_router(gamification.router, prefix="/api")
app.include_router(ocr.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(service_centers.router, prefix="/api")
app.include_router(service_visits.router, prefix="/api") app.include_router(service_visits.router, prefix="/api")
app.include_router(change_requests.router, prefix="/api") app.include_router(change_requests.router, prefix="/api")

View File

@@ -28,6 +28,8 @@ class Car(Base):
make: Mapped[str | None] = mapped_column(String(80)) make: Mapped[str | None] = mapped_column(String(80))
model: Mapped[str | None] = mapped_column(String(80)) model: Mapped[str | None] = mapped_column(String(80))
trim: Mapped[str | None] = mapped_column(String(120)) trim: Mapped[str | None] = mapped_column(String(120))
generation: Mapped[str | None] = mapped_column(String(120))
body_type: Mapped[str | None] = mapped_column(String(80))
year: Mapped[int | None] year: Mapped[int | None]
plate_number: Mapped[str | None] = mapped_column(String(32)) plate_number: Mapped[str | None] = mapped_column(String(32))
vin: Mapped[str | None] = mapped_column(String(32)) vin: Mapped[str | None] = mapped_column(String(32))
@@ -36,6 +38,9 @@ class Car(Base):
license_plate_country: Mapped[str | None] = mapped_column(String(2), index=True) license_plate_country: Mapped[str | None] = mapped_column(String(2), index=True)
vin_normalized: Mapped[str | None] = mapped_column(String(17), unique=True, index=True) vin_normalized: Mapped[str | None] = mapped_column(String(17), unique=True, index=True)
fuel_type: Mapped[str | None] = mapped_column(String(32)) fuel_type: Mapped[str | None] = mapped_column(String(32))
engine_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
transmission: Mapped[str | None] = mapped_column(String(40))
drive_type: Mapped[str | None] = mapped_column(String(40))
target_consumption_l_per_100km: Mapped[Decimal | None] = mapped_column(Numeric(6, 2)) target_consumption_l_per_100km: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2)) fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
engine_oil_type: Mapped[str | None] = mapped_column(String(80)) engine_oil_type: Mapped[str | None] = mapped_column(String(80))
@@ -46,11 +51,28 @@ class Car(Base):
brake_fluid_type: Mapped[str | None] = mapped_column(String(80)) brake_fluid_type: Mapped[str | None] = mapped_column(String(80))
tire_pressure_front_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2)) tire_pressure_front_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2)) tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
tire_size: Mapped[str | None] = mapped_column(String(80))
oil_change_interval_km: Mapped[int | None] = mapped_column(Integer)
oil_change_interval_months: Mapped[int | None] = mapped_column(Integer)
purchase_date: Mapped[date | None] = mapped_column(Date) purchase_date: Mapped[date | None] = mapped_column(Date)
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
purchase_currency: Mapped[str | None] = mapped_column(String(3))
purchase_type: Mapped[str] = mapped_column(String(24), default="unknown", server_default="unknown")
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB") currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
include_depreciation: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") include_depreciation: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
expected_ownership_months: Mapped[int | None] = mapped_column(Integer)
expected_residual_value: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
loan_principal: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
loan_down_payment: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
loan_term_months: Mapped[int | None] = mapped_column(Integer)
loan_annual_interest_rate: Mapped[Decimal | None] = mapped_column(Numeric(6, 3))
loan_first_payment_date: Mapped[date | None] = mapped_column(Date)
loan_payment_day: Mapped[int | None] = mapped_column(Integer)
loan_payment_type: Mapped[str] = mapped_column(String(24), default="annuity", server_default="annuity")
loan_currency: Mapped[str | None] = mapped_column(String(3))
loan_comment: Mapped[str | None] = mapped_column(Text)
current_odometer: Mapped[int | None] current_odometer: Mapped[int | None]
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
@@ -61,6 +83,7 @@ class Car(Base):
service_entries = relationship("ServiceEntry", 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") expense_entries = relationship("ExpenseEntry", back_populates="car", cascade="all, delete-orphan")
service_links = relationship("CarServiceLink", back_populates="car", cascade="all, delete-orphan") service_links = relationship("CarServiceLink", back_populates="car", cascade="all, delete-orphan")
odometer_history = relationship("OdometerHistory", back_populates="car", cascade="all, delete-orphan")
class CarMake(Base): class CarMake(Base):
@@ -163,6 +186,23 @@ class CarServiceLink(Base):
service_center = relationship("ServiceCenter", back_populates="car_links") service_center = relationship("ServiceCenter", back_populates="car_links")
class OdometerHistory(Base):
__tablename__ = "odometer_history"
id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
previous_odometer: Mapped[int | None] = mapped_column(Integer)
new_odometer: Mapped[int] = mapped_column(Integer)
source_record_type: Mapped[str] = mapped_column(String(40), index=True)
source_record_id: Mapped[int | None] = mapped_column(Integer, index=True)
changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
changed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
confirmation_required: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
user_confirmed: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
car = relationship("Car", back_populates="odometer_history")
class ServiceInboxMessage(Base): class ServiceInboxMessage(Base):
__tablename__ = "service_inbox_messages" __tablename__ = "service_inbox_messages"

View File

@@ -2,7 +2,7 @@ import enum
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func from sqlalchemy import JSON, Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base
@@ -54,7 +54,7 @@ class FuelEntry(Base):
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2)) total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2))
station: Mapped[str | None] = mapped_column(String(160)) station: Mapped[str | None] = mapped_column(String(160))
fuel_brand: Mapped[str | None] = mapped_column(String(80)) fuel_brand: Mapped[str | None] = mapped_column(String(80))
is_full_tank: Mapped[bool] = mapped_column(default=True) is_full_tank: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=None)
notes: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -97,6 +97,11 @@ class ExpenseEntry(Base):
period_end: Mapped[date | None] = mapped_column(Date) period_end: Mapped[date | None] = mapped_column(Date)
period_months: Mapped[int | None] period_months: Mapped[int | None]
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
policy_number: Mapped[str | None] = mapped_column(String(120))
insurance_type: Mapped[str | None] = mapped_column(String(40))
payment_period_months: Mapped[int | None] = mapped_column()
document_urls: Mapped[list | None] = mapped_column(JSON)
metadata_json: Mapped[dict | None] = mapped_column(JSON)
notes: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,7 +1,9 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_validator
from app.services.vehicle_identity import validate_vin
class CarBase(BaseModel): class CarBase(BaseModel):
@@ -9,10 +11,15 @@ class CarBase(BaseModel):
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
trim: str | None = None trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None year: int | None = None
plate_number: str | None = None plate_number: str | None = None
vin: str | None = None vin: str | None = None
fuel_type: str | None = None fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
target_consumption_l_per_100km: Decimal | None = None target_consumption_l_per_100km: Decimal | None = None
fuel_tank_volume_l: Decimal | None = None fuel_tank_volume_l: Decimal | None = None
engine_oil_type: str | None = None engine_oil_type: str | None = None
@@ -23,11 +30,33 @@ class CarBase(BaseModel):
brake_fluid_type: str | None = None brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
purchase_date: date | None = None purchase_date: date | None = None
purchase_price: Decimal | None = None purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str = "unknown"
currency: str = "RUB" currency: str = "RUB"
include_depreciation: bool = False include_depreciation: bool = False
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str = "annuity"
loan_currency: str | None = None
loan_comment: str | None = None
current_odometer: int | None = None current_odometer: int | None = None
notes: str | None = None
@field_validator("vin")
@classmethod
def validate_vin_field(cls, value: str | None) -> str | None:
return validate_vin(value)
class CarCreate(CarBase): class CarCreate(CarBase):
@@ -39,10 +68,15 @@ class CarUpdate(BaseModel):
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
trim: str | None = None trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None year: int | None = None
plate_number: str | None = None plate_number: str | None = None
vin: str | None = None vin: str | None = None
fuel_type: str | None = None fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
target_consumption_l_per_100km: Decimal | None = None target_consumption_l_per_100km: Decimal | None = None
fuel_tank_volume_l: Decimal | None = None fuel_tank_volume_l: Decimal | None = None
engine_oil_type: str | None = None engine_oil_type: str | None = None
@@ -53,11 +87,33 @@ class CarUpdate(BaseModel):
brake_fluid_type: str | None = None brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
purchase_date: date | None = None purchase_date: date | None = None
purchase_price: Decimal | None = None purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str | None = None
currency: str | None = None currency: str | None = None
include_depreciation: bool | None = None include_depreciation: bool | None = None
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str | None = None
loan_currency: str | None = None
loan_comment: str | None = None
current_odometer: int | None = None current_odometer: int | None = None
notes: str | None = None
@field_validator("vin")
@classmethod
def validate_vin_field(cls, value: str | None) -> str | None:
return validate_vin(value)
class CarRead(CarBase): class CarRead(CarBase):

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.models.expense import ExpenseCategory, ServiceType from app.models.expense import ExpenseCategory, ServiceType
@@ -14,18 +14,27 @@ class FuelEntryBase(BaseModel):
total_cost: Decimal | None = None total_cost: Decimal | None = None
station: str | None = None station: str | None = None
fuel_brand: str | None = None fuel_brand: str | None = None
is_full_tank: bool = True is_full_tank: bool | None = None
notes: str | None = None notes: str | None = None
@model_validator(mode="after") @model_validator(mode="after")
def fill_total_cost(self) -> "FuelEntryBase": def fill_total_cost(self) -> "FuelEntryBase":
if self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.liters <= 0:
raise ValueError("liters must be positive")
if self.price_per_liter <= 0:
raise ValueError("price_per_liter must be positive")
if self.total_cost is None: if self.total_cost is None:
self.total_cost = self.liters * self.price_per_liter self.total_cost = self.liters * self.price_per_liter
if self.total_cost <= 0:
raise ValueError("total_cost must be positive")
return self return self
class FuelEntryCreate(FuelEntryBase): class FuelEntryCreate(FuelEntryBase):
car_id: int car_id: int
confirm_lower_odometer: bool = False
class FuelEntryUpdate(BaseModel): class FuelEntryUpdate(BaseModel):
@@ -38,6 +47,7 @@ class FuelEntryUpdate(BaseModel):
fuel_brand: str | None = None fuel_brand: str | None = None
is_full_tank: bool | None = None is_full_tank: bool | None = None
notes: str | None = None notes: str | None = None
confirm_lower_odometer: bool = False
class FuelEntryRead(FuelEntryBase): class FuelEntryRead(FuelEntryBase):
@@ -61,9 +71,20 @@ class ServiceEntryBase(BaseModel):
next_due_odometer: int | None = None next_due_odometer: int | None = None
notes: str | None = None notes: str | None = None
@model_validator(mode="after")
def validate_service(self) -> "ServiceEntryBase":
if self.odometer is not None and self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.total_cost < 0:
raise ValueError("total_cost must be non-negative")
if not self.title.strip():
raise ValueError("title is required")
return self
class ServiceEntryCreate(ServiceEntryBase): class ServiceEntryCreate(ServiceEntryBase):
car_id: int car_id: int
confirm_lower_odometer: bool = False
class ServiceEntryUpdate(BaseModel): class ServiceEntryUpdate(BaseModel):
@@ -77,6 +98,7 @@ class ServiceEntryUpdate(BaseModel):
next_due_date: date | None = None next_due_date: date | None = None
next_due_odometer: int | None = None next_due_odometer: int | None = None
notes: str | None = None notes: str | None = None
confirm_lower_odometer: bool = False
class ServiceEntryRead(ServiceEntryBase): class ServiceEntryRead(ServiceEntryBase):
@@ -99,19 +121,36 @@ class ExpenseEntryBase(BaseModel):
period_end: date | None = None period_end: date | None = None
period_months: int | None = None period_months: int | None = None
is_recurring: bool = False is_recurring: bool = False
policy_number: str | None = None
insurance_type: str | None = None
payment_period_months: int | None = None
document_urls: list[str] | None = None
metadata_json: dict | None = None
notes: str | None = None notes: str | None = None
@model_validator(mode="after") @model_validator(mode="after")
def validate_period(self) -> "ExpenseEntryBase": def validate_period(self) -> "ExpenseEntryBase":
if self.total_cost <= 0:
raise ValueError("total_cost must be positive")
if self.odometer is not None and self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.period_months is not None and self.period_months < 1: if self.period_months is not None and self.period_months < 1:
raise ValueError("period_months must be positive") raise ValueError("period_months must be positive")
if self.payment_period_months is not None and self.payment_period_months < 1:
raise ValueError("payment_period_months must be positive")
if self.period_start and self.period_end and self.period_end < self.period_start: if self.period_start and self.period_end and self.period_end < self.period_start:
raise ValueError("period_end must be after period_start") raise ValueError("period_end must be after period_start")
if self.category == ExpenseCategory.insurance:
if self.period_start and self.period_end:
return self
if self.period_months or self.payment_period_months:
return self
return self return self
class ExpenseEntryCreate(ExpenseEntryBase): class ExpenseEntryCreate(ExpenseEntryBase):
car_id: int car_id: int
confirm_lower_odometer: bool = False
class ExpenseEntryUpdate(BaseModel): class ExpenseEntryUpdate(BaseModel):
@@ -126,7 +165,20 @@ class ExpenseEntryUpdate(BaseModel):
period_end: date | None = None period_end: date | None = None
period_months: int | None = None period_months: int | None = None
is_recurring: bool | None = None is_recurring: bool | None = None
policy_number: str | None = None
insurance_type: str | None = None
payment_period_months: int | None = None
document_urls: list[str] | None = None
metadata_json: dict | None = None
notes: str | None = None notes: str | None = None
confirm_lower_odometer: bool = False
@field_validator("total_cost")
@classmethod
def validate_total_cost(cls, value: Decimal | None) -> Decimal | None:
if value is not None and value <= 0:
raise ValueError("total_cost must be positive")
return value
class ExpenseEntryRead(ExpenseEntryBase): class ExpenseEntryRead(ExpenseEntryBase):
@@ -151,11 +203,23 @@ class OwnershipStats(BaseModel):
service_cost: Decimal service_cost: Decimal
total_cost: Decimal total_cost: Decimal
expenses_cost: Decimal = Decimal("0") expenses_cost: Decimal = Decimal("0")
repair_cost: Decimal = Decimal("0")
fixed_costs: Decimal = Decimal("0")
variable_costs: Decimal = Decimal("0")
recurring_costs: Decimal = Decimal("0") recurring_costs: Decimal = Decimal("0")
one_time_costs: Decimal = Decimal("0") one_time_costs: Decimal = Decimal("0")
forecast_next_month: Decimal = Decimal("0") forecast_next_month: Decimal = Decimal("0")
depreciation_cost: Decimal = Decimal("0") depreciation_cost: Decimal = Decimal("0")
loan_principal_cost: Decimal = Decimal("0")
loan_interest_cost: Decimal = Decimal("0")
total_cost_without_credit: Decimal = Decimal("0")
total_cost_with_credit: Decimal = Decimal("0")
cost_per_day: Decimal = Decimal("0")
cost_per_month: Decimal = Decimal("0") cost_per_month: Decimal = Decimal("0")
current_month_cost: Decimal = Decimal("0")
previous_month_cost: Decimal = Decimal("0")
month_over_month_change_pct: float | None = None
cost_warning: str | None = None
cost_by_category: dict[str, Decimal] = Field(default_factory=dict) cost_by_category: dict[str, Decimal] = Field(default_factory=dict)
categories: list[OwnershipCategoryBreakdown] = Field(default_factory=list) categories: list[OwnershipCategoryBreakdown] = Field(default_factory=list)
liters: Decimal liters: Decimal
@@ -179,5 +243,25 @@ class OdometerPrediction(BaseModel):
avg_price_per_liter: float | None = None avg_price_per_liter: float | None = None
price_samples: int = 0 price_samples: int = 0
price_confidence: float = 0 price_confidence: float = 0
average_full_tank_distance: float | None = None
average_fuel_consumption_full_tank: float | None = None
average_cost_per_full_tank: float | None = None
last_full_tank_distance: int | None = None
full_tank_warning: str | None = None
confidence: float confidence: float
insight: str insight: str
class OdometerHistoryRead(BaseModel):
id: int
car_id: int
previous_odometer: int | None = None
new_odometer: int
source_record_type: str
source_record_id: int | None = None
changed_at: datetime
changed_by: int | None = None
confirmation_required: bool
user_confirmed: bool
model_config = ConfigDict(from_attributes=True)

View File

@@ -114,20 +114,49 @@ class VehicleCreate(BaseModel):
name: str name: str
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None year: int | None = None
license_plate: str | None = None license_plate: str | None = None
license_plate_country: str | None = None license_plate_country: str | None = None
vin: str | None = None vin: str | None = None
current_odometer: int | None = None current_odometer: int | None = None
fuel_type: str | None = None fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
engine_oil_type: str | None = None engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
fuel_tank_volume_l: Decimal | None = None fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None purchase_date: date | None = None
purchase_price: Decimal | None = None purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str = "unknown"
currency: str = "RUB" currency: str = "RUB"
include_depreciation: bool = False include_depreciation: bool = False
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str = "annuity"
loan_currency: str | None = None
loan_comment: str | None = None
notes: str | None = None
@field_validator("vin") @field_validator("vin")
@classmethod @classmethod
@@ -139,20 +168,49 @@ class VehicleUpdate(BaseModel):
name: str | None = None name: str | None = None
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None year: int | None = None
license_plate: str | None = None license_plate: str | None = None
license_plate_country: str | None = None license_plate_country: str | None = None
vin: str | None = None vin: str | None = None
current_odometer: int | None = None current_odometer: int | None = None
fuel_type: str | None = None fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
fuel_tank_volume_l: Decimal | None = None fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None purchase_date: date | None = None
purchase_price: Decimal | None = None purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str | None = None
currency: str | None = None currency: str | None = None
include_depreciation: bool | None = None include_depreciation: bool | None = None
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
engine_oil_type: str | None = None engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str | None = None
loan_currency: str | None = None
loan_comment: str | None = None
notes: str | None = None
@field_validator("vin") @field_validator("vin")
@classmethod @classmethod
@@ -166,20 +224,49 @@ class VehicleRead(BaseModel):
name: str name: str
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
trim: str | None = None
generation: str | None = None
body_type: str | None = None
year: int | None = None year: int | None = None
license_plate_display: str | None = None license_plate_display: str | None = None
license_plate_country: str | None = None license_plate_country: str | None = None
vin_normalized: str | None = None vin_normalized: str | None = None
current_odometer: int | None = None current_odometer: int | None = None
fuel_type: str | None = None fuel_type: str | None = None
engine_volume_l: Decimal | None = None
transmission: str | None = None
drive_type: str | None = None
fuel_tank_volume_l: Decimal | None = None fuel_tank_volume_l: Decimal | None = None
target_consumption_l_per_100km: Decimal | None = None target_consumption_l_per_100km: Decimal | None = None
purchase_date: date | None = None purchase_date: date | None = None
purchase_price: Decimal | None = None purchase_price: Decimal | None = None
purchase_currency: str | None = None
purchase_type: str = "unknown"
currency: str = "RUB" currency: str = "RUB"
include_depreciation: bool = False include_depreciation: bool = False
expected_ownership_months: int | None = None
expected_residual_value: Decimal | None = None
engine_oil_type: str | None = None engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
tire_size: str | None = None
oil_change_interval_km: int | None = None
oil_change_interval_months: int | None = None
loan_principal: Decimal | None = None
loan_down_payment: Decimal | None = None
loan_term_months: int | None = None
loan_annual_interest_rate: Decimal | None = None
loan_first_payment_date: date | None = None
loan_payment_day: int | None = None
loan_payment_type: str = "annuity"
loan_currency: str | None = None
loan_comment: str | None = None
notes: str | None = None
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -358,4 +445,9 @@ class ServiceInboxRead(ServiceInboxCreate):
error: str | None = None error: str | None = None
created_at: datetime created_at: datetime
class AdminModerationDecision(BaseModel):
comment: str | None = None
reason: str | None = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -3,12 +3,38 @@ from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
import pandas as pd import pandas as pd
from sqlalchemy import Select, func, or_, select from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.car import Car from app.models.car import Car
from app.models.expense import ExpenseCategory, ExpenseEntry, FuelEntry, ServiceEntry from app.models.expense import ExpenseCategory, ExpenseEntry, FuelEntry, ServiceEntry
from app.schemas.expense import OdometerPrediction, OwnershipStats from app.schemas.expense import OdometerPrediction, OwnershipStats
from app.services.loans import generate_annuity_schedule
FIXED_EXPENSE_CATEGORIES = {
ExpenseCategory.insurance,
ExpenseCategory.tax,
ExpenseCategory.loan_payment,
ExpenseCategory.loan_interest,
ExpenseCategory.parking,
}
VARIABLE_EXPENSE_CATEGORIES = {
ExpenseCategory.fine,
ExpenseCategory.car_wash,
ExpenseCategory.toll,
ExpenseCategory.tires,
ExpenseCategory.wheels,
ExpenseCategory.battery,
ExpenseCategory.parts,
ExpenseCategory.repair,
ExpenseCategory.maintenance,
ExpenseCategory.diagnostics,
ExpenseCategory.towing,
ExpenseCategory.state_fee,
ExpenseCategory.registration,
ExpenseCategory.inspection,
ExpenseCategory.other,
}
async def get_ownership_stats( async def get_ownership_stats(
@@ -60,21 +86,39 @@ async def get_ownership_stats(
odometer_values = [value for value in odometer_values if value is not None] 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 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( (
expense_cost,
recurring_cost,
_expense_count,
expense_categories,
fixed_expense_cost,
variable_expense_cost,
) = await expense_period_totals(
session, car_id, date_from, date_to session, car_id, date_from, date_to
) )
car = await session.get(Car, car_id) car = await session.get(Car, car_id)
depreciation_cost = calculate_depreciation(car, date_from, date_to) if car else Decimal("0") depreciation_cost = calculate_depreciation(car, date_from, date_to) if car else Decimal("0")
loan_principal_cost, loan_interest_cost = calculate_loan_costs(car, date_from, date_to) if car else (Decimal("0"), Decimal("0"))
total_cost = Decimal(fuel_cost) + Decimal(service_cost) + expense_cost + depreciation_cost total_cost = Decimal(fuel_cost) + Decimal(service_cost) + expense_cost + depreciation_cost + loan_principal_cost + loan_interest_cost
avg_consumption = await full_tank_consumption(session, car_id, date_from, date_to) tank_metrics = await full_tank_metrics(session, car_id, date_from, date_to)
avg_consumption = tank_metrics["average_fuel_consumption_full_tank"]
cost_per_km = float(total_cost / distance_km) if distance_km else None 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")) months = max(Decimal(period_days(date_from, date_to)) / Decimal("30.4375"), Decimal("0.033"))
cost_per_day = (total_cost / Decimal(period_days(date_from, date_to))).quantize(Decimal("0.01"))
cost_per_month = (total_cost / months).quantize(Decimal("0.01")) cost_per_month = (total_cost / months).quantize(Decimal("0.01"))
recurring_total = (recurring_cost + depreciation_cost).quantize(Decimal("0.01")) recurring_total = (recurring_cost + depreciation_cost + loan_principal_cost + loan_interest_cost).quantize(Decimal("0.01"))
one_time_costs = max(total_cost - recurring_total, Decimal("0")).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")) recurring_monthly = (recurring_total / months).quantize(Decimal("0.01"))
forecast_next_month = max(cost_per_month, recurring_monthly).quantize(Decimal("0.01")) forecast_next_month = max(cost_per_month, recurring_monthly).quantize(Decimal("0.01"))
repair_cost = (
Decimal(service_cost)
+ expense_categories.get("repair", Decimal("0"))
+ expense_categories.get("maintenance", Decimal("0"))
+ expense_categories.get("diagnostics", Decimal("0"))
).quantize(Decimal("0.01"))
fixed_costs = (fixed_expense_cost + depreciation_cost + loan_principal_cost + loan_interest_cost).quantize(Decimal("0.01"))
variable_costs = (Decimal(fuel_cost) + Decimal(service_cost) + variable_expense_cost).quantize(Decimal("0.01"))
cost_by_category = { cost_by_category = {
"fuel": Decimal(fuel_cost), "fuel": Decimal(fuel_cost),
@@ -83,11 +127,22 @@ async def get_ownership_stats(
} }
if depreciation_cost: if depreciation_cost:
cost_by_category["depreciation"] = depreciation_cost cost_by_category["depreciation"] = depreciation_cost
if loan_principal_cost:
cost_by_category["loan_payment"] = cost_by_category.get("loan_payment", Decimal("0")) + loan_principal_cost
if loan_interest_cost:
cost_by_category["loan_interest"] = cost_by_category.get("loan_interest", Decimal("0")) + loan_interest_cost
categories = [ categories = [
{"category": key, "total_cost": value, "entries_count": 0} {"category": key, "total_cost": value, "entries_count": 0}
for key, value in sorted(cost_by_category.items()) for key, value in sorted(cost_by_category.items())
if value if value
] ]
current_month_cost, previous_month_cost = await month_comparison_totals(session, car_id, date_to)
month_change = None
cost_warning = None
if previous_month_cost > 0:
month_change = float((current_month_cost - previous_month_cost) * Decimal("100") / previous_month_cost)
if month_change >= 35:
cost_warning = "Расходы заметно выше прошлого месяца. Проверьте крупные ремонты, штрафы или регулярные платежи."
return OwnershipStats( return OwnershipStats(
car_id=car_id, car_id=car_id,
@@ -97,11 +152,23 @@ async def get_ownership_stats(
service_cost=service_cost, service_cost=service_cost,
expenses_cost=expense_cost, expenses_cost=expense_cost,
total_cost=total_cost, total_cost=total_cost,
repair_cost=repair_cost,
fixed_costs=fixed_costs,
variable_costs=variable_costs,
recurring_costs=recurring_total, recurring_costs=recurring_total,
one_time_costs=one_time_costs, one_time_costs=one_time_costs,
forecast_next_month=forecast_next_month, forecast_next_month=forecast_next_month,
depreciation_cost=depreciation_cost, depreciation_cost=depreciation_cost,
loan_principal_cost=loan_principal_cost,
loan_interest_cost=loan_interest_cost,
total_cost_without_credit=(total_cost - loan_principal_cost - loan_interest_cost).quantize(Decimal("0.01")),
total_cost_with_credit=total_cost.quantize(Decimal("0.01")),
cost_per_day=cost_per_day,
cost_per_month=cost_per_month, cost_per_month=cost_per_month,
current_month_cost=current_month_cost,
previous_month_cost=previous_month_cost,
month_over_month_change_pct=round(month_change, 2) if month_change is not None else None,
cost_warning=cost_warning,
cost_by_category=cost_by_category, cost_by_category=cost_by_category,
categories=categories, categories=categories,
liters=liters, liters=liters,
@@ -144,6 +211,9 @@ def expense_window(entry: ExpenseEntry) -> tuple[date, date]:
def allocated_expense_cost(entry: ExpenseEntry, date_from: date, date_to: date) -> Decimal: def allocated_expense_cost(entry: ExpenseEntry, date_from: date, date_to: date) -> Decimal:
monthly_period = entry.payment_period_months or entry.period_months or inferred_monthly_period(entry)
if monthly_period and (entry.period_start or entry.entry_date):
return allocated_monthly_expense_cost(entry, date_from, date_to, monthly_period)
start, end = expense_window(entry) start, end = expense_window(entry)
total_days = period_days(start, end) total_days = period_days(start, end)
matched_days = overlap_days(start, end, date_from, date_to) matched_days = overlap_days(start, end, date_from, date_to)
@@ -154,24 +224,49 @@ def allocated_expense_cost(entry: ExpenseEntry, date_from: date, date_to: date)
return (Decimal(entry.total_cost) * Decimal(matched_days) / Decimal(total_days)).quantize(Decimal("0.01")) return (Decimal(entry.total_cost) * Decimal(matched_days) / Decimal(total_days)).quantize(Decimal("0.01"))
def inferred_monthly_period(entry: ExpenseEntry) -> int | None:
if entry.category != ExpenseCategory.insurance or not entry.period_start or not entry.period_end:
return None
for months in (1, 3, 6, 12):
if add_months(entry.period_start, months) - timedelta(days=1) == entry.period_end:
return months
return None
def allocated_monthly_expense_cost(
entry: ExpenseEntry, date_from: date, date_to: date, months: int
) -> Decimal:
start = entry.period_start or entry.entry_date
if months <= 0:
return Decimal("0")
monthly_cost = Decimal(entry.total_cost) / Decimal(months)
total = Decimal("0")
for month_index in range(months):
month_start = add_months(start, month_index)
month_end = add_months(start, month_index + 1) - timedelta(days=1)
matched = overlap_days(month_start, month_end, date_from, date_to)
if matched <= 0:
continue
total_days = period_days(month_start, month_end)
total += monthly_cost * Decimal(matched) / Decimal(total_days)
return total.quantize(Decimal("0.01"))
async def expense_period_totals( async def expense_period_totals(
session: AsyncSession, car_id: int, date_from: date, date_to: date session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> tuple[Decimal, Decimal, int, dict[str, Decimal]]: ) -> tuple[Decimal, Decimal, int, dict[str, Decimal], Decimal, Decimal]:
result = await session.execute( result = await session.execute(
select(ExpenseEntry) select(ExpenseEntry)
.where( .where(
ExpenseEntry.car_id == car_id, ExpenseEntry.car_id == car_id,
or_( ExpenseEntry.entry_date <= date_to,
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()) .order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
) )
total = Decimal("0") total = Decimal("0")
recurring = Decimal("0") recurring = Decimal("0")
fixed = Decimal("0")
variable = Decimal("0")
categories: dict[str, Decimal] = {} categories: dict[str, Decimal] = {}
count = 0 count = 0
for entry in result.scalars(): for entry in result.scalars():
@@ -182,26 +277,104 @@ async def expense_period_totals(
total += amount total += amount
category = entry.category.value if isinstance(entry.category, ExpenseCategory) else str(entry.category) category = entry.category.value if isinstance(entry.category, ExpenseCategory) else str(entry.category)
categories[category] = categories.get(category, Decimal("0")) + amount categories[category] = categories.get(category, Decimal("0")) + amount
if entry.is_recurring or entry.category in {ExpenseCategory.insurance, ExpenseCategory.loan_payment, ExpenseCategory.loan_interest}: if entry.is_recurring or entry.category in FIXED_EXPENSE_CATEGORIES:
recurring += amount recurring += amount
return total.quantize(Decimal("0.01")), recurring.quantize(Decimal("0.01")), count, categories if entry.category in FIXED_EXPENSE_CATEGORIES or entry.is_recurring:
fixed += amount
else:
variable += amount
return (
total.quantize(Decimal("0.01")),
recurring.quantize(Decimal("0.01")),
count,
categories,
fixed.quantize(Decimal("0.01")),
variable.quantize(Decimal("0.01")),
)
def calculate_depreciation(car: Car, date_from: date, date_to: date) -> Decimal: 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: if not car.include_depreciation or not car.purchase_price or not car.purchase_date:
return Decimal("0") return Decimal("0")
depreciation_start = car.purchase_date depreciation_start = car.purchase_date
depreciation_end = add_months(car.purchase_date, 60) - timedelta(days=1) months = car.expected_ownership_months or 60
residual = Decimal(car.expected_residual_value or 0)
depreciable = max(Decimal(car.purchase_price) - residual, Decimal("0"))
depreciation_end = add_months(car.purchase_date, months) - timedelta(days=1)
matched_days = overlap_days(depreciation_start, depreciation_end, date_from, date_to) matched_days = overlap_days(depreciation_start, depreciation_end, date_from, date_to)
if matched_days <= 0: if matched_days <= 0:
return Decimal("0") return Decimal("0")
daily_cost = Decimal(car.purchase_price) / Decimal(period_days(depreciation_start, depreciation_end)) daily_cost = depreciable / Decimal(period_days(depreciation_start, depreciation_end))
return (daily_cost * Decimal(matched_days)).quantize(Decimal("0.01")) return (daily_cost * Decimal(matched_days)).quantize(Decimal("0.01"))
def calculate_loan_costs(car: Car, date_from: date, date_to: date) -> tuple[Decimal, Decimal]:
if not car.loan_principal or not car.loan_term_months:
return Decimal("0"), Decimal("0")
first_payment = car.loan_first_payment_date or car.purchase_date
if not first_payment:
return Decimal("0"), Decimal("0")
annual_rate = Decimal(car.loan_annual_interest_rate or 0)
schedule = generate_annuity_schedule(
principal=Decimal(car.loan_principal),
months=car.loan_term_months,
annual_rate=annual_rate,
first_payment_date=first_payment,
)
principal = Decimal("0")
interest = Decimal("0")
for row in schedule:
if row.payment_date and date_from <= row.payment_date <= date_to:
principal += row.principal
interest += row.interest
return principal.quantize(Decimal("0.01")), interest.quantize(Decimal("0.01"))
async def raw_period_total(session: AsyncSession, car_id: int, date_from: date, date_to: date) -> Decimal:
fuel = (
await session.execute(
select(func.coalesce(func.sum(FuelEntry.total_cost), 0)).where(
FuelEntry.car_id == car_id,
FuelEntry.entry_date >= date_from,
FuelEntry.entry_date <= date_to,
)
)
).scalar_one()
service = (
await session.execute(
select(func.coalesce(func.sum(ServiceEntry.total_cost), 0)).where(
ServiceEntry.car_id == car_id,
ServiceEntry.entry_date >= date_from,
ServiceEntry.entry_date <= date_to,
)
)
).scalar_one()
expenses, _, _, _, _, _ = await expense_period_totals(session, car_id, date_from, date_to)
car = await session.get(Car, car_id)
depreciation = calculate_depreciation(car, date_from, date_to) if car else Decimal("0")
loan_principal, loan_interest = calculate_loan_costs(car, date_from, date_to) if car else (Decimal("0"), Decimal("0"))
return (Decimal(fuel) + Decimal(service) + expenses + depreciation + loan_principal + loan_interest).quantize(Decimal("0.01"))
async def month_comparison_totals(session: AsyncSession, car_id: int, today: date) -> tuple[Decimal, Decimal]:
current_from = today.replace(day=1)
previous_to = current_from - timedelta(days=1)
previous_from = previous_to.replace(day=1)
return (
await raw_period_total(session, car_id, current_from, today),
await raw_period_total(session, car_id, previous_from, previous_to),
)
async def full_tank_consumption( async def full_tank_consumption(
session: AsyncSession, car_id: int, date_from: date, date_to: date session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> float | None: ) -> float | None:
return (await full_tank_metrics(session, car_id, date_from, date_to))["average_fuel_consumption_full_tank"]
async def full_tank_metrics(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> dict[str, float | int | str | None]:
result = await session.execute( result = await session.execute(
select(FuelEntry) select(FuelEntry)
.where( .where(
@@ -213,10 +386,15 @@ async def full_tank_consumption(
entries = list(result.scalars()) entries = list(result.scalars())
full_indexes = [index for index, entry in enumerate(entries) if entry.is_full_tank] full_indexes = [index for index, entry in enumerate(entries) if entry.is_full_tank]
if len(full_indexes) < 2: if len(full_indexes) < 2:
return None return {
"average_full_tank_distance": None,
"average_fuel_consumption_full_tank": None,
"average_cost_per_full_tank": None,
"last_full_tank_distance": None,
"full_tank_warning": None,
}
total_liters = Decimal("0") intervals: list[dict] = []
total_distance = 0
previous_full_index = full_indexes[0] previous_full_index = full_indexes[0]
for current_full_index in full_indexes[1:]: for current_full_index in full_indexes[1:]:
previous = entries[previous_full_index] previous = entries[previous_full_index]
@@ -232,13 +410,45 @@ async def full_tank_consumption(
Decimal(entry.liters) for entry in entries[previous_full_index + 1 : current_full_index + 1] Decimal(entry.liters) for entry in entries[previous_full_index + 1 : current_full_index + 1]
) )
if interval_liters > 0: if interval_liters > 0:
total_liters += interval_liters interval_cost = sum(
total_distance += distance Decimal(entry.total_cost) for entry in entries[previous_full_index + 1 : current_full_index + 1]
)
intervals.append({"distance": distance, "liters": interval_liters, "cost": interval_cost})
previous_full_index = current_full_index previous_full_index = current_full_index
if total_distance <= 0 or total_liters <= 0: if not intervals:
return None return {
return float(total_liters * Decimal(100) / Decimal(total_distance)) "average_full_tank_distance": None,
"average_fuel_consumption_full_tank": None,
"average_cost_per_full_tank": None,
"last_full_tank_distance": None,
"full_tank_warning": None,
}
total_distance = sum(item["distance"] for item in intervals)
total_liters = sum((item["liters"] for item in intervals), Decimal("0"))
total_cost = sum((item["cost"] for item in intervals), Decimal("0"))
avg_distance = float(Decimal(total_distance) / Decimal(len(intervals)))
avg_consumption = float(total_liters * Decimal(100) / Decimal(total_distance))
avg_cost = float(total_cost / Decimal(len(intervals)))
last_distance = int(intervals[-1]["distance"])
warning = None
previous = intervals[:-1]
if previous:
previous_avg = float(Decimal(sum(item["distance"] for item in previous)) / Decimal(len(previous)))
if previous_avg > 0 and last_distance < previous_avg * 0.75:
drop = round((1 - last_distance / previous_avg) * 100)
warning = (
f"Обычно на полном баке получается около {previous_avg:.0f} км. "
f"Последний интервал {last_distance} км, это на {drop}% меньше. "
"Проверьте режим поездок, давление шин, качество топлива или техническое состояние."
)
return {
"average_full_tank_distance": round(avg_distance, 1),
"average_fuel_consumption_full_tank": round(avg_consumption, 2),
"average_cost_per_full_tank": round(avg_cost, 2),
"last_full_tank_distance": last_distance,
"full_tank_warning": warning,
}
async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame: async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame:
@@ -249,6 +459,7 @@ async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFr
async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction: async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction:
price_prediction = await predict_fuel_price(session, car_id) price_prediction = await predict_fuel_price(session, car_id)
tank_prediction = await full_tank_metrics(session, car_id, date.min, date.today())
fuel = await dataframe_from_query( fuel = await dataframe_from_query(
session, session,
select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where( select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where(
@@ -271,6 +482,7 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
avg_km_per_day=None, avg_km_per_day=None,
avg_km_per_month=None, avg_km_per_month=None,
**price_prediction, **price_prediction,
**tank_prediction,
confidence=0, confidence=0,
insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.", insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.",
) )
@@ -291,6 +503,7 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
avg_km_per_day=None, avg_km_per_day=None,
avg_km_per_month=None, avg_km_per_month=None,
**price_prediction, **price_prediction,
**tank_prediction,
confidence=0.2, confidence=0.2,
insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.", insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.",
) )
@@ -337,6 +550,7 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
avg_km_per_day=round(km_per_day, 1), avg_km_per_day=round(km_per_day, 1),
avg_km_per_month=round(km_per_day * 30.4, 1), avg_km_per_month=round(km_per_day * 30.4, 1),
**price_prediction, **price_prediction,
**tank_prediction,
confidence=round(confidence, 2), confidence=round(confidence, 2),
insight=insight, insight=insight,
) )

94
app/services/loans.py Normal file
View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
MONEY = Decimal("0.01")
@dataclass(frozen=True)
class LoanPayment:
number: int
payment_date: date | None
payment: Decimal
principal: Decimal
interest: Decimal
remaining_principal: Decimal
def quantize_money(value: Decimal) -> Decimal:
return value.quantize(MONEY, rounding=ROUND_HALF_UP)
def annuity_payment(principal: Decimal, months: int, annual_rate: Decimal) -> Decimal:
if principal <= 0:
raise ValueError("principal must be positive")
if months <= 0:
raise ValueError("months must be positive")
if annual_rate < 0:
raise ValueError("annual_rate must be non-negative")
if annual_rate == 0:
return quantize_money(principal / Decimal(months))
monthly_rate = annual_rate / Decimal("12") / Decimal("100")
factor = (Decimal("1") + monthly_rate) ** months
payment = principal * monthly_rate * factor / (factor - Decimal("1"))
return quantize_money(payment)
def loan_summary(principal: Decimal, months: int, annual_rate: Decimal) -> dict:
payment = annuity_payment(principal, months, annual_rate)
total_payment = quantize_money(payment * Decimal(months))
total_interest = max(total_payment - principal, Decimal("0")).quantize(MONEY)
return {
"monthly_payment": payment,
"total_payment": total_payment,
"overpayment": total_interest,
"total_interest": total_interest,
"principal": principal,
"months": months,
"annual_rate": annual_rate,
}
def generate_annuity_schedule(
*,
principal: Decimal,
months: int,
annual_rate: Decimal,
first_payment_date: date | None = None,
) -> list[LoanPayment]:
payment = annuity_payment(principal, months, annual_rate)
monthly_rate = annual_rate / Decimal("12") / Decimal("100")
remaining = principal
rows: list[LoanPayment] = []
for number in range(1, months + 1):
interest = quantize_money(remaining * monthly_rate) if annual_rate else Decimal("0.00")
principal_part = payment - interest
if number == months or principal_part > remaining:
principal_part = remaining
payment_for_row = principal_part + interest
else:
payment_for_row = payment
remaining = max(remaining - principal_part, Decimal("0"))
rows.append(
LoanPayment(
number=number,
payment_date=None if first_payment_date is None else add_months(first_payment_date, number - 1),
payment=quantize_money(payment_for_row),
principal=quantize_money(principal_part),
interest=quantize_money(interest),
remaining_principal=quantize_money(remaining),
)
)
return rows
def add_months(value: date, months: int) -> date:
import calendar
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)

157
app/services/odometer.py Normal file
View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.car import Car, OdometerHistory
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
def validate_odometer_change(
car: Car,
new_odometer: int | None,
*,
source_record_type: str,
confirm_lower_odometer: bool = False,
) -> None:
if new_odometer is None:
return
if new_odometer < 0:
raise HTTPException(status_code=422, detail="Odometer must be non-negative")
current = car.current_odometer
if current is not None and new_odometer < current and not confirm_lower_odometer:
raise HTTPException(
status_code=409,
detail={
"code": "odometer_lower_than_current",
"message": "Новый пробег меньше текущего. Подтвердите ручную корректировку или проверьте запись.",
"current_odometer": current,
"new_odometer": new_odometer,
"source": source_record_type,
},
)
if current is not None and new_odometer > current + 100000 and not confirm_lower_odometer:
raise HTTPException(
status_code=409,
detail={
"code": "odometer_jump_requires_confirmation",
"message": "Пробег сильно отличается от текущего. Проверьте число перед сохранением.",
"current_odometer": current,
"new_odometer": new_odometer,
"source": source_record_type,
},
)
def add_odometer_history(
session: AsyncSession,
car: Car,
*,
new_odometer: int,
source_record_type: str,
source_record_id: int | None,
changed_by: int | None,
confirmation_required: bool = False,
user_confirmed: bool = True,
) -> None:
previous = car.current_odometer
session.add(
OdometerHistory(
car_id=car.id,
previous_odometer=previous,
new_odometer=new_odometer,
source_record_type=source_record_type,
source_record_id=source_record_id,
changed_by=changed_by,
confirmation_required=confirmation_required,
user_confirmed=user_confirmed,
)
)
car.current_odometer = new_odometer
async def apply_odometer_from_record(
session: AsyncSession,
car: Car,
*,
new_odometer: int | None,
source_record_type: str,
source_record_id: int | None,
changed_by: int | None,
confirm_lower_odometer: bool = False,
) -> None:
if new_odometer is None:
return
validate_odometer_change(
car,
new_odometer,
source_record_type=source_record_type,
confirm_lower_odometer=confirm_lower_odometer,
)
current = car.current_odometer
if current is None or new_odometer > current or confirm_lower_odometer:
add_odometer_history(
session,
car,
new_odometer=new_odometer,
source_record_type=source_record_type,
source_record_id=source_record_id,
changed_by=changed_by,
confirmation_required=current is not None and new_odometer < current,
user_confirmed=True,
)
async def recalculate_current_odometer(
session: AsyncSession,
car_id: int,
*,
changed_by: int | None = None,
source_record_type: str = "recalculate",
) -> None:
car = await session.get(Car, car_id)
if car is None:
return
fuel_result = await session.execute(
select(FuelEntry.odometer)
.where(FuelEntry.car_id == car_id)
.order_by(FuelEntry.odometer.desc())
.limit(1)
)
service_result = await session.execute(
select(ServiceEntry.odometer)
.where(ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None))
.order_by(ServiceEntry.odometer.desc())
.limit(1)
)
expense_result = await session.execute(
select(ExpenseEntry.odometer)
.where(ExpenseEntry.car_id == car_id, ExpenseEntry.odometer.is_not(None))
.order_by(ExpenseEntry.odometer.desc())
.limit(1)
)
values = [
value
for value in (
fuel_result.scalar_one_or_none(),
service_result.scalar_one_or_none(),
expense_result.scalar_one_or_none(),
)
if value is not None
]
new_value = max(values) if values else None
if new_value != car.current_odometer:
if new_value is None:
car.current_odometer = None
return
add_odometer_history(
session,
car,
new_odometer=new_value,
source_record_type=source_record_type,
source_record_id=None,
changed_by=changed_by,
confirmation_required=False,
user_confirmed=True,
)

View File

@@ -0,0 +1,193 @@
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, Field
from app.services.vehicle_identity import normalize_license_plate, validate_vin
FULL_TANK_RE = re.compile(r"(до\s+полного|полный\s+бак|залил\s+полный|full\s+tank)", re.I)
NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
class ParsedRecord(BaseModel):
event_type: str
confidence: float = Field(ge=0, le=1)
missing_fields: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
data: dict[str, Any] = Field(default_factory=dict)
def decimal_from_match(value: str | None) -> Decimal | None:
if not value:
return None
return Decimal(value.replace(",", "."))
def parse_record_text(text: str) -> ParsedRecord:
source = " ".join(text.strip().split())
lower = source.lower()
if not source:
return ParsedRecord(event_type="unknown", confidence=0, missing_fields=["text"])
vin = extract_vin(source)
plate = extract_license_plate(source)
if any(word in lower for word in ("купил", "покупка", "кредит", "loan", "lease")):
return parse_purchase(source, vin, plate)
if any(word in lower for word in ("заправ", "литр", "л ", "full tank", "бак")):
return parse_fuel(source, vin, plate)
if any(word in lower for word in ("страхов", "полис", "osago", "каско")):
return parse_expense(source, "insurance", vin, plate)
if any(word in lower for word in ("штраф", "fine")):
return parse_expense(source, "fine", vin, plate)
if any(word in lower for word in ("налог", "tax")):
return parse_expense(source, "tax", vin, plate)
if any(word in lower for word in ("то", "сервис", "ремонт", "масл", "diagnostics", "repair")):
return parse_service(source, vin, plate)
return ParsedRecord(
event_type="unknown",
confidence=0.2,
warnings=["Не удалось надежно определить тип записи. Откройте ручной ввод."],
data=identity_payload(vin, plate),
)
def parse_fuel(source: str, vin: str | None, plate: str | None) -> ParsedRecord:
liters = find_decimal(r"(\d+(?:[.,]\d+)?)\s*(?:л|литр|liter|l)\b", source)
amount = find_decimal(r"(?:на|сумма|total|amount)\s*(\d+(?:[.,]\d+)?)", source)
if amount is None:
amount = largest_money_like_number(source, exclude={liters})
odometer = find_int(r"(?:пробег|одометр|odo|km|км)\s*(\d{2,7})", source)
price_per_liter = None
if liters and amount:
price_per_liter = (amount / liters).quantize(Decimal("0.01"))
missing = []
if liters is None:
missing.append("fuel_liters")
if amount is None:
missing.append("amount")
if odometer is None:
missing.append("odometer_km")
return ParsedRecord(
event_type="fuel",
confidence=0.9 if not missing else 0.55,
missing_fields=missing,
data={
**identity_payload(vin, plate),
"is_full_tank": bool(FULL_TANK_RE.search(source)),
"fuel_liters": float(liters) if liters is not None else None,
"amount": float(amount) if amount is not None else None,
"price_per_liter": float(price_per_liter) if price_per_liter is not None else None,
"odometer_km": odometer,
},
)
def parse_purchase(source: str, vin: str | None, plate: str | None) -> ParsedRecord:
purchase_price = find_decimal(r"(?:за|стоимость|цена)\s*(\d+(?:[.,]\d+)?)", source)
loan_principal = find_decimal(r"(?:кредит|loan)\s*(\d+(?:[.,]\d+)?)", source)
term = find_int(r"(?:на|срок)\s*(\d{1,3})\s*(?:мес|месяц|months)", source)
rate = find_decimal(r"(?:под|ставк[аи]|rate)\s*(\d+(?:[.,]\d+)?)\s*%?", source)
currency = detect_currency(source)
missing = []
if purchase_price is None:
missing.append("purchase_price")
return ParsedRecord(
event_type="vehicle_purchase",
confidence=0.86 if purchase_price is not None else 0.45,
missing_fields=missing,
data={
**identity_payload(vin, plate),
"purchase_price": float(purchase_price) if purchase_price is not None else None,
"purchase_currency": currency,
"purchase_type": "credit" if loan_principal else "cash",
"loan_principal": float(loan_principal) if loan_principal is not None else None,
"loan_term_months": term,
"annual_interest_rate": float(rate) if rate is not None else None,
},
)
def parse_expense(source: str, category: str, vin: str | None, plate: str | None) -> ParsedRecord:
amount = find_decimal(r"(?:на|сумма|оплатил|total|amount)\s*(\d+(?:[.,]\d+)?)", source) or largest_money_like_number(source)
return ParsedRecord(
event_type=category,
confidence=0.75 if amount is not None else 0.5,
missing_fields=[] if amount is not None else ["amount"],
data={
**identity_payload(vin, plate),
"category": category,
"amount": float(amount) if amount is not None else None,
"currency": detect_currency(source),
},
)
def parse_service(source: str, vin: str | None, plate: str | None) -> ParsedRecord:
amount = find_decimal(r"(?:на|сумма|стоимость|total|amount)\s*(\d+(?:[.,]\d+)?)", source)
odometer = find_int(r"(?:пробег|одометр|odo|km|км)\s*(\d{2,7})", source)
title = "Замена масла" if re.search(r"масл", source, re.I) else "Сервисная запись"
return ParsedRecord(
event_type="service",
confidence=0.72,
missing_fields=[] if odometer is not None else ["odometer_km"],
data={
**identity_payload(vin, plate),
"title": title,
"amount": float(amount) if amount is not None else 0,
"odometer_km": odometer,
"service_type": "maintenance" if title == "Замена масла" else "repair",
},
)
def identity_payload(vin: str | None, plate: str | None) -> dict[str, str | None]:
return {"vin": vin, "license_plate": plate}
def extract_vin(source: str) -> str | None:
for candidate in re.findall(r"[A-HJ-NPR-Z0-9][A-HJ-NPR-Z0-9\s-]{15,25}[A-HJ-NPR-Z0-9]", source.upper()):
try:
return validate_vin(candidate)
except ValueError:
continue
return None
def extract_license_plate(source: str) -> str | None:
match = re.search(r"(?:номер|госномер|plate)\s*[:#]?\s*([A-ZА-Я0-9가-힣\-\s]{4,14})", source, re.I)
return normalize_license_plate(match.group(1)) if match else None
def find_decimal(pattern: str, source: str) -> Decimal | None:
match = re.search(pattern, source, re.I)
return decimal_from_match(match.group(1)) if match else None
def find_int(pattern: str, source: str) -> int | None:
match = re.search(pattern, source, re.I)
return int(match.group(1)) if match else None
def largest_money_like_number(source: str, exclude: set[Decimal | None] | None = None) -> Decimal | None:
excluded = {item for item in (exclude or set()) if item is not None}
values = [decimal_from_match(match.group(1)) for match in NUMBER_RE.finditer(source)]
candidates = [value for value in values if value is not None and value not in excluded]
if not candidates:
return None
return max(candidates)
def detect_currency(source: str) -> str:
lower = source.lower()
if "вон" in lower or "krw" in lower or "" in lower:
return "KRW"
if "usd" in lower or "$" in lower:
return "USD"
if "eur" in lower or "" in lower:
return "EUR"
return "RUB"

View File

@@ -36,6 +36,54 @@ class MissingItem:
DEFAULT_ACHIEVEMENTS = [ DEFAULT_ACHIEVEMENTS = [
{
"code": "vehicle_added",
"scope": "vehicle",
"title": "Авто добавлено",
"description": "В гараже появилась первая карточка автомобиля.",
"icon": "car",
"category": "profile",
},
{
"code": "vin_added",
"scope": "vehicle",
"title": "VIN указан",
"description": "Идентификация автомобиля стала надежнее.",
"icon": "vin",
"category": "profile",
},
{
"code": "license_plate_added",
"scope": "vehicle",
"title": "Госномер указан",
"description": "Карточку проще связать с сервисными визитами.",
"icon": "plate",
"category": "profile",
},
{
"code": "vehicle_profile_half",
"scope": "vehicle",
"title": "Карточка авто заполнена на 50%",
"description": "Данных уже достаточно для базовой аналитики.",
"icon": "progress",
"category": "profile",
},
{
"code": "vehicle_profile_full",
"scope": "vehicle",
"title": "Карточка авто заполнена полностью",
"description": "Цифровой паспорт автомобиля готов к эксплуатации.",
"icon": "passport",
"category": "profile",
},
{
"code": "first_fuel_record",
"scope": "vehicle",
"title": "Первая заправка",
"description": "Расход топлива начал формировать историю владения.",
"icon": "fuel",
"category": "tracking",
},
{ {
"code": "first_service_record", "code": "first_service_record",
"scope": "vehicle", "scope": "vehicle",
@@ -371,6 +419,47 @@ async def evaluate_vehicle_achievements(
visits: list[ServiceVisit], visits: list[ServiceVisit],
) -> None: ) -> None:
achievements = await ensure_default_achievements(session) achievements = await ensure_default_achievements(session)
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vehicle_added"],
)
if car.vin_normalized:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vin_added"],
)
if car.license_plate_normalized:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["license_plate_added"],
)
if vehicle_score.completeness_score >= 50:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vehicle_profile_half"],
)
if vehicle_score.completeness_score >= 95:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["vehicle_profile_full"],
)
if fuel_entries:
await unlock_achievement(
session,
user_id=car.owner_id,
vehicle_id=car.id,
achievement=achievements["first_fuel_record"],
)
if service_entries or visits: if service_entries or visits:
await unlock_achievement( await unlock_achievement(
session, session,

View File

@@ -15,6 +15,28 @@ class ApiClient:
headers["X-Telegram-User-Id"] = str(telegram_id) headers["X-Telegram-User-Id"] = str(telegram_id)
return headers return headers
async def request(
self,
method: str,
path: str,
*,
telegram_id: int | None = None,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
async with httpx.AsyncClient(base_url=self.base_url, timeout=15) as client:
response = await client.request(
method,
path,
json=json,
params=params,
headers=self.headers(telegram_id),
)
response.raise_for_status()
if response.status_code == 204:
return None
return response.json()
async def upsert_user(self, telegram_user: Any) -> dict[str, Any]: async def upsert_user(self, telegram_user: Any) -> dict[str, Any]:
payload = { payload = {
"telegram_id": telegram_user.id, "telegram_id": telegram_user.id,
@@ -50,3 +72,47 @@ class ApiClient:
response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id)) response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def create_fuel(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]:
return await self.request("POST", "/api/fuel", telegram_id=telegram_id, json=payload)
async def create_service(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]:
return await self.request("POST", "/api/service", telegram_id=telegram_id, json=payload)
async def create_expense(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]:
return await self.request("POST", "/api/expenses", telegram_id=telegram_id, json=payload)
async def parse_record(self, telegram_id: int, text: str) -> dict[str, Any]:
return await self.request("POST", "/api/parse/record", telegram_id=telegram_id, json={"text": text})
async def public_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
return await self.request("GET", "/api/service-centers/public", telegram_id=telegram_id)
async def my_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
return await self.request("GET", "/api/service-centers/my", telegram_id=telegram_id)
async def register_service_center(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]:
return await self.request("POST", "/api/service-centers", telegram_id=telegram_id, json=payload)
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)
async def moderate_service_center(
self,
telegram_id: int,
service_center_id: int,
action: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
endpoint = {
"approve": "verify",
"reject": "reject",
"suspend": "suspend",
"changes": "request-changes",
}[action]
return await self.request(
"POST",
f"/api/admin/service-centers/{service_center_id}/{endpoint}",
telegram_id=telegram_id,
json=payload or {},
)

View File

@@ -1,6 +1,9 @@
import asyncio import asyncio
import logging import logging
from datetime import date
from decimal import Decimal
import httpx
from aiogram import Bot, Dispatcher, F from aiogram import Bot, Dispatcher, F
from aiogram.filters import Command, CommandObject from aiogram.filters import Command, CommandObject
from aiogram.types import ( from aiogram.types import (
@@ -25,78 +28,426 @@ api = ApiClient()
def main_keyboard() -> ReplyKeyboardMarkup: def main_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[KeyboardButton(text="Открыть CarPass")], [KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")],
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")], [KeyboardButton(text="Помощь")],
], ],
resize_keyboard=True, resize_keyboard=True,
) )
def webapp_inline_keyboard() -> InlineKeyboardMarkup: def webapp_inline_keyboard(text: str = "Открыть CarPass") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup( return InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
[InlineKeyboardButton(text="Открыть CarPass", web_app=WebAppInfo(url=settings.effective_webapp_url))], [InlineKeyboardButton(text=text, web_app=WebAppInfo(url=settings.effective_webapp_url))],
] ]
) )
def menu_inline_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=settings.effective_webapp_url))],
[
InlineKeyboardButton(text="Мои авто", callback_data="menu:garage"),
InlineKeyboardButton(text="Аналитика", callback_data="menu:analytics"),
],
[
InlineKeyboardButton(text="Добавить запись", callback_data="menu:add_record"),
InlineKeyboardButton(text="СТО", callback_data="menu:sto"),
],
]
)
def admin_card_keyboard(center_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="Одобрить", callback_data=f"admin:approve:{center_id}"),
InlineKeyboardButton(text="Правки", callback_data=f"admin:changes:{center_id}"),
],
[
InlineKeyboardButton(text="Отклонить", callback_data=f"admin:reject:{center_id}"),
InlineKeyboardButton(text="Заморозить", callback_data=f"admin:suspend:{center_id}"),
],
]
)
async def safe_answer(message: Message, text: str, **kwargs) -> None:
await message.answer(text, **kwargs)
async def upsert(message: Message) -> dict:
return await api.upsert_user(message.from_user)
async def list_user_cars(message: Message) -> list[dict]:
user = await upsert(message)
return await api.list_cars(user["id"], message.from_user.id)
async def require_one_car(message: Message) -> dict | None:
cars = await list_user_cars(message)
if not cars:
await message.answer(
"В гараже пока нет автомобиля. Добавь его командой /add_car Название или через Mini App.",
reply_markup=webapp_inline_keyboard("Добавить авто"),
)
return None
if len(cars) > 1:
await message.answer(
"У тебя несколько авто. Для точной записи открой Mini App и выбери нужную машину.",
reply_markup=webapp_inline_keyboard("Выбрать авто"),
)
return None
return cars[0]
def money(value) -> str:
return f"{Decimal(str(value or 0)).quantize(Decimal('0.01'))}"
def parse_amount_arg(args: str | None) -> Decimal | None:
if not args:
return None
import re
matches = re.findall(r"\d+(?:[.,]\d+)?", args)
if not matches:
return None
return Decimal(matches[-1].replace(",", "."))
@dp.message(Command("start")) @dp.message(Command("start"))
async def start(message: Message) -> None: async def start(message: Message) -> None:
user = await api.upsert_user(message.from_user) user = await upsert(message)
text = ( text = (
f"Готово, {user.get('first_name') or 'водитель'}.\n\n" f"Готово, {user.get('first_name') or 'водитель'}.\n\n"
"CarPass цифровой паспорт автомобиля: заправки, обслуживание, напоминания, подтвержденная история и стоимость владения.\n\n" "CarPass ведет цифровой паспорт автомобиля: заправки, ТО, страховку, штрафы, стоимость владения и подтвержденную историю СТО.\n\n"
"Нажми «Открыть CarPass», чтобы перейти в приложение." "Mini App открывай кнопкой под сообщением: так Telegram передает защищенную авторизацию."
) )
await message.answer(text, reply_markup=webapp_inline_keyboard()) await message.answer(text, reply_markup=menu_inline_keyboard())
await message.answer("Клавиатура ниже открывает меню бота. Сам Mini App запускается кнопкой в сообщении выше.", reply_markup=main_keyboard()) await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=main_keyboard())
@dp.message(F.text == "Меню")
@dp.message(Command("menu"))
async def menu(message: Message) -> None:
await upsert(message)
await message.answer(
"Главное меню CarPass. Быстрые команды доступны здесь, а полный интерфейс работает в Mini App.",
reply_markup=menu_inline_keyboard(),
)
@dp.message(Command("garage"))
@dp.message(Command("cars"))
@dp.message(F.text == "Мои авто")
async def cars(message: Message) -> None:
items = await list_user_cars(message)
if not items:
await message.answer("Автомобилей пока нет. Добавь через Mini App или /add_car Название.", reply_markup=webapp_inline_keyboard("Добавить авто"))
return
buttons = [[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items]
buttons.append([InlineKeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))])
await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@dp.message(Command("add_car")) @dp.message(Command("add_car"))
async def add_car(message: Message, command: CommandObject) -> None: async def add_car(message: Message, command: CommandObject) -> None:
user = await api.upsert_user(message.from_user) user = await upsert(message)
name = command.args.strip() if command.args else "" name = command.args.strip() if command.args else ""
if not name: if not name:
await message.answer("Напиши так: /add_car Toyota Camry") await message.answer(
"Напиши так: /add_car Toyota Camry\n\nVIN, госномер, кредит и параметры масла удобнее заполнить в Mini App.",
reply_markup=webapp_inline_keyboard("Заполнить карточку"),
)
return return
car = await api.create_car(user["id"], name, message.from_user.id) try:
await message.answer(f"Добавил авто: {car['name']}") car = await api.create_car(user["id"], name, message.from_user.id)
except httpx.HTTPStatusError as error:
await message.answer(f"Не удалось добавить авто: {error.response.text}")
@dp.message(Command("cars"))
@dp.message(F.text == "Мои авто")
async def cars(message: Message) -> None:
user = await api.upsert_user(message.from_user)
items = await api.list_cars(user["id"], message.from_user.id)
if not items:
await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.")
return return
await message.answer(f"Добавил авто: {car['name']}", reply_markup=webapp_inline_keyboard("Открыть карточку"))
buttons = [
[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items @dp.message(Command("add_record"))
] async def add_record(message: Message) -> None:
await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) await upsert(message)
await message.answer(
"Добавить запись можно командой или через Mini App.\n\n"
"/fuel заправил полный бак 43 литра на 72000, пробег 184230\n"
"/service замена масла 70000 пробег 184900\n"
"/insurance страховка 1200000 на 12 месяцев\n"
"/tax налог 180000\n"
"/fine штраф 40000\n\n"
"При заправке бот распознает фразы «полный бак», «до полного», «full tank».",
reply_markup=webapp_inline_keyboard("Добавить запись"),
)
@dp.message(Command("fuel"))
async def fuel(message: Message, command: CommandObject) -> None:
args = command.args or ""
if not args.strip():
await message.answer(
"Напиши заправку текстом: /fuel заправил полный бак 43 литра на 72000, пробег 184230\n\n"
"Бак был заправлен до полного? Укажи «полный бак», «нет» или «не знаю».",
reply_markup=webapp_inline_keyboard("Заполнить заправку"),
)
return
await create_record_from_text(message, args, expected="fuel")
@dp.message(Command("service"))
async def service(message: Message, command: CommandObject) -> None:
args = command.args or ""
if not args.strip():
await message.answer(
"Напиши сервисную запись: /service замена масла 70000 пробег 184900\n\n"
"Для фото, следующего ТО и детальных работ удобнее Mini App.",
reply_markup=webapp_inline_keyboard("Добавить ТО"),
)
return
await create_record_from_text(message, args, expected="service")
@dp.message(Command("insurance"))
async def insurance(message: Message, command: CommandObject) -> None:
await create_expense_command(message, command, "insurance", "Страховка")
@dp.message(Command("tax"))
async def tax(message: Message, command: CommandObject) -> None:
await create_expense_command(message, command, "tax", "Налог")
@dp.message(Command("fine"))
async def fine(message: Message, command: CommandObject) -> None:
await create_expense_command(message, command, "fine", "Штраф")
async def create_expense_command(message: Message, command: CommandObject, category: str, title: str) -> None:
args = command.args or ""
if not args.strip():
await message.answer(f"Напиши сумму: /{category if category != 'fine' else 'fine'} {title.lower()} 40000", reply_markup=webapp_inline_keyboard(f"Добавить {title.lower()}"))
return
car = await require_one_car(message)
if not car:
return
parsed = await api.parse_record(message.from_user.id, f"{title} {args}")
amount = parsed.get("data", {}).get("amount") or parse_amount_arg(args)
if not amount:
await message.answer("Не нашёл сумму. Проверь запись или открой форму в Mini App.", reply_markup=webapp_inline_keyboard("Открыть форму"))
return
payload = {
"car_id": car["id"],
"entry_date": date.today().isoformat(),
"category": category,
"title": title,
"total_cost": float(amount),
"currency": parsed.get("data", {}).get("currency") or car.get("currency") or "RUB",
"is_recurring": category in {"insurance", "tax"},
}
if category == "insurance":
payload["period_months"] = 12 if "12" in args else None
payload["payment_period_months"] = payload["period_months"]
try:
await api.create_expense(message.from_user.id, payload)
except httpx.HTTPStatusError as error:
await message.answer(f"Запись не сохранена: {error.response.text}")
return
await message.answer(f"{title} сохранен для {car['name']}.")
@dp.message(Command("analytics"))
async def analytics(message: Message) -> None:
await cars(message)
@dp.message(Command("sto"))
async def sto(message: Message) -> None:
await upsert(message)
try:
centers = await api.public_service_centers(message.from_user.id)
except httpx.HTTPStatusError:
centers = []
if not centers:
await message.answer(
"Проверенных СТО пока нет в каталоге. Можно зарегистрировать свое СТО через Mini App или командой /register_sto Название.",
reply_markup=webapp_inline_keyboard("Открыть СТО"),
)
return
text = "Проверенные СТО:\n" + "\n".join(
f"{item['id']}. {item.get('display_name') or item.get('name')}{item.get('city') or 'город не указан'}"
for item in centers[:10]
)
await message.answer(text, reply_markup=webapp_inline_keyboard("Каталог СТО"))
@dp.message(Command("register_sto"))
async def register_sto(message: Message, command: CommandObject) -> None:
await upsert(message)
name = command.args.strip() if command.args else ""
if not name:
await message.answer(
"Для заявки СТО нужны название, адрес, телефон, специализация и фото документов. Открой форму в Mini App.\n\n"
"Быстрый черновик можно создать так: /register_sto Smart Service",
reply_markup=webapp_inline_keyboard("Зарегистрировать СТО"),
)
return
try:
center = await api.register_service_center(message.from_user.id, {"display_name": name})
except httpx.HTTPStatusError as error:
await message.answer(f"Не удалось отправить заявку: {error.response.text}")
return
await message.answer(
f"Заявка СТО «{center['display_name'] or center['name']}» отправлена на модерацию. Статус: {center['verification_status']}.",
reply_markup=webapp_inline_keyboard("Дополнить заявку"),
)
@dp.message(Command("admin_sto_pending"))
async def admin_sto_pending(message: Message) -> None:
await upsert(message)
try:
centers = await api.pending_service_centers(message.from_user.id)
except httpx.HTTPStatusError as error:
await message.answer(f"Нет доступа к модерации: {error.response.text}")
return
if not centers:
await message.answer("Pending-заявок СТО нет.")
return
for center in centers[:20]:
text = "\n".join(
[
f"Заявка СТО #{center['id']}",
center.get("display_name") or center.get("name") or "Без названия",
f"Юр. название: {center.get('legal_name') or '-'}",
f"Рег. номер: {center.get('business_registration_number') or '-'}",
f"Адрес: {', '.join(x for x in [center.get('country'), center.get('city'), center.get('address')] if x) or '-'}",
f"Телефон: {center.get('phone') or center.get('contact_phone') or '-'}",
f"Контакт: {center.get('contact_person') or '-'}",
f"Документы: {len(center.get('document_photo_urls') or [])}",
]
)
await message.answer(text, reply_markup=admin_card_keyboard(center["id"]))
async def admin_action(message: Message, command: CommandObject, action: str) -> None:
args = (command.args or "").split(maxsplit=1)
if not args:
await message.answer("Укажи id заявки. Например: /admin_sto_approve 12")
return
try:
center_id = int(args[0])
except ValueError:
await message.answer("id заявки должен быть числом.")
return
comment = args[1] if len(args) > 1 else None
try:
center = await api.moderate_service_center(
message.from_user.id,
center_id,
action,
{"reason": comment, "comment": comment},
)
except httpx.HTTPStatusError as error:
await message.answer(f"Не удалось выполнить действие: {error.response.text}")
return
await message.answer(f"Готово. СТО #{center['id']} теперь в статусе {center['verification_status']}.")
@dp.message(Command("admin_sto_approve"))
async def admin_sto_approve(message: Message, command: CommandObject) -> None:
await admin_action(message, command, "approve")
@dp.message(Command("admin_sto_reject"))
async def admin_sto_reject(message: Message, command: CommandObject) -> None:
await admin_action(message, command, "reject")
@dp.message(Command("admin_sto_changes"))
async def admin_sto_changes(message: Message, command: CommandObject) -> None:
await admin_action(message, command, "changes")
@dp.message(Command("admin_sto_suspend"))
async def admin_sto_suspend(message: Message, command: CommandObject) -> None:
await admin_action(message, command, "suspend")
@dp.callback_query(F.data.startswith("stats:")) @dp.callback_query(F.data.startswith("stats:"))
async def show_stats(callback: CallbackQuery) -> None: async def show_stats(callback: CallbackQuery) -> None:
car_id = int(callback.data.split(":", 1)[1]) car_id = int(callback.data.split(":", 1)[1])
stats = await api.stats(car_id, callback.from_user.id) try:
stats = await api.stats(car_id, callback.from_user.id)
except httpx.HTTPStatusError as error:
await callback.message.answer(f"Не удалось получить статистику: {error.response.text}")
await callback.answer()
return
consumption = stats["avg_consumption_l_per_100km"] consumption = stats["avg_consumption_l_per_100km"]
cost_per_km = stats["cost_per_km"] cost_per_km = stats["cost_per_km"]
await callback.message.answer( lines = [
"\n".join( "Статистика авто:",
[ f"Расходы всего: {money(stats['total_cost'])}",
"Статистика авто:", f"Фиксированные: {money(stats.get('fixed_costs'))}",
f"Расходы всего: {stats['total_cost']}", f"Переменные: {money(stats.get('variable_costs'))}",
f"Топливо: {stats['fuel_cost']}", f"Топливо: {money(stats['fuel_cost'])}",
f"Сервис и ремонты: {stats['service_cost']}", f"Сервис и ремонты: {money(stats['service_cost'])}",
f"Пробег по записям: {stats['distance_km']} км", f"Пробег по записям: {stats['distance_km']} км",
f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных", f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных",
f"Стоимость 1 км: {cost_per_km:.2f}" if cost_per_km else "Стоимость 1 км: нет данных", f"Стоимость 1 км: {cost_per_km:.2f}" if cost_per_km else "Стоимость 1 км: нет данных",
] ]
if stats.get("cost_warning"):
lines.append(stats["cost_warning"])
await callback.message.answer("\n".join(lines))
await callback.answer()
@dp.callback_query(F.data.startswith("menu:"))
async def menu_callback(callback: CallbackQuery) -> None:
action = callback.data.split(":", 1)[1]
if action in {"garage", "analytics"}:
user = await api.upsert_user(callback.from_user)
items = await api.list_cars(user["id"], callback.from_user.id)
if not items:
await callback.message.answer("Автомобилей пока нет.", reply_markup=webapp_inline_keyboard("Добавить авто"))
else:
buttons = [[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items]
await callback.message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
elif action == "sto":
centers = await api.public_service_centers(callback.from_user.id)
if not centers:
await callback.message.answer("Проверенных СТО пока нет.", reply_markup=webapp_inline_keyboard("Каталог СТО"))
else:
await callback.message.answer(
"Проверенные СТО:\n"
+ "\n".join(f"{item['id']}. {item.get('display_name') or item.get('name')}" for item in centers[:10])
)
else:
await callback.message.answer("Открой Mini App для добавления записи.", reply_markup=webapp_inline_keyboard("Добавить запись"))
await callback.answer()
@dp.callback_query(F.data.startswith("admin:"))
async def admin_callback(callback: CallbackQuery) -> None:
_, action, center_id = callback.data.split(":", 2)
try:
center = await api.moderate_service_center(
callback.from_user.id,
int(center_id),
action,
{"reason": "Решение из Telegram-кнопки", "comment": "Решение из Telegram-кнопки"},
) )
) except httpx.HTTPStatusError as error:
await callback.message.answer(f"Модерация не выполнена: {error.response.text}")
await callback.answer()
return
await callback.message.answer(f"СТО #{center['id']} теперь в статусе {center['verification_status']}.")
await callback.answer() await callback.answer()
@@ -105,27 +456,105 @@ async def show_stats(callback: CallbackQuery) -> None:
async def help_message(message: Message) -> None: async def help_message(message: Message) -> None:
await message.answer( await message.answer(
"CarPass помогает вести цифровой паспорт автомобиля.\n\n" "CarPass помогает вести цифровой паспорт автомобиля.\n\n"
"Что можно делать:\n" "Главное:\n"
"добавлять автомобили и параметры обслуживания;\n" "/garage — список автомобилей;\n"
"вести заправки, ТО, ремонт, страховку, налоги и штрафы;\n" "/add_car Название — быстро добавить авто;\n"
"видеть стоимость владения, стоимость 1 км и прогноз расходов;\n" "/fuel — заправка, включая полный бак;\n"
"загрузить чек, проверить распознанные данные и сохранить запись;\n" "/service — ТО и ремонт;\n"
"привязать авто к проверенному СТО и подтверждать сервисную историю;\n" "/insurance, /tax, /fine — регулярные и разовые расходы;\n"
"зарегистрировать СТО и отправить заявку на проверку.\n\n" "/analytics — стоимость владения и расход;\n"
"Mini App нужно открывать кнопкой под этим сообщением: так Telegram передает защищенную авторизацию.", "• /sto — каталог проверенных СТО;\n"
reply_markup=webapp_inline_keyboard(), "• /register_sto — заявка на СТО.\n\n"
"Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.",
reply_markup=menu_inline_keyboard(),
) )
@dp.message(F.text == "Открыть CarPass") @dp.message(F.text == "Открыть CarPass")
@dp.message(F.text == "Открыть гараж") @dp.message(F.text == "Открыть гараж")
async def open_carpass(message: Message) -> None: async def old_open_buttons(message: Message) -> None:
await message.answer( await message.answer(
"Открой CarPass кнопкой ниже. Это правильный Telegram Mini App вход с авторизацией.", "Эта кнопка больше не используется как ReplyButton. Открой CarPass через защищенную кнопку ниже.",
reply_markup=webapp_inline_keyboard(), reply_markup=webapp_inline_keyboard(),
) )
@dp.message(F.text)
async def parse_free_text(message: Message) -> None:
if message.text.startswith("/"):
return
parsed = await api.parse_record(message.from_user.id, message.text)
if parsed.get("event_type") == "unknown" or parsed.get("confidence", 0) < 0.55:
await message.answer("Не понял запись. Открой /menu или Mini App, там все формы под рукой.", reply_markup=menu_inline_keyboard())
return
await create_record_from_parsed(message, parsed)
async def create_record_from_text(message: Message, text: str, expected: str | None = None) -> None:
parsed = await api.parse_record(message.from_user.id, text)
if expected and parsed.get("event_type") != expected:
parsed["event_type"] = expected
await create_record_from_parsed(message, parsed)
async def create_record_from_parsed(message: Message, parsed: dict) -> None:
car = await require_one_car(message)
if not car:
return
data = parsed.get("data", {})
event_type = parsed.get("event_type")
try:
if event_type == "fuel":
missing = [field for field in ("fuel_liters", "amount", "odometer_km") if not data.get(field)]
if missing:
await message.answer("Для заправки нужны литры, сумма и пробег. Открой форму, чтобы не ошибиться.", reply_markup=webapp_inline_keyboard("Добавить заправку"))
return
await api.create_fuel(
message.from_user.id,
{
"car_id": car["id"],
"entry_date": date.today().isoformat(),
"odometer": int(data["odometer_km"]),
"liters": float(data["fuel_liters"]),
"price_per_liter": float(data["price_per_liter"] or Decimal(str(data["amount"])) / Decimal(str(data["fuel_liters"]))),
"total_cost": float(data["amount"]),
"is_full_tank": data.get("is_full_tank"),
},
)
await message.answer("Заправка сохранена.")
elif event_type == "service":
await api.create_service(
message.from_user.id,
{
"car_id": car["id"],
"entry_date": date.today().isoformat(),
"odometer": data.get("odometer_km"),
"service_type": data.get("service_type") or "maintenance",
"title": data.get("title") or "Сервисная запись",
"total_cost": float(data.get("amount") or 0),
},
)
await message.answer("Сервисная запись сохранена.")
elif event_type in {"insurance", "tax", "fine"}:
await api.create_expense(
message.from_user.id,
{
"car_id": car["id"],
"entry_date": date.today().isoformat(),
"category": event_type,
"title": {"insurance": "Страховка", "tax": "Налог", "fine": "Штраф"}[event_type],
"total_cost": float(data.get("amount") or 0),
"currency": data.get("currency") or "RUB",
"is_recurring": event_type in {"insurance", "tax"},
},
)
await message.answer("Расход сохранен.")
else:
await message.answer("Эту запись лучше проверить в Mini App перед сохранением.", reply_markup=webapp_inline_keyboard("Открыть форму"))
except httpx.HTTPStatusError as error:
await message.answer(f"Запись не сохранена: {error.response.text}")
async def main() -> None: async def main() -> None:
if not settings.bot_token: if not settings.bot_token:
raise RuntimeError("BOT_TOKEN is empty") raise RuntimeError("BOT_TOKEN is empty")

View File

@@ -115,8 +115,8 @@ async def test_expense_crud_and_insurance_allocation(client, auth_headers) -> No
) )
assert stats.status_code == 200 assert stats.status_code == 200
body = stats.json() body = stats.json()
assert body["expenses_cost"] in {"101.92", "101.93"} assert body["expenses_cost"] == "100.00"
assert body["cost_by_category"]["insurance"] in {"101.92", "101.93"} assert body["cost_by_category"]["insurance"] == "100.00"
patched = await client.patch( patched = await client.patch(
f"/api/expenses/{entry_id}", f"/api/expenses/{entry_id}",

View File

@@ -0,0 +1,314 @@
import pytest
@pytest.mark.asyncio
async def test_license_plate_can_be_saved_and_edited(client, auth_headers) -> None:
car = (
await client.post(
"/api/cars",
headers=auth_headers,
json={"name": "Plate car", "plate_number": "12 가 3456"},
)
).json()
updated = await client.patch(
f"/api/cars/{car['id']}",
headers=auth_headers,
json={"plate_number": "34 나 7890"},
)
assert updated.status_code == 200
assert updated.json()["plate_number"] == "34 나 7890"
@pytest.mark.asyncio
async def test_insurance_six_months_allocates_proportionally(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Insurance 6"})).json()
await client.post(
"/api/expenses",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-01",
"category": "insurance",
"title": "Insurance",
"total_cost": 600,
"period_start": "2026-01-01",
"period_months": 6,
"payment_period_months": 6,
"is_recurring": True,
},
)
stats = await client.get(
f"/api/cars/{car['id']}/stats?date_from=2026-02-01&date_to=2026-02-28",
headers=auth_headers,
)
assert stats.status_code == 200
assert stats.json()["cost_by_category"]["insurance"] == "100.00"
@pytest.mark.asyncio
async def test_custom_insurance_period_allocates_by_overlap(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Insurance custom"})).json()
await client.post(
"/api/expenses",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-15",
"category": "insurance",
"title": "Short insurance",
"total_cost": 310,
"period_start": "2026-01-15",
"period_end": "2026-02-14",
"is_recurring": True,
},
)
stats = await client.get(
f"/api/cars/{car['id']}/stats?date_from=2026-02-01&date_to=2026-02-28",
headers=auth_headers,
)
assert stats.status_code == 200
assert stats.json()["cost_by_category"]["insurance"] == "140.00"
@pytest.mark.asyncio
async def test_loan_calculator_regular_and_zero_rate(client, auth_headers) -> None:
regular = await client.post(
"/api/loans/calculate",
headers=auth_headers,
json={"principal": 6000000, "term_months": 36, "annual_interest_rate": 5.5},
)
zero = await client.post(
"/api/loans/calculate",
headers=auth_headers,
json={"principal": 1200, "term_months": 12, "annual_interest_rate": 0},
)
assert regular.status_code == 200
assert float(regular.json()["monthly_payment"]) > 0
assert zero.json()["monthly_payment"] == "100.00"
assert zero.json()["total_interest"] == "0.00"
@pytest.mark.asyncio
async def test_ownership_cost_includes_credit_and_fixed_variable_split(client, auth_headers) -> None:
car = (
await client.post(
"/api/cars",
headers=auth_headers,
json={
"name": "Credit car",
"purchase_type": "credit",
"loan_principal": 1200,
"loan_term_months": 12,
"loan_annual_interest_rate": 0,
"loan_first_payment_date": "2026-01-01",
},
)
).json()
await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-10",
"odometer": 1000,
"liters": 20,
"price_per_liter": 2,
},
)
await client.post(
"/api/expenses",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-12",
"category": "fine",
"title": "Fine",
"total_cost": 30,
},
)
stats = await client.get(
f"/api/cars/{car['id']}/stats?date_from=2026-01-01&date_to=2026-01-31",
headers=auth_headers,
)
payload = stats.json()
assert payload["loan_principal_cost"] == "100.00"
assert payload["loan_interest_cost"] == "0.00"
assert payload["fixed_costs"] == "100.00"
assert payload["variable_costs"] == "70.00"
assert payload["total_cost_with_credit"] == "170.00"
assert payload["total_cost_without_credit"] == "70.00"
@pytest.mark.asyncio
async def test_odometer_lower_fuel_requires_confirmation_and_delete_recalculates(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Odo car"})).json()
first = (
await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-01",
"odometer": 1000,
"liters": 20,
"price_per_liter": 2,
},
)
).json()
second = (
await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-02",
"odometer": 2000,
"liters": 20,
"price_per_liter": 2,
},
)
).json()
blocked = await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-03",
"odometer": 900,
"liters": 20,
"price_per_liter": 2,
},
)
await client.delete(f"/api/fuel/{second['id']}", headers=auth_headers)
refreshed = await client.get(f"/api/cars/{car['id']}", headers=auth_headers)
history = await client.get(f"/api/cars/{car['id']}/odometer-history", headers=auth_headers)
assert first["odometer"] == 1000
assert blocked.status_code == 409
assert refreshed.json()["current_odometer"] == 1000
assert len(history.json()) >= 2
@pytest.mark.asyncio
async def test_service_lower_odometer_can_be_confirmed(client, auth_headers) -> None:
car = (
await client.post(
"/api/cars",
headers=auth_headers,
json={"name": "Service odo", "current_odometer": 1000},
)
).json()
blocked = await client.post(
"/api/service",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-02",
"odometer": 900,
"service_type": "maintenance",
"title": "Correction",
"total_cost": 0,
},
)
confirmed = await client.post(
"/api/service",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-02",
"odometer": 900,
"service_type": "maintenance",
"title": "Correction",
"total_cost": 0,
"confirm_lower_odometer": True,
},
)
assert blocked.status_code == 409
assert confirmed.status_code == 201
@pytest.mark.asyncio
async def test_full_tank_analysis_uses_full_tank_intervals_and_warns(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Tank car"})).json()
for odometer, liters, full in [
(1000, 40, True),
(1200, 10, False),
(1500, 35, True),
(1600, 40, True),
]:
await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-01-01",
"odometer": odometer,
"liters": liters,
"price_per_liter": 2,
"is_full_tank": full,
},
)
analytics = await client.get(f"/api/cars/{car['id']}/analytics", headers=auth_headers)
payload = analytics.json()
assert payload["average_full_tank_distance"] == 300.0
assert payload["last_full_tank_distance"] == 100
assert payload["full_tank_warning"] is not None
@pytest.mark.asyncio
async def test_parser_recognizes_full_tank_and_credit_purchase(client, auth_headers) -> None:
fuel = await client.post(
"/api/parse/record",
headers=auth_headers,
json={"text": "заправил полный бак 43 литра на 72000, пробег 184230"},
)
purchase = await client.post(
"/api/parse/record",
headers=auth_headers,
json={"text": "купил машину за 8500000 вон, кредит 6000000 на 36 месяцев под 5.5%"},
)
assert fuel.json()["event_type"] == "fuel"
assert fuel.json()["data"]["is_full_tank"] is True
assert purchase.json()["event_type"] == "vehicle_purchase"
assert purchase.json()["data"]["purchase_type"] == "credit"
assert purchase.json()["data"]["loan_term_months"] == 36
@pytest.mark.asyncio
async def test_admin_request_changes_keeps_application_visible_to_moderation(
client, auth_headers, admin_auth_headers, internal_headers
) -> None:
center = (
await client.post(
"/api/service-centers",
headers=auth_headers,
json={"display_name": "Needs Work Service", "country": "KR"},
)
).json()
await client.post(
"/api/users",
headers=internal_headers,
json={"telegram_id": 9001, "platform_role": "admin"},
)
response = await client.post(
f"/api/admin/service-centers/{center['id']}/request-changes",
headers=admin_auth_headers,
json={"reason": "Добавьте документы", "comment": "Нужны фото регистрации"},
)
assert response.status_code == 200
assert response.json()["verification_status"] == "needs_changes"

View File

@@ -255,6 +255,9 @@
<button class="menu-row" data-menu-section="fineSection">Штрафы</button> <button class="menu-row" data-menu-section="fineSection">Штрафы</button>
<button class="menu-row" data-menu-section="publicServicesSection">СТО</button> <button class="menu-row" data-menu-section="publicServicesSection">СТО</button>
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button> <button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
<button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button>
<button class="menu-row" data-menu-section="connectedServicesSection">Подключённые СТО</button>
<button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button>
<button class="menu-row" data-menu-section="settingsSection">Настройки</button> <button class="menu-row" data-menu-section="settingsSection">Настройки</button>
<section class="drawer-section hidden" id="carsSection"> <section class="drawer-section hidden" id="carsSection">
@@ -327,6 +330,33 @@
Конец периода Конец периода
<input name="period_end" type="date" /> <input name="period_end" type="date" />
</label> </label>
<label>
Месяцев покрытия
<select name="period_months">
<option value="">По датам</option>
<option value="1">1 месяц</option>
<option value="3">3 месяца</option>
<option value="6">6 месяцев</option>
<option value="12">12 месяцев</option>
</select>
</label>
<label>
Номер полиса / документа
<input name="policy_number" />
</label>
<label>
Тип страховки
<select name="insurance_type">
<option value="">Не задано</option>
<option value="mandatory">ОСАГО / обязательная</option>
<option value="full">КАСКО / полная</option>
<option value="other">Другое</option>
</select>
</label>
<label>
Комментарий
<input name="notes" />
</label>
<label class="check"> <label class="check">
<input name="is_recurring" type="checkbox" /> <input name="is_recurring" type="checkbox" />
Регулярный расход Регулярный расход
@@ -465,11 +495,29 @@
Регистрационный номер Регистрационный номер
<input name="business_registration_number" /> <input name="business_registration_number" />
</label> </label>
<label>
Фото фасада, URL
<input name="facade_photo_url" placeholder="https://..." />
</label>
<label>
Фото документов, URL через запятую
<input name="document_photo_urls" placeholder="https://..., https://..." />
</label>
<label>
Дополнительные фото, URL через запятую
<input name="additional_photo_urls" placeholder="https://..., https://..." />
</label>
<button type="submit">Отправить заявку</button> <button type="submit">Отправить заявку</button>
</form> </form>
<div id="serviceCentersList" class="stack-list"></div> <div id="serviceCentersList" class="stack-list"></div>
</section> </section>
<section class="drawer-section hidden" id="adminSection">
<h2>Модерация СТО</h2>
<div class="tip-card">Заявки видны только администраторам и модераторам.</div>
<div id="adminPendingServices" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="carFormSection"> <section class="drawer-section hidden" id="carFormSection">
<h2>Новое авто</h2> <h2>Новое авто</h2>
<form id="carForm" class="grid-form drawer-form"> <form id="carForm" class="grid-form drawer-form">
@@ -497,6 +545,18 @@
Год Год
<input name="year" type="number" min="1900" max="2100" /> <input name="year" type="number" min="1900" max="2100" />
</label> </label>
<label>
Госномер
<input name="plate_number" placeholder="12가3456" />
</label>
<label>
VIN
<input name="vin" maxlength="17" placeholder="17 символов без I/O/Q" />
</label>
<label>
Текущий пробег
<input name="current_odometer" type="number" min="0" />
</label>
<label> <label>
Тип топлива Тип топлива
<select name="fuel_type" id="fuelTypeSelect"> <select name="fuel_type" id="fuelTypeSelect">
@@ -507,6 +567,24 @@
<option value="electric">Электро</option> <option value="electric">Электро</option>
</select> </select>
</label> </label>
<label>
Стоимость покупки
<input name="purchase_price" type="number" min="0" step="0.01" />
</label>
<label>
Дата покупки
<input name="purchase_date" type="date" />
</label>
<label>
Тип покупки
<select name="purchase_type">
<option value="unknown">Не указано</option>
<option value="cash">Наличные</option>
<option value="credit">Кредит</option>
<option value="lease">Лизинг</option>
<option value="gift">Подарок</option>
</select>
</label>
<button type="submit">Добавить авто</button> <button type="submit">Добавить авто</button>
</form> </form>
</section> </section>
@@ -515,6 +593,45 @@
<h2>Параметры авто</h2> <h2>Параметры авто</h2>
<div class="tip-card" id="carProfileHint">Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.</div> <div class="tip-card" id="carProfileHint">Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.</div>
<form id="carProfileForm" class="grid-form drawer-form"> <form id="carProfileForm" class="grid-form drawer-form">
<label>
Госномер
<input name="plate_number" placeholder="12가3456" />
</label>
<label>
VIN
<input name="vin" maxlength="17" placeholder="17 символов без I/O/Q" />
</label>
<label>
Поколение / кузов
<input name="generation" placeholder="XV70 / CN7" />
</label>
<label>
Тип кузова
<input name="body_type" placeholder="седан / SUV" />
</label>
<label>
Объем двигателя, л
<input name="engine_volume_l" type="number" min="0" step="0.01" />
</label>
<label>
Коробка передач
<select name="transmission">
<option value="">Не задано</option>
<option value="manual">Механика</option>
<option value="automatic">Автомат</option>
<option value="cvt">CVT</option>
<option value="dct">DCT</option>
</select>
</label>
<label>
Привод
<select name="drive_type">
<option value="">Не задано</option>
<option value="fwd">Передний</option>
<option value="rwd">Задний</option>
<option value="awd">Полный</option>
</select>
</label>
<label> <label>
Тип топлива Тип топлива
<select name="fuel_type"> <select name="fuel_type">
@@ -565,6 +682,64 @@
Давление зад, bar Давление зад, bar
<input name="tire_pressure_rear_bar" type="number" min="0" step="0.01" placeholder="2.20" /> <input name="tire_pressure_rear_bar" type="number" min="0" step="0.01" placeholder="2.20" />
</label> </label>
<label>
Размер шин
<input name="tire_size" placeholder="205/55 R16" />
</label>
<label>
Интервал масла, км
<input name="oil_change_interval_km" type="number" min="0" placeholder="10000" />
</label>
<label>
Интервал масла, мес
<input name="oil_change_interval_months" type="number" min="0" placeholder="12" />
</label>
<label>
Стоимость покупки
<input name="purchase_price" type="number" min="0" step="0.01" />
</label>
<label>
Дата покупки
<input name="purchase_date" type="date" />
</label>
<label>
Тип покупки
<select name="purchase_type">
<option value="unknown">Не указано</option>
<option value="cash">Наличные</option>
<option value="credit">Кредит</option>
<option value="lease">Лизинг</option>
<option value="gift">Подарок</option>
</select>
</label>
<label>
Сумма кредита
<input name="loan_principal" type="number" min="0" step="0.01" />
</label>
<label>
Первоначальный взнос
<input name="loan_down_payment" type="number" min="0" step="0.01" />
</label>
<label>
Срок кредита, мес
<input name="loan_term_months" type="number" min="1" />
</label>
<label>
Ставка годовая, %
<input name="loan_annual_interest_rate" type="number" min="0" step="0.001" />
</label>
<label>
Первый платеж
<input name="loan_first_payment_date" type="date" />
</label>
<label class="check">
<input name="include_depreciation" type="checkbox" />
Учитывать амортизацию
</label>
<label>
Заметки
<input name="notes" placeholder="Особенности авто" />
</label>
<button type="submit">Сохранить параметры</button> <button type="submit">Сохранить параметры</button>
</form> </form>
</section> </section>

View File

@@ -319,6 +319,9 @@ const state = {
analytics: null, analytics: null,
serviceCenters: [], serviceCenters: [],
publicServiceCenters: [], publicServiceCenters: [],
confirmations: null,
connectedServices: [],
adminPendingServices: [],
vehicleScore: null, vehicleScore: null,
vehicleTimeline: [], vehicleTimeline: [],
achievements: [], achievements: [],
@@ -512,6 +515,7 @@ async function ensureUser() {
body: JSON.stringify({ init_data: tg.initData }), body: JSON.stringify({ init_data: tg.initData }),
}); });
hideAuthOverlay(); hideAuthOverlay();
updateRoleVisibility();
return; return;
} }
if (state.authConfig?.allow_dev_auth) { if (state.authConfig?.allow_dev_auth) {
@@ -519,6 +523,7 @@ async function ensureUser() {
localStorage.setItem("driversDevTelegramId", devId); localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me"); state.user = await api("/users/me");
hideAuthOverlay(); hideAuthOverlay();
updateRoleVisibility();
return; return;
} }
await showTelegramLogin(); await showTelegramLogin();
@@ -530,6 +535,11 @@ function hideAuthOverlay() {
document.body.classList.remove("auth-required"); document.body.classList.remove("auth-required");
} }
function updateRoleVisibility() {
const isAdmin = ["admin", "verifier", "moderator"].includes(state.user?.platform_role);
document.querySelectorAll(".admin-only").forEach((node) => node.classList.toggle("hidden", !isAdmin));
}
function showTelegramOpenHint() { function showTelegramOpenHint() {
const overlay = document.querySelector("#authOverlay"); const overlay = document.querySelector("#authOverlay");
const slot = document.querySelector("#telegramLoginSlot"); const slot = document.querySelector("#telegramLoginSlot");
@@ -578,6 +588,7 @@ async function showTelegramLogin() {
}); });
localStorage.setItem("driversUser", JSON.stringify(state.user)); localStorage.setItem("driversUser", JSON.stringify(state.user));
hideAuthOverlay(); hideAuthOverlay();
updateRoleVisibility();
await loadCars(); await loadCars();
}; };
const script = document.createElement("script"); const script = document.createElement("script");
@@ -799,7 +810,17 @@ function renderCars() {
} }
function setInputValue(form, name, value) { function setInputValue(form, name, value) {
if (form?.elements[name]) form.elements[name].value = value ?? ""; if (!form?.elements[name]) return;
const input = form.elements[name];
if (input.type === "checkbox") {
input.checked = Boolean(value);
return;
}
input.value = value ?? "";
}
function csvList(value) {
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null;
} }
function fillCarProfileForm() { function fillCarProfileForm() {
@@ -816,6 +837,13 @@ function fillCarProfileForm() {
} }
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name; hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
[ [
"plate_number",
"vin",
"generation",
"body_type",
"engine_volume_l",
"transmission",
"drive_type",
"fuel_type", "fuel_type",
"target_consumption_l_per_100km", "target_consumption_l_per_100km",
"fuel_tank_volume_l", "fuel_tank_volume_l",
@@ -827,9 +855,168 @@ function fillCarProfileForm() {
"brake_fluid_type", "brake_fluid_type",
"tire_pressure_front_bar", "tire_pressure_front_bar",
"tire_pressure_rear_bar", "tire_pressure_rear_bar",
"tire_size",
"oil_change_interval_km",
"oil_change_interval_months",
"purchase_price",
"purchase_date",
"purchase_type",
"loan_principal",
"loan_down_payment",
"loan_term_months",
"loan_annual_interest_rate",
"loan_first_payment_date",
"include_depreciation",
"notes",
].forEach((name) => setInputValue(form, name, car[name])); ].forEach((name) => setInputValue(form, name, car[name]));
} }
async function loadConfirmations() {
const root = document.querySelector("#confirmationRequests");
if (!root) return;
try {
state.confirmations = await api("/my/confirmations");
const visits = state.confirmations.service_visits || [];
const changes = state.confirmations.change_requests || [];
const links = state.confirmations.service_links || [];
if (!visits.length && !changes.length && !links.length) {
root.innerHTML = `<div class="empty">Новых запросов нет</div>`;
return;
}
root.innerHTML = [
...visits.map((visit) => `
<div class="stack-item">
<strong>Визит СТО #${visit.id}</strong>
<small>${visit.visit_date} · ${visit.odometer || "-"} км · ${money(visit.total_cost || 0)}</small>
<div class="row-actions">
<button type="button" data-confirm-visit="${visit.id}">Подтвердить</button>
<button type="button" data-dispute-visit="${visit.id}">Спор</button>
</div>
</div>`),
...changes.map((item) => `
<div class="stack-item">
<strong>Изменение ${item.field_name}</strong>
<small>${item.old_value || "-"}${item.new_value || "-"}</small>
<div class="row-actions">
<button type="button" data-approve-change="${item.id}">Принять</button>
<button type="button" data-reject-change="${item.id}">Отклонить</button>
</div>
</div>`),
...links.map((link) => `
<div class="stack-item">
<strong>Запрос доступа от СТО #${link.service_center_id}</strong>
<small>Авто #${link.car_id} · ${link.access_level}</small>
<div class="row-actions">
<button type="button" data-approve-link="${link.id}">Разрешить</button>
<button type="button" data-revoke-link="${link.id}">Отклонить</button>
</div>
</div>`),
].join("");
bindConfirmationActions(root);
} catch (error) {
root.innerHTML = `<div class="empty">Не удалось загрузить подтверждения</div>`;
}
}
function bindConfirmationActions(root) {
root.querySelectorAll("[data-confirm-visit]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
await api(`/service-visits/${button.dataset.confirmVisit}/confirm`, { method: "POST" });
await loadConfirmations();
await loadSelectedCar();
}));
});
root.querySelectorAll("[data-dispute-visit]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отмечаю спор...", async () => {
await api(`/service-visits/${button.dataset.disputeVisit}/dispute`, { method: "POST" });
await loadConfirmations();
}));
});
root.querySelectorAll("[data-approve-change]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Применяю...", async () => {
await api(`/vehicle-change-requests/${button.dataset.approveChange}/approve`, { method: "POST" });
await loadConfirmations();
await loadCars();
}));
});
root.querySelectorAll("[data-reject-change]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/vehicle-change-requests/${button.dataset.rejectChange}/reject`, { method: "POST" });
await loadConfirmations();
}));
});
root.querySelectorAll("[data-approve-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Разрешаю доступ...", async () => {
await api(`/service-centers/links/${button.dataset.approveLink}/approve`, { method: "POST" });
await loadConfirmations();
await loadConnectedServices();
}));
});
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
await loadConfirmations();
}));
});
}
async function loadConnectedServices() {
const root = document.querySelector("#connectedServices");
if (!root) return;
try {
state.connectedServices = await api("/my/service-links");
root.innerHTML = state.connectedServices.length
? state.connectedServices.map((link) => `
<div class="stack-item">
<strong>${link.service_center_name}</strong>
<small>${link.car_name} · ${link.access_level} · ${link.status}</small>
${link.status === "approved" ? `<button type="button" data-revoke-link="${link.id}">Отозвать доступ</button>` : ""}
</div>`).join("")
: `<div class="empty">Подключенных автосервисов пока нет</div>`;
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отзываю доступ...", async () => {
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
await loadConnectedServices();
}));
});
} catch (error) {
root.innerHTML = `<div class="empty">Не удалось загрузить подключения</div>`;
}
}
async function loadAdminPendingServices() {
const root = document.querySelector("#adminPendingServices");
if (!root) return;
try {
state.adminPendingServices = await api("/admin/service-centers/pending");
root.innerHTML = state.adminPendingServices.length
? state.adminPendingServices.map((center) => `
<div class="stack-item">
<strong>#${center.id} ${center.display_name || center.name}</strong>
<small>${[center.legal_name, center.city, center.address].filter(Boolean).join(" · ") || "Данные не заполнены"}</small>
<small>Документы: ${(center.document_photo_urls || []).length}</small>
<div class="row-actions">
<button type="button" data-admin-action="verify" data-admin-center="${center.id}">Одобрить</button>
<button type="button" data-admin-action="request-changes" data-admin-center="${center.id}">Правки</button>
<button type="button" data-admin-action="reject" data-admin-center="${center.id}">Отклонить</button>
</div>
</div>`).join("")
: `<div class="empty">Pending-заявок нет</div>`;
root.querySelectorAll("[data-admin-action]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Сохраняю решение...", async () => {
const comment = button.dataset.adminAction === "verify" ? "Одобрено" : window.prompt("Комментарий для владельца СТО") || "";
await api(`/admin/service-centers/${button.dataset.adminCenter}/${button.dataset.adminAction}`, {
method: "POST",
body: JSON.stringify({ reason: comment, comment }),
});
await loadAdminPendingServices();
}));
});
} catch (error) {
root.innerHTML = `<div class="empty">Нет доступа или сервер не ответил</div>`;
}
}
function openCarProfile() { function openCarProfile() {
openDrawerSection("carProfileSection"); openDrawerSection("carProfileSection");
} }
@@ -1050,9 +1237,13 @@ function renderStats(stats) {
<button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button> <button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button>
<button class="stat pop" data-report="summary"><span>В месяц</span><strong>${money(stats.cost_per_month || 0)}</strong><em>${t("среднее в периоде")}</em></button> <button class="stat pop" data-report="summary"><span>В месяц</span><strong>${money(stats.cost_per_month || 0)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="summary"><span>Прогноз</span><strong>${money(stats.forecast_next_month || 0)}</strong><em>ближайший месяц</em></button> <button class="stat pop" data-report="summary"><span>Прогноз</span><strong>${money(stats.forecast_next_month || 0)}</strong><em>ближайший месяц</em></button>
<button class="stat pop" data-report="summary"><span>Фиксированные</span><strong>${money(stats.fixed_costs || 0)}</strong><em>страховка, налоги, кредит</em></button>
<button class="stat pop" data-report="summary"><span>Переменные</span><strong>${money(stats.variable_costs || 0)}</strong><em>топливо, ремонт, услуги</em></button>
<button class="stat pop" data-report="summary"><span>Кредит</span><strong>${money((Number(stats.loan_principal_cost || 0) + Number(stats.loan_interest_cost || 0)))}</strong><em>тело и проценты</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button> <button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button> <button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button> <button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button>
${stats.cost_warning ? `<div class="stat wide warning"><span>Предупреждение</span><strong>${stats.cost_warning}</strong><em>мягкая проверка расходов</em></div>` : ""}
`; `;
root.querySelectorAll("[data-report]").forEach((button) => { root.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => openReport(button.dataset.report)); button.addEventListener("click", () => openReport(button.dataset.report));
@@ -1265,9 +1456,12 @@ function openReport(type = "summary") {
${reportMetric(t("Пробег"), `${stats.distance_km} км`)} ${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")} ${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")}
${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")} ${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")}
${reportMetric("Средний полный бак", analytics?.average_full_tank_distance ? `${analytics.average_full_tank_distance} км` : "-")}
${reportMetric("Средний бак", analytics?.average_cost_per_full_tank ? money(analytics.average_cost_per_full_tank) : "-")}
${reportMetric(t("Текущая цена"), analytics?.current_price_per_liter ? `${formatFuelPrice(analytics.current_price_per_liter)} / л` : "-")} ${reportMetric(t("Текущая цена"), analytics?.current_price_per_liter ? `${formatFuelPrice(analytics.current_price_per_liter)} / л` : "-")}
${reportMetric(t("Прогноз цены"), analytics?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")} ${reportMetric(t("Прогноз цены"), analytics?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")}
</div> </div>
${analytics?.full_tank_warning ? `<div class="tip-card warning">${analytics.full_tank_warning}</div>` : ""}
<div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div> <div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div>
`, `,
}; };
@@ -1589,7 +1783,15 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => {
model: data.model || null, model: data.model || null,
trim: data.trim || null, trim: data.trim || null,
year: data.year ? Number(data.year) : null, year: data.year ? Number(data.year) : null,
plate_number: data.plate_number || null,
vin: data.vin || null,
current_odometer: numberOrNull(data.current_odometer),
fuel_type: data.fuel_type || null, fuel_type: data.fuel_type || null,
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
purchase_currency: state.user?.currency || "RUB",
currency: state.user?.currency || "RUB",
}), }),
}); });
form.reset(); form.reset();
@@ -1614,6 +1816,13 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even
const updated = await api(`/cars/${car.id}`, { const updated = await api(`/cars/${car.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
plate_number: data.plate_number || null,
vin: data.vin || null,
generation: data.generation || null,
body_type: data.body_type || null,
engine_volume_l: numberOrNull(data.engine_volume_l),
transmission: data.transmission || null,
drive_type: data.drive_type || null,
fuel_type: data.fuel_type || null, fuel_type: data.fuel_type || null,
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km), target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l), fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
@@ -1625,6 +1834,20 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even
brake_fluid_type: data.brake_fluid_type || null, brake_fluid_type: data.brake_fluid_type || null,
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar), tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar), tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
tire_size: data.tire_size || null,
oil_change_interval_km: numberOrNull(data.oil_change_interval_km),
oil_change_interval_months: numberOrNull(data.oil_change_interval_months),
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
include_depreciation: Boolean(data.include_depreciation),
loan_principal: numberOrNull(data.loan_principal),
loan_down_payment: numberOrNull(data.loan_down_payment),
loan_term_months: numberOrNull(data.loan_term_months),
loan_annual_interest_rate: numberOrNull(data.loan_annual_interest_rate),
loan_first_payment_date: data.loan_first_payment_date || null,
loan_currency: state.user?.currency || car.currency || "RUB",
notes: data.notes || null,
}), }),
}); });
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item)); state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
@@ -1730,6 +1953,11 @@ document.querySelector("#expenseForm").addEventListener("submit", async (event)
odometer: numberOrNull(data.odometer), odometer: numberOrNull(data.odometer),
period_start: data.period_start || null, period_start: data.period_start || null,
period_end: data.period_end || null, period_end: data.period_end || null,
period_months: numberOrNull(data.period_months),
payment_period_months: numberOrNull(data.period_months),
policy_number: data.policy_number || null,
insurance_type: data.insurance_type || null,
notes: data.notes || null,
is_recurring: Boolean(data.is_recurring), is_recurring: Boolean(data.is_recurring),
}), }),
}); });
@@ -1792,11 +2020,12 @@ async function openDrawerSection(sectionId, options = {}) {
: "Напомним о ТО, страховке и регулярном внесении пробега.", : "Напомним о ТО, страховке и регулярном внесении пробега.",
); );
} }
if (sectionId === "confirmationsSection") renderPlaceholderList("#confirmationRequests", "Новых запросов нет"); if (sectionId === "confirmationsSection") await loadConfirmations();
if (sectionId === "connectedServicesSection") renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет"); if (sectionId === "connectedServicesSection") await loadConnectedServices();
if (sectionId === "servicePanelSection") await loadServiceCenters(); if (sectionId === "servicePanelSection") await loadServiceCenters();
if (sectionId === "publicServicesSection") await loadPublicServiceCenters(); if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
if (sectionId === "reviewsSection") renderServiceReviews(); if (sectionId === "reviewsSection") renderServiceReviews();
if (sectionId === "adminSection") await loadAdminPendingServices();
if (options.expenseCategory) { if (options.expenseCategory) {
openDrawerSection("expensesSection"); openDrawerSection("expensesSection");
presetExpense(options.expenseCategory); presetExpense(options.expenseCategory);
@@ -1809,7 +2038,11 @@ function presetExpense(category) {
const form = document.querySelector("#expenseForm"); const form = document.querySelector("#expenseForm");
form.category.value = category; form.category.value = category;
form.title.value = expenseLabel(category); form.title.value = expenseLabel(category);
if (category === "insurance") form.is_recurring.checked = true; form.is_recurring.checked = category === "insurance" || category === "tax";
if (category === "insurance") {
form.period_months.value = "12";
form.insurance_type.value = "mandatory";
}
} }
document.querySelectorAll("[data-action]").forEach((button) => { document.querySelectorAll("[data-action]").forEach((button) => {
@@ -1888,6 +2121,9 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e
: null, : null,
working_hours: data.working_hours || null, working_hours: data.working_hours || null,
business_registration_number: data.business_registration_number || null, business_registration_number: data.business_registration_number || null,
facade_photo_url: data.facade_photo_url || null,
document_photo_urls: csvList(data.document_photo_urls),
additional_photo_urls: csvList(data.additional_photo_urls),
}), }),
}); });
form.reset(); form.reset();

View File

@@ -659,7 +659,7 @@ h2 {
.grid-form, .grid-form,
.entry-form { .entry-form {
display: grid; display: grid;
grid-template-columns: 1.1fr 1fr 1fr 120px auto; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px; gap: 12px;
align-items: end; align-items: end;
} }
@@ -793,6 +793,15 @@ select:disabled {
background: var(--soft); background: var(--soft);
} }
.stat.wide {
grid-column: 1 / -1;
}
.warning {
border-color: rgba(210, 141, 38, 0.35);
background: #fff6e7;
}
.stat strong { .stat strong {
display: block; display: block;
margin-top: 6px; margin-top: 6px;
@@ -1457,6 +1466,23 @@ select {
color: var(--muted); color: var(--muted);
} }
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.row-actions button,
.stack-item > button {
min-height: 34px;
border: 1px solid var(--line);
border-radius: 7px;
background: #fff;
color: var(--text);
padding: 0 10px;
}
.trust-badge { .trust-badge {
width: fit-content; width: fit-content;
padding: 5px 8px; padding: 5px 8px;