From c0014ab4ea1ef89a0b237a4d7428fe599f63fa83 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Thu, 14 May 2026 21:19:37 +0900 Subject: [PATCH] Complete CarPass product flows --- README.md | 17 +- ...002_vehicle_finance_odometer_moderation.py | 112 ++++ app/api/admin.py | 151 ++++- app/api/cars.py | 31 +- app/api/entries.py | 164 ++++-- app/api/my.py | 126 ++++- app/api/parser.py | 55 ++ app/api/service_centers.py | 52 ++ app/api/service_visits.py | 11 +- app/db/base.py | 2 +- app/main.py | 2 + app/models/car.py | 40 ++ app/models/expense.py | 9 +- app/schemas/car.py | 58 +- app/schemas/expense.py | 88 ++- app/schemas/service_center.py | 92 +++ app/services/calculations.py | 262 ++++++++- app/services/loans.py | 94 ++++ app/services/odometer.py | 157 ++++++ app/services/record_parser.py | 193 +++++++ app/services/scoring.py | 89 +++ bot/api_client.py | 66 +++ bot/main.py | 529 ++++++++++++++++-- tests/test_entries.py | 4 +- tests/test_product_readiness.py | 314 +++++++++++ web/index.html | 175 ++++++ web/static/app.js | 244 +++++++- web/static/styles.css | 28 +- 28 files changed, 3006 insertions(+), 159 deletions(-) create mode 100644 alembic/versions/202605140002_vehicle_finance_odometer_moderation.py create mode 100644 app/api/parser.py create mode 100644 app/services/loans.py create mode 100644 app/services/odometer.py create mode 100644 app/services/record_parser.py create mode 100644 tests/test_product_readiness.py diff --git a/README.md b/README.md index c3ebd51..32e61c0 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,20 @@ CarPass — цифровой паспорт автомобиля в Telegram. О - Все автомобили в одном гараже. - Заправки, ТО, ремонт, страховка, налоги, штрафы, парковки, мойки и другие расходы. - Стоимость владения за период, стоимость 1 км и прогноз ближайших расходов. -- Расход топлива по корректным полным бакам. +- Разделение расходов на фиксированные и переменные: топливо, ремонт, страховка, налоги, штрафы, кредит и другие категории. +- Кредитный калькулятор: ежемесячный платеж, проценты, переплата и влияние кредита на стоимость владения. +- Расход топлива по корректным интервалам между полными баками и мягкие предупреждения, если запас хода резко снизился. - Мягкий прогресс заполнения профиля авто: VIN, госномер, пробег, масло, параметры обслуживания. - Бейджи качества истории без игровых очков и токсичных рейтингов. - Напоминания о ТО, страховке и важных событиях. -- OCR чеков: фото или файл распознается, затем пользователь проверяет данные перед сохранением. +- OCR чеков и разбор свободного текста: пользователь проверяет найденные данные перед сохранением. +- История одометра: пробег обновляется из записей, а спорные значения требуют подтверждения. ## Для СТО - Регистрация автосервиса через Mini App. - Заявка на проверку и статус модерации. +- Модерация заявок: approved, rejected, needs changes, suspended. - Публичная карточка СТО после подтверждения. - Отзывы, рейтинг и ответы сервиса. - Запрос доступа к конкретному автомобилю только с подтверждением владельца. @@ -30,6 +34,15 @@ CarPass не раскрывает историю автомобиля по од Mini App открывается через кнопку внутри Telegram-бота. Так Telegram передает защищенную авторизацию, а гараж привязывается к аккаунту пользователя. Если страницу открыть напрямую в браузере, CarPass покажет понятное приглашение открыть приложение через Telegram. +## Команды бота + +- `/start` и `/menu` — правильный вход в Mini App. +- `/garage` — список автомобилей. +- `/add_car` — быстрое добавление авто. +- `/fuel`, `/service`, `/insurance`, `/tax`, `/fine` — быстрые записи текстом. +- `/analytics` — стоимость владения и расход. +- `/sto`, `/register_sto` — каталог и регистрация СТО. + ## Почему это полезно Для владельца CarPass превращает хаотичные чеки и заметки в понятную картину расходов и обслуживания. Для сервиса это аккуратный канал взаимодействия с клиентом, подтвержденная история работ и доверие без лишнего доступа к персональным данным. diff --git a/alembic/versions/202605140002_vehicle_finance_odometer_moderation.py b/alembic/versions/202605140002_vehicle_finance_odometer_moderation.py new file mode 100644 index 0000000..ab7030e --- /dev/null +++ b/alembic/versions/202605140002_vehicle_finance_odometer_moderation.py @@ -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") diff --git a/app/api/admin.py b/app/api/admin.py index 692b24d..a3e0c3a 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,14 +1,22 @@ from datetime import UTC, datetime +import httpx from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.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.schemas.service_center import ServiceCenterRead, ServiceVisitRead +from app.schemas.service_center import AdminModerationDecision, ServiceCenterRead, ServiceVisitRead router = APIRouter(prefix="/admin", tags=["admin"]) @@ -31,9 +39,23 @@ async def pending_service_centers( 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) async def verify_service_center( service_center_id: int, + payload: AdminModerationDecision | None = None, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceCenter: @@ -43,8 +65,21 @@ async def verify_service_center( raise HTTPException(status_code=404, detail="Service center not found") center.verification_status = "approved" center.verified_at = datetime.now(UTC) - await mark_latest_verification(session, center.id, "approved", current_user.id) - await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id) + if center.owner_user_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.refresh(center) return center @@ -53,6 +88,7 @@ async def verify_service_center( @router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead) async def reject_service_center( service_center_id: int, + payload: AdminModerationDecision | None = None, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceCenter: @@ -61,8 +97,20 @@ async def reject_service_center( if center is None: raise HTTPException(status_code=404, detail="Service center not found") center.verification_status = "rejected" - await mark_latest_verification(session, center.id, "rejected", current_user.id) - await log_audit(session, actor=current_user, action="service_center.reject", 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 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.refresh(center) return center @@ -71,6 +119,7 @@ async def reject_service_center( @router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead) async def suspend_service_center( service_center_id: int, + payload: AdminModerationDecision | None = None, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceCenter: @@ -80,7 +129,50 @@ async def suspend_service_center( raise HTTPException(status_code=404, detail="Service center not found") center.verification_status = "suspended" 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.refresh(center) return center @@ -126,7 +218,11 @@ async def disputes( 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: result = await session.execute( select(ServiceCenterVerification) @@ -139,3 +235,42 @@ async def mark_latest_verification( verification.status = status verification.reviewed_by = reviewed_by 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 diff --git a/app/api/cars.py b/app/api/cars.py index 24e3e26..f4a423b 100644 --- a/app/api/cars.py +++ b/app/api/cars.py @@ -4,9 +4,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_telegram_user from app.db.session import get_session -from app.models.car import Car +from app.models.car import Car, VehicleAccess from app.models.user import User 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 router = APIRouter(prefix="/cars", tags=["cars"]) @@ -30,6 +31,17 @@ async def create_car( data = apply_identity_fields(payload.model_dump(exclude={"owner_id"})) car = Car(**data, owner_id=current_user.id) 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.refresh(car) return car @@ -75,8 +87,23 @@ async def update_car( raise HTTPException(status_code=404, detail="Car not found") if car.owner_id != current_user.id: 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) + 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.refresh(car) return car diff --git a/app/api/entries.py b/app/api/entries.py index 785695d..c1d53ee 100644 --- a/app/api/entries.py +++ b/app/api/entries.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_telegram_user from app.db.session import get_session -from app.models.car import Car +from app.models.car import Car, OdometerHistory from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry from app.models.user import User from app.schemas.expense import ( @@ -18,6 +18,7 @@ from app.schemas.expense import ( FuelEntryCreate, FuelEntryRead, FuelEntryUpdate, + OdometerHistoryRead, OdometerPrediction, OwnershipStats, ServiceEntryCreate, @@ -25,6 +26,11 @@ from app.schemas.expense import ( ServiceEntryUpdate, ) 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"]) @@ -47,40 +53,6 @@ async def ensure_entry_owner( 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) async def create_fuel_entry( payload: FuelEntryCreate, @@ -88,10 +60,24 @@ async def create_fuel_entry( current_user: User = Depends(get_current_telegram_user), ) -> FuelEntry: 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) - if car.current_odometer is None or payload.odometer > car.current_odometer: - car.current_odometer = payload.odometer + await session.flush() + 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.refresh(entry) return entry @@ -129,13 +115,21 @@ async def update_fuel_entry( current_user: User = Depends(get_current_telegram_user), ) -> FuelEntry: 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) if payload.total_cost is None and ( payload.liters is not None or payload.price_per_liter is not None ): 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.refresh(entry) return entry @@ -151,7 +145,7 @@ async def delete_fuel_entry( car_id = entry.car_id await session.delete(entry) 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() @@ -162,10 +156,24 @@ async def create_service_entry( current_user: User = Depends(get_current_telegram_user), ) -> ServiceEntry: 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) - if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer): - car.current_odometer = payload.odometer + await session.flush() + 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.refresh(entry) return entry @@ -203,9 +211,17 @@ async def update_service_entry( current_user: User = Depends(get_current_telegram_user), ) -> ServiceEntry: 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) - 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.refresh(entry) return entry @@ -221,7 +237,7 @@ async def delete_service_entry( car_id = entry.car_id await session.delete(entry) 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() @@ -232,10 +248,24 @@ async def create_expense_entry( current_user: User = Depends(get_current_telegram_user), ) -> ExpenseEntry: car = await ensure_owned_car(session, payload.car_id, current_user) - entry = ExpenseEntry(**payload.model_dump()) + 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) - if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer): - car.current_odometer = payload.odometer + await session.flush() + 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.refresh(entry) return entry @@ -276,9 +306,17 @@ async def update_expense_entry( current_user: User = Depends(get_current_telegram_user), ) -> ExpenseEntry: entry = await ensure_entry_owner(session, await session.get(ExpenseEntry, entry_id), current_user) - for field, value in payload.model_dump(exclude_unset=True).items(): + 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) - 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.refresh(entry) return entry @@ -294,10 +332,30 @@ async def delete_expense_entry( car_id = entry.car_id await session.delete(entry) 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() +@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) async def car_stats( car_id: int, diff --git a/app/api/my.py b/app/api/my.py index 3658541..edfa920 100644 --- a/app/api/my.py +++ b/app/api/my.py @@ -1,11 +1,18 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.encoders import jsonable_encoder -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_telegram_user, log_audit 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.schemas.service_center import ( VehicleAccessGrant, @@ -15,6 +22,7 @@ from app.schemas.service_center import ( VehicleUpdate, ) 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 router = APIRouter(tags=["my"]) @@ -53,8 +61,13 @@ async def my_vehicles( ) -> list[Car]: result = await session.execute( select(Car) - .join(VehicleAccess, VehicleAccess.vehicle_id == Car.id) - .where(VehicleAccess.user_id == current_user.id, VehicleAccess.status == "active") + .outerjoin(VehicleAccess, VehicleAccess.vehicle_id == Car.id) + .where( + or_( + Car.owner_id == current_user.id, + (VehicleAccess.user_id == current_user.id) & (VehicleAccess.status == "active"), + ) + ) .order_by(Car.created_at.desc()) ) return list(result.scalars()) @@ -70,6 +83,15 @@ async def create_vehicle( 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 log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id) await session.commit() await session.refresh(car) @@ -88,8 +110,23 @@ async def update_vehicle( raise HTTPException(status_code=404, detail="Vehicle not found") if car.owner_id != current_user.id: 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) + 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 session.commit() await session.refresh(car) @@ -116,6 +153,85 @@ async def vehicle_service_history( 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) async def grant_vehicle_access( vehicle_id: int, diff --git a/app/api/parser.py b/app/api/parser.py new file mode 100644 index 0000000..d5b1d62 --- /dev/null +++ b/app/api/parser.py @@ -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 + ], + } diff --git a/app/api/service_centers.py b/app/api/service_centers.py index db3cf6c..2c4ff4a 100644 --- a/app/api/service_centers.py +++ b/app/api/service_centers.py @@ -46,6 +46,7 @@ from app.schemas.service_center import ( VehicleSearchRequest, VehicleSearchResult, ) +from app.services.odometer import validate_odometer_change from app.services.vehicle_identity import mask_license_plate, mask_vin router = APIRouter(prefix="/service-centers", tags=["service-centers"]) @@ -81,6 +82,18 @@ async def create_service_center( ) session.add(center) 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( service_center_id=center.id, user_id=current_user.id, @@ -94,6 +107,44 @@ async def create_service_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]) async def my_service_centers( session: AsyncSession = Depends(get_session), @@ -287,6 +338,7 @@ async def create_visit( vehicle = await session.get(Car, payload.vehicle_id) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") + validate_odometer_change(vehicle, payload.odometer, source_record_type="service_visit") await ensure_service_center_approved(session, service_center_id) await ensure_center_vehicle_access(session, service_center_id, vehicle, current_user) visit = ServiceVisit( diff --git a/app/api/service_visits.py b/app/api/service_visits.py index b4ce084..60c2555 100644 --- a/app/api/service_visits.py +++ b/app/api/service_visits.py @@ -14,6 +14,7 @@ from app.schemas.service_center import ( VehicleDataChangeRequestCreate, VehicleDataChangeRequestRead, ) +from app.services.odometer import apply_odometer_from_record from app.services.vehicle_identity import normalize_license_plate, validate_vin router = APIRouter(prefix="/service-visits", tags=["service-visits"]) @@ -83,8 +84,14 @@ async def confirm_visit( raise HTTPException(status_code=403, detail="Forbidden") visit.status = "confirmed" visit.owner_resolved_at = datetime.now(UTC) - if visit.odometer and (vehicle.current_odometer is None or visit.odometer > vehicle.current_odometer): - vehicle.current_odometer = visit.odometer + await apply_odometer_from_record( + 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 session.commit() await session.refresh(visit) diff --git a/app/db/base.py b/app/db/base.py index fa2b68a..2aed3e2 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - pass + """Shared SQLAlchemy declarative metadata.""" diff --git a/app/main.py b/app/main.py index b80abfa..2a742cb 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,7 @@ from app.api import ( gamification, my, ocr, + parser, service_centers, service_visits, users, @@ -37,6 +38,7 @@ app.include_router(cars.router, prefix="/api") app.include_router(entries.router, prefix="/api") app.include_router(gamification.router, prefix="/api") app.include_router(ocr.router, prefix="/api") +app.include_router(parser.router, prefix="/api") app.include_router(service_centers.router, prefix="/api") app.include_router(service_visits.router, prefix="/api") app.include_router(change_requests.router, prefix="/api") diff --git a/app/models/car.py b/app/models/car.py index 2d1e6d6..48a7547 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -28,6 +28,8 @@ class Car(Base): make: Mapped[str | None] = mapped_column(String(80)) model: Mapped[str | None] = mapped_column(String(80)) 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] plate_number: 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) vin_normalized: Mapped[str | None] = mapped_column(String(17), unique=True, index=True) 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)) fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2)) 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)) 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_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_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") 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] + notes: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() @@ -61,6 +83,7 @@ class Car(Base): service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan") expense_entries = relationship("ExpenseEntry", back_populates="car", cascade="all, delete-orphan") service_links = relationship("CarServiceLink", back_populates="car", cascade="all, delete-orphan") + odometer_history = relationship("OdometerHistory", back_populates="car", cascade="all, delete-orphan") class CarMake(Base): @@ -163,6 +186,23 @@ class CarServiceLink(Base): 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): __tablename__ = "service_inbox_messages" diff --git a/app/models/expense.py b/app/models/expense.py index bc335c3..732009f 100644 --- a/app/models/expense.py +++ b/app/models/expense.py @@ -2,7 +2,7 @@ import enum from datetime import date, datetime 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 app.db.base import Base @@ -54,7 +54,7 @@ class FuelEntry(Base): total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2)) station: Mapped[str | None] = mapped_column(String(160)) 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) 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_months: Mapped[int | None] 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/schemas/car.py b/app/schemas/car.py index c28a129..dc49532 100644 --- a/app/schemas/car.py +++ b/app/schemas/car.py @@ -1,7 +1,9 @@ from datetime import date, datetime 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): @@ -9,10 +11,15 @@ class CarBase(BaseModel): make: str | None = None model: str | None = None trim: str | None = None + generation: str | None = None + body_type: str | None = None year: int | None = None plate_number: str | None = None vin: 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 fuel_tank_volume_l: Decimal | None = None engine_oil_type: str | None = None @@ -23,11 +30,33 @@ class CarBase(BaseModel): 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 purchase_date: date | None = None purchase_price: Decimal | None = None + purchase_currency: str | None = None + purchase_type: str = "unknown" currency: str = "RUB" 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 + 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): @@ -39,10 +68,15 @@ class CarUpdate(BaseModel): make: str | None = None model: str | None = None trim: str | None = None + generation: str | None = None + body_type: str | None = None year: int | None = None plate_number: str | None = None vin: 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 fuel_tank_volume_l: Decimal | None = None engine_oil_type: str | None = None @@ -53,11 +87,33 @@ class CarUpdate(BaseModel): 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 purchase_date: date | None = None purchase_price: Decimal | None = None + purchase_currency: str | None = None + purchase_type: str | None = None currency: str | 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 + 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): diff --git a/app/schemas/expense.py b/app/schemas/expense.py index a0e81e0..9855791 100644 --- a/app/schemas/expense.py +++ b/app/schemas/expense.py @@ -1,7 +1,7 @@ from datetime import date, datetime 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 @@ -14,18 +14,27 @@ class FuelEntryBase(BaseModel): total_cost: Decimal | None = None station: str | None = None fuel_brand: str | None = None - is_full_tank: bool = True + is_full_tank: bool | None = None notes: str | None = None @model_validator(mode="after") 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: self.total_cost = self.liters * self.price_per_liter + if self.total_cost <= 0: + raise ValueError("total_cost must be positive") return self class FuelEntryCreate(FuelEntryBase): car_id: int + confirm_lower_odometer: bool = False class FuelEntryUpdate(BaseModel): @@ -38,6 +47,7 @@ class FuelEntryUpdate(BaseModel): fuel_brand: str | None = None is_full_tank: bool | None = None notes: str | None = None + confirm_lower_odometer: bool = False class FuelEntryRead(FuelEntryBase): @@ -61,9 +71,20 @@ class ServiceEntryBase(BaseModel): next_due_odometer: int | 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): car_id: int + confirm_lower_odometer: bool = False class ServiceEntryUpdate(BaseModel): @@ -77,6 +98,7 @@ class ServiceEntryUpdate(BaseModel): next_due_date: date | None = None next_due_odometer: int | None = None notes: str | None = None + confirm_lower_odometer: bool = False class ServiceEntryRead(ServiceEntryBase): @@ -99,19 +121,36 @@ class ExpenseEntryBase(BaseModel): period_end: date | None = None period_months: int | None = None 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 @model_validator(mode="after") 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: 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: 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 class ExpenseEntryCreate(ExpenseEntryBase): car_id: int + confirm_lower_odometer: bool = False class ExpenseEntryUpdate(BaseModel): @@ -126,7 +165,20 @@ class ExpenseEntryUpdate(BaseModel): period_end: date | None = None period_months: int | 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 + 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): @@ -151,11 +203,23 @@ class OwnershipStats(BaseModel): service_cost: Decimal total_cost: Decimal 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") one_time_costs: Decimal = Decimal("0") forecast_next_month: 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") + 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) categories: list[OwnershipCategoryBreakdown] = Field(default_factory=list) liters: Decimal @@ -179,5 +243,25 @@ class OdometerPrediction(BaseModel): avg_price_per_liter: float | None = None price_samples: int = 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 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) diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py index 36ba829..ebdf2b5 100644 --- a/app/schemas/service_center.py +++ b/app/schemas/service_center.py @@ -114,20 +114,49 @@ class VehicleCreate(BaseModel): name: str make: str | None = None model: str | None = None + trim: str | None = None + generation: str | None = None + body_type: str | None = None year: int | None = None license_plate: str | None = None license_plate_country: str | None = None vin: str | None = None current_odometer: int | 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_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 target_consumption_l_per_100km: Decimal | None = None purchase_date: date | None = None purchase_price: Decimal | None = None + purchase_currency: str | None = None + purchase_type: str = "unknown" currency: str = "RUB" 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") @classmethod @@ -139,20 +168,49 @@ class VehicleUpdate(BaseModel): name: str | None = None make: str | None = None model: str | None = None + trim: str | None = None + generation: str | None = None + body_type: str | None = None year: int | None = None license_plate: str | None = None license_plate_country: str | None = None vin: str | None = None current_odometer: int | 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 target_consumption_l_per_100km: Decimal | None = None purchase_date: date | None = None purchase_price: Decimal | None = None + purchase_currency: str | None = None + purchase_type: str | None = None currency: str | 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_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") @classmethod @@ -166,20 +224,49 @@ class VehicleRead(BaseModel): name: str make: str | None = None model: str | None = None + trim: str | None = None + generation: str | None = None + body_type: str | None = None year: int | None = None license_plate_display: str | None = None license_plate_country: str | None = None vin_normalized: str | None = None current_odometer: int | 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 target_consumption_l_per_100km: Decimal | None = None purchase_date: date | None = None purchase_price: Decimal | None = None + purchase_currency: str | None = None + purchase_type: str = "unknown" currency: str = "RUB" include_depreciation: bool = False + expected_ownership_months: int | None = None + expected_residual_value: Decimal | None = None engine_oil_type: str | 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 model_config = ConfigDict(from_attributes=True) @@ -358,4 +445,9 @@ class ServiceInboxRead(ServiceInboxCreate): error: str | None = None created_at: datetime + +class AdminModerationDecision(BaseModel): + comment: str | None = None + reason: str | None = None + model_config = ConfigDict(from_attributes=True) diff --git a/app/services/calculations.py b/app/services/calculations.py index ef73311..179768a 100644 --- a/app/services/calculations.py +++ b/app/services/calculations.py @@ -3,12 +3,38 @@ from datetime import date, timedelta from decimal import Decimal import pandas as pd -from sqlalchemy import Select, func, or_, select +from sqlalchemy import Select, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.car import Car from app.models.expense import ExpenseCategory, ExpenseEntry, FuelEntry, ServiceEntry 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( @@ -60,21 +86,39 @@ async def get_ownership_stats( odometer_values = [value for value in odometer_values if value is not None] distance_km = int(max(odometer_values) - min(odometer_values)) if len(odometer_values) >= 2 else 0 - expense_cost, recurring_cost, _expense_count, expense_categories = await expense_period_totals( + ( + 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 ) car = await session.get(Car, car_id) 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 - avg_consumption = await full_tank_consumption(session, car_id, date_from, date_to) + total_cost = Decimal(fuel_cost) + Decimal(service_cost) + expense_cost + depreciation_cost + loan_principal_cost + loan_interest_cost + 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 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")) - 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")) recurring_monthly = (recurring_total / months).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 = { "fuel": Decimal(fuel_cost), @@ -83,11 +127,22 @@ async def get_ownership_stats( } if 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 = [ {"category": key, "total_cost": value, "entries_count": 0} for key, value in sorted(cost_by_category.items()) 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( car_id=car_id, @@ -97,11 +152,23 @@ async def get_ownership_stats( service_cost=service_cost, expenses_cost=expense_cost, total_cost=total_cost, + repair_cost=repair_cost, + fixed_costs=fixed_costs, + variable_costs=variable_costs, recurring_costs=recurring_total, one_time_costs=one_time_costs, forecast_next_month=forecast_next_month, 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, + 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, categories=categories, 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: + 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) total_days = period_days(start, end) 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")) +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( 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( select(ExpenseEntry) .where( ExpenseEntry.car_id == car_id, - or_( - ExpenseEntry.entry_date.between(date_from, date_to), - ExpenseEntry.period_start.between(date_from, date_to), - ExpenseEntry.period_end.between(date_from, date_to), - (ExpenseEntry.period_start <= date_from) & (ExpenseEntry.period_end >= date_to), - ), + ExpenseEntry.entry_date <= date_to, ) .order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc()) ) total = Decimal("0") recurring = Decimal("0") + fixed = Decimal("0") + variable = Decimal("0") categories: dict[str, Decimal] = {} count = 0 for entry in result.scalars(): @@ -182,26 +277,104 @@ async def expense_period_totals( total += amount category = entry.category.value if isinstance(entry.category, ExpenseCategory) else str(entry.category) categories[category] = categories.get(category, Decimal("0")) + amount - if entry.is_recurring or entry.category in {ExpenseCategory.insurance, ExpenseCategory.loan_payment, ExpenseCategory.loan_interest}: + if entry.is_recurring or entry.category in FIXED_EXPENSE_CATEGORIES: 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: if not car.include_depreciation or not car.purchase_price or not car.purchase_date: return Decimal("0") depreciation_start = car.purchase_date - depreciation_end = add_months(car.purchase_date, 60) - timedelta(days=1) + 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) if matched_days <= 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")) +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( session: AsyncSession, car_id: int, date_from: date, date_to: date ) -> 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( select(FuelEntry) .where( @@ -213,10 +386,15 @@ async def full_tank_consumption( entries = list(result.scalars()) full_indexes = [index for index, entry in enumerate(entries) if entry.is_full_tank] 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") - total_distance = 0 + intervals: list[dict] = [] previous_full_index = full_indexes[0] for current_full_index in full_indexes[1:]: 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] ) if interval_liters > 0: - total_liters += interval_liters - total_distance += distance + interval_cost = sum( + 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 - if total_distance <= 0 or total_liters <= 0: - return None - return float(total_liters * Decimal(100) / Decimal(total_distance)) + if not intervals: + 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_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: @@ -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: 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( session, 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_month=None, **price_prediction, + **tank_prediction, confidence=0, insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.", ) @@ -291,6 +503,7 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic avg_km_per_day=None, avg_km_per_month=None, **price_prediction, + **tank_prediction, confidence=0.2, 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_month=round(km_per_day * 30.4, 1), **price_prediction, + **tank_prediction, confidence=round(confidence, 2), insight=insight, ) diff --git a/app/services/loans.py b/app/services/loans.py new file mode 100644 index 0000000..293feb7 --- /dev/null +++ b/app/services/loans.py @@ -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) diff --git a/app/services/odometer.py b/app/services/odometer.py new file mode 100644 index 0000000..103e5c5 --- /dev/null +++ b/app/services/odometer.py @@ -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, + ) diff --git a/app/services/record_parser.py b/app/services/record_parser.py new file mode 100644 index 0000000..73eeab4 --- /dev/null +++ b/app/services/record_parser.py @@ -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" diff --git a/app/services/scoring.py b/app/services/scoring.py index a41a244..40e4ee2 100644 --- a/app/services/scoring.py +++ b/app/services/scoring.py @@ -36,6 +36,54 @@ class MissingItem: 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", "scope": "vehicle", @@ -371,6 +419,47 @@ async def evaluate_vehicle_achievements( visits: list[ServiceVisit], ) -> None: 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: await unlock_achievement( session, diff --git a/bot/api_client.py b/bot/api_client.py index 958f3fd..b6a7939 100644 --- a/bot/api_client.py +++ b/bot/api_client.py @@ -15,6 +15,28 @@ class ApiClient: headers["X-Telegram-User-Id"] = str(telegram_id) 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]: payload = { "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.raise_for_status() 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 {}, + ) diff --git a/bot/main.py b/bot/main.py index f62422a..d66cc2b 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,6 +1,9 @@ import asyncio import logging +from datetime import date +from decimal import Decimal +import httpx from aiogram import Bot, Dispatcher, F from aiogram.filters import Command, CommandObject from aiogram.types import ( @@ -25,78 +28,426 @@ api = ApiClient() def main_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ - [KeyboardButton(text="Открыть CarPass")], - [KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")], + [KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")], + [KeyboardButton(text="Помощь")], ], resize_keyboard=True, ) -def webapp_inline_keyboard() -> InlineKeyboardMarkup: +def webapp_inline_keyboard(text: str = "Открыть CarPass") -> InlineKeyboardMarkup: return InlineKeyboardMarkup( 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")) async def start(message: Message) -> None: - user = await api.upsert_user(message.from_user) + user = await upsert(message) text = ( f"Готово, {user.get('first_name') or 'водитель'}.\n\n" - "CarPass — цифровой паспорт автомобиля: заправки, обслуживание, напоминания, подтвержденная история и стоимость владения.\n\n" - "Нажми «Открыть CarPass», чтобы перейти в приложение." + "CarPass ведет цифровой паспорт автомобиля: заправки, ТО, страховку, штрафы, стоимость владения и подтвержденную историю СТО.\n\n" + "Mini App открывай кнопкой под сообщением: так Telegram передает защищенную авторизацию." ) - await message.answer(text, reply_markup=webapp_inline_keyboard()) - await message.answer("Клавиатура ниже открывает меню бота. Сам Mini App запускается кнопкой в сообщении выше.", reply_markup=main_keyboard()) + await message.answer(text, reply_markup=menu_inline_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")) 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 "" 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 - car = await api.create_car(user["id"], name, message.from_user.id) - await message.answer(f"Добавил авто: {car['name']}") - - -@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 Название.") + try: + car = await api.create_car(user["id"], name, message.from_user.id) + except httpx.HTTPStatusError as error: + await message.answer(f"Не удалось добавить авто: {error.response.text}") 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 - ] - await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + +@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(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:")) async def show_stats(callback: CallbackQuery) -> None: car_id = int(callback.data.split(":", 1)[1]) - stats = await api.stats(car_id, callback.from_user.id) + try: + stats = await api.stats(car_id, callback.from_user.id) + except httpx.HTTPStatusError as error: + await callback.message.answer(f"Не удалось получить статистику: {error.response.text}") + await callback.answer() + return consumption = stats["avg_consumption_l_per_100km"] cost_per_km = stats["cost_per_km"] - await callback.message.answer( - "\n".join( - [ - "Статистика авто:", - f"Расходы всего: {stats['total_cost']}", - f"Топливо: {stats['fuel_cost']}", - f"Сервис и ремонты: {stats['service_cost']}", - f"Пробег по записям: {stats['distance_km']} км", - f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных", - f"Стоимость 1 км: {cost_per_km:.2f}" if cost_per_km else "Стоимость 1 км: нет данных", - ] + lines = [ + "Статистика авто:", + f"Расходы всего: {money(stats['total_cost'])}", + f"Фиксированные: {money(stats.get('fixed_costs'))}", + f"Переменные: {money(stats.get('variable_costs'))}", + f"Топливо: {money(stats['fuel_cost'])}", + f"Сервис и ремонты: {money(stats['service_cost'])}", + f"Пробег по записям: {stats['distance_km']} км", + f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных", + 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() @@ -105,27 +456,105 @@ async def show_stats(callback: CallbackQuery) -> None: async def help_message(message: Message) -> None: await message.answer( "CarPass помогает вести цифровой паспорт автомобиля.\n\n" - "Что можно делать:\n" - "• добавлять автомобили и параметры обслуживания;\n" - "• вести заправки, ТО, ремонт, страховку, налоги и штрафы;\n" - "• видеть стоимость владения, стоимость 1 км и прогноз расходов;\n" - "• загрузить чек, проверить распознанные данные и сохранить запись;\n" - "• привязать авто к проверенному СТО и подтверждать сервисную историю;\n" - "• зарегистрировать СТО и отправить заявку на проверку.\n\n" - "Mini App нужно открывать кнопкой под этим сообщением: так Telegram передает защищенную авторизацию.", - reply_markup=webapp_inline_keyboard(), + "Главное:\n" + "• /garage — список автомобилей;\n" + "• /add_car Название — быстро добавить авто;\n" + "• /fuel — заправка, включая полный бак;\n" + "• /service — ТО и ремонт;\n" + "• /insurance, /tax, /fine — регулярные и разовые расходы;\n" + "• /analytics — стоимость владения и расход;\n" + "• /sto — каталог проверенных СТО;\n" + "• /register_sto — заявка на СТО.\n\n" + "Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.", + reply_markup=menu_inline_keyboard(), ) @dp.message(F.text == "Открыть CarPass") @dp.message(F.text == "Открыть гараж") -async def open_carpass(message: Message) -> None: +async def old_open_buttons(message: Message) -> None: await message.answer( - "Открой CarPass кнопкой ниже. Это правильный Telegram Mini App вход с авторизацией.", + "Эта кнопка больше не используется как ReplyButton. Открой CarPass через защищенную кнопку ниже.", 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: if not settings.bot_token: raise RuntimeError("BOT_TOKEN is empty") diff --git a/tests/test_entries.py b/tests/test_entries.py index bda18af..40f9bd5 100644 --- a/tests/test_entries.py +++ b/tests/test_entries.py @@ -115,8 +115,8 @@ async def test_expense_crud_and_insurance_allocation(client, auth_headers) -> No ) assert stats.status_code == 200 body = stats.json() - assert body["expenses_cost"] in {"101.92", "101.93"} - assert body["cost_by_category"]["insurance"] in {"101.92", "101.93"} + assert body["expenses_cost"] == "100.00" + assert body["cost_by_category"]["insurance"] == "100.00" patched = await client.patch( f"/api/expenses/{entry_id}", diff --git a/tests/test_product_readiness.py b/tests/test_product_readiness.py new file mode 100644 index 0000000..207da6d --- /dev/null +++ b/tests/test_product_readiness.py @@ -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" diff --git a/web/index.html b/web/index.html index f0d27bc..11289f0 100644 --- a/web/index.html +++ b/web/index.html @@ -255,6 +255,9 @@ + + + + + @@ -515,6 +593,45 @@

Параметры авто

Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.
+ + + + + + + + + + + + + + + + + + + +
diff --git a/web/static/app.js b/web/static/app.js index 824b073..dc6c743 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -319,6 +319,9 @@ const state = { analytics: null, serviceCenters: [], publicServiceCenters: [], + confirmations: null, + connectedServices: [], + adminPendingServices: [], vehicleScore: null, vehicleTimeline: [], achievements: [], @@ -512,6 +515,7 @@ async function ensureUser() { body: JSON.stringify({ init_data: tg.initData }), }); hideAuthOverlay(); + updateRoleVisibility(); return; } if (state.authConfig?.allow_dev_auth) { @@ -519,6 +523,7 @@ async function ensureUser() { localStorage.setItem("driversDevTelegramId", devId); state.user = await api("/users/me"); hideAuthOverlay(); + updateRoleVisibility(); return; } await showTelegramLogin(); @@ -530,6 +535,11 @@ function hideAuthOverlay() { 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() { const overlay = document.querySelector("#authOverlay"); const slot = document.querySelector("#telegramLoginSlot"); @@ -578,6 +588,7 @@ async function showTelegramLogin() { }); localStorage.setItem("driversUser", JSON.stringify(state.user)); hideAuthOverlay(); + updateRoleVisibility(); await loadCars(); }; const script = document.createElement("script"); @@ -799,7 +810,17 @@ function renderCars() { } 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() { @@ -816,6 +837,13 @@ function fillCarProfileForm() { } 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", "target_consumption_l_per_100km", "fuel_tank_volume_l", @@ -827,9 +855,168 @@ function fillCarProfileForm() { "brake_fluid_type", "tire_pressure_front_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])); } +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 = `
Новых запросов нет
`; + return; + } + root.innerHTML = [ + ...visits.map((visit) => ` +
+ Визит СТО #${visit.id} + ${visit.visit_date} · ${visit.odometer || "-"} км · ${money(visit.total_cost || 0)} +
+ + +
+
`), + ...changes.map((item) => ` +
+ Изменение ${item.field_name} + ${item.old_value || "-"} → ${item.new_value || "-"} +
+ + +
+
`), + ...links.map((link) => ` +
+ Запрос доступа от СТО #${link.service_center_id} + Авто #${link.car_id} · ${link.access_level} +
+ + +
+
`), + ].join(""); + bindConfirmationActions(root); + } catch (error) { + root.innerHTML = `
Не удалось загрузить подтверждения
`; + } +} + +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) => ` +
+ ${link.service_center_name} + ${link.car_name} · ${link.access_level} · ${link.status} + ${link.status === "approved" ? `` : ""} +
`).join("") + : `
Подключенных автосервисов пока нет
`; + 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 = `
Не удалось загрузить подключения
`; + } +} + +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) => ` +
+ #${center.id} ${center.display_name || center.name} + ${[center.legal_name, center.city, center.address].filter(Boolean).join(" · ") || "Данные не заполнены"} + Документы: ${(center.document_photo_urls || []).length} +
+ + + +
+
`).join("") + : `
Pending-заявок нет
`; + 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 = `
Нет доступа или сервер не ответил
`; + } +} + function openCarProfile() { openDrawerSection("carProfileSection"); } @@ -1050,9 +1237,13 @@ function renderStats(stats) { + + + + ${stats.cost_warning ? `
Предупреждение${stats.cost_warning}мягкая проверка расходов
` : ""} `; root.querySelectorAll("[data-report]").forEach((button) => { button.addEventListener("click", () => openReport(button.dataset.report)); @@ -1265,9 +1456,12 @@ function openReport(type = "summary") { ${reportMetric(t("Пробег"), `${stats.distance_km} км`)} ${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")} ${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?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")} + ${analytics?.full_tank_warning ? `
${analytics.full_tank_warning}
` : ""}
${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}
`, }; @@ -1589,7 +1783,15 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => { model: data.model || null, trim: data.trim || 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, + 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(); @@ -1614,6 +1816,13 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even const updated = await api(`/cars/${car.id}`, { method: "PATCH", 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, target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km), 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, tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_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)); @@ -1730,6 +1953,11 @@ document.querySelector("#expenseForm").addEventListener("submit", async (event) odometer: numberOrNull(data.odometer), period_start: data.period_start || 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), }), }); @@ -1792,11 +2020,12 @@ async function openDrawerSection(sectionId, options = {}) { : "Напомним о ТО, страховке и регулярном внесении пробега.", ); } - if (sectionId === "confirmationsSection") renderPlaceholderList("#confirmationRequests", "Новых запросов нет"); - if (sectionId === "connectedServicesSection") renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет"); + if (sectionId === "confirmationsSection") await loadConfirmations(); + if (sectionId === "connectedServicesSection") await loadConnectedServices(); if (sectionId === "servicePanelSection") await loadServiceCenters(); if (sectionId === "publicServicesSection") await loadPublicServiceCenters(); if (sectionId === "reviewsSection") renderServiceReviews(); + if (sectionId === "adminSection") await loadAdminPendingServices(); if (options.expenseCategory) { openDrawerSection("expensesSection"); presetExpense(options.expenseCategory); @@ -1809,7 +2038,11 @@ function presetExpense(category) { const form = document.querySelector("#expenseForm"); form.category.value = 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) => { @@ -1888,6 +2121,9 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e : null, working_hours: data.working_hours || 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(); diff --git a/web/static/styles.css b/web/static/styles.css index 2173108..8820513 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -659,7 +659,7 @@ h2 { .grid-form, .entry-form { display: grid; - grid-template-columns: 1.1fr 1fr 1fr 120px auto; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; align-items: end; } @@ -793,6 +793,15 @@ select:disabled { background: var(--soft); } +.stat.wide { + grid-column: 1 / -1; +} + +.warning { + border-color: rgba(210, 141, 38, 0.35); + background: #fff6e7; +} + .stat strong { display: block; margin-top: 6px; @@ -1457,6 +1466,23 @@ select { 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 { width: fit-content; padding: 5px 8px;