Complete CarPass product flows
This commit is contained in:
17
README.md
17
README.md
@@ -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 превращает хаотичные чеки и заметки в понятную картину расходов и обслуживания. Для сервиса это аккуратный канал взаимодействия с клиентом, подтвержденная история работ и доверие без лишнего доступа к персональным данным.
|
||||||
|
|||||||
@@ -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")
|
||||||
151
app/api/admin.py
151
app/api/admin.py
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
126
app/api/my.py
126
app/api/my.py
@@ -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
55
app/api/parser.py
Normal 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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
"""Shared SQLAlchemy declarative metadata."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
94
app/services/loans.py
Normal 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
157
app/services/odometer.py
Normal 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,
|
||||||
|
)
|
||||||
193
app/services/record_parser.py
Normal file
193
app/services/record_parser.py
Normal 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"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {},
|
||||||
|
)
|
||||||
|
|||||||
511
bot/main.py
511
bot/main.py
@@ -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
|
||||||
|
try:
|
||||||
car = await api.create_car(user["id"], name, message.from_user.id)
|
car = await api.create_car(user["id"], name, message.from_user.id)
|
||||||
await message.answer(f"Добавил авто: {car['name']}")
|
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 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("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
)
|
||||||
|
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])
|
||||||
|
try:
|
||||||
stats = await api.stats(car_id, callback.from_user.id)
|
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"Расходы всего: {stats['total_cost']}",
|
f"Расходы всего: {money(stats['total_cost'])}",
|
||||||
f"Топливо: {stats['fuel_cost']}",
|
f"Фиксированные: {money(stats.get('fixed_costs'))}",
|
||||||
f"Сервис и ремонты: {stats['service_cost']}",
|
f"Переменные: {money(stats.get('variable_costs'))}",
|
||||||
|
f"Топливо: {money(stats['fuel_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")
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
314
tests/test_product_readiness.py
Normal file
314
tests/test_product_readiness.py
Normal 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"
|
||||||
175
web/index.html
175
web/index.html
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user