add vehicle service profile settings
This commit is contained in:
109
alembic/versions/202605120003_vehicle_service_profile.py
Normal file
109
alembic/versions/202605120003_vehicle_service_profile.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""vehicle service profile
|
||||||
|
|
||||||
|
Revision ID: 202605120003
|
||||||
|
Revises: 202605120002
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "202605120003"
|
||||||
|
down_revision: str | None = "202605120002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("cars", sa.Column("target_consumption_l_per_100km", sa.Numeric(6, 2), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("fuel_tank_volume_l", sa.Numeric(6, 2), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("engine_oil_type", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("engine_oil_volume_l", sa.Numeric(5, 2), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("transmission_fluid_type", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("transmission_fluid_volume_l", sa.Numeric(5, 2), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("coolant_type", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("brake_fluid_type", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("tire_pressure_front_bar", sa.Numeric(4, 2), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("tire_pressure_rear_bar", sa.Numeric(4, 2), nullable=True))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"service_centers",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("telegram_chat_id", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("contact_phone", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("address", sa.String(length=240), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
sa.UniqueConstraint("telegram_chat_id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_service_centers_name"), "service_centers", ["name"])
|
||||||
|
op.create_index(op.f("ix_service_centers_telegram_chat_id"), "service_centers", ["telegram_chat_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"car_service_links",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("car_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_center_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("external_vehicle_ref", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("car_id", "service_center_id", name="uq_car_service_link"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_car_service_links_car_id"), "car_service_links", ["car_id"])
|
||||||
|
op.create_index(op.f("ix_car_service_links_external_vehicle_ref"), "car_service_links", ["external_vehicle_ref"])
|
||||||
|
op.create_index(op.f("ix_car_service_links_service_center_id"), "car_service_links", ["service_center_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"service_inbox_messages",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_center_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("car_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source_chat_id", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("raw_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("parsed_status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("parsed_payload", sa.Text(), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_service_inbox_messages_car_id"), "service_inbox_messages", ["car_id"])
|
||||||
|
op.create_index(op.f("ix_service_inbox_messages_parsed_status"), "service_inbox_messages", ["parsed_status"])
|
||||||
|
op.create_index(op.f("ix_service_inbox_messages_service_center_id"), "service_inbox_messages", ["service_center_id"])
|
||||||
|
op.create_index(op.f("ix_service_inbox_messages_source_chat_id"), "service_inbox_messages", ["source_chat_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_service_inbox_messages_source_chat_id"), table_name="service_inbox_messages")
|
||||||
|
op.drop_index(op.f("ix_service_inbox_messages_service_center_id"), table_name="service_inbox_messages")
|
||||||
|
op.drop_index(op.f("ix_service_inbox_messages_parsed_status"), table_name="service_inbox_messages")
|
||||||
|
op.drop_index(op.f("ix_service_inbox_messages_car_id"), table_name="service_inbox_messages")
|
||||||
|
op.drop_table("service_inbox_messages")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_car_service_links_service_center_id"), table_name="car_service_links")
|
||||||
|
op.drop_index(op.f("ix_car_service_links_external_vehicle_ref"), table_name="car_service_links")
|
||||||
|
op.drop_index(op.f("ix_car_service_links_car_id"), table_name="car_service_links")
|
||||||
|
op.drop_table("car_service_links")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_service_centers_telegram_chat_id"), table_name="service_centers")
|
||||||
|
op.drop_index(op.f("ix_service_centers_name"), table_name="service_centers")
|
||||||
|
op.drop_table("service_centers")
|
||||||
|
|
||||||
|
op.drop_column("cars", "tire_pressure_rear_bar")
|
||||||
|
op.drop_column("cars", "tire_pressure_front_bar")
|
||||||
|
op.drop_column("cars", "brake_fluid_type")
|
||||||
|
op.drop_column("cars", "coolant_type")
|
||||||
|
op.drop_column("cars", "transmission_fluid_volume_l")
|
||||||
|
op.drop_column("cars", "transmission_fluid_type")
|
||||||
|
op.drop_column("cars", "engine_oil_volume_l")
|
||||||
|
op.drop_column("cars", "engine_oil_type")
|
||||||
|
op.drop_column("cars", "fuel_tank_volume_l")
|
||||||
|
op.drop_column("cars", "target_consumption_l_per_100km")
|
||||||
73
app/api/service_centers.py
Normal file
73
app/api/service_centers.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
|
||||||
|
from app.schemas.service_center import (
|
||||||
|
CarServiceLinkCreate,
|
||||||
|
CarServiceLinkRead,
|
||||||
|
ServiceCenterCreate,
|
||||||
|
ServiceCenterRead,
|
||||||
|
ServiceInboxCreate,
|
||||||
|
ServiceInboxRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_service_center(
|
||||||
|
payload: ServiceCenterCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> ServiceCenter:
|
||||||
|
center = ServiceCenter(**payload.model_dump())
|
||||||
|
session.add(center)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(center)
|
||||||
|
return center
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ServiceCenterRead])
|
||||||
|
async def list_service_centers(session: AsyncSession = Depends(get_session)) -> list[ServiceCenter]:
|
||||||
|
result = await session.execute(select(ServiceCenter).order_by(ServiceCenter.name))
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def link_car_to_service(
|
||||||
|
payload: CarServiceLinkCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> CarServiceLink:
|
||||||
|
if await session.get(Car, payload.car_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Car not found")
|
||||||
|
if await session.get(ServiceCenter, payload.service_center_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Service center not found")
|
||||||
|
link = CarServiceLink(**payload.model_dump())
|
||||||
|
session.add(link)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(link)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inbox", response_model=ServiceInboxRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def receive_service_message(
|
||||||
|
payload: ServiceInboxCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> ServiceInboxMessage:
|
||||||
|
service_center_id = payload.service_center_id
|
||||||
|
if not service_center_id and payload.source_chat_id:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceCenter).where(ServiceCenter.telegram_chat_id == payload.source_chat_id)
|
||||||
|
)
|
||||||
|
center = result.scalar_one_or_none()
|
||||||
|
service_center_id = center.id if center else None
|
||||||
|
|
||||||
|
message = ServiceInboxMessage(
|
||||||
|
source_chat_id=payload.source_chat_id,
|
||||||
|
raw_text=payload.raw_text,
|
||||||
|
car_id=payload.car_id,
|
||||||
|
service_center_id=service_center_id,
|
||||||
|
parsed_status="pending",
|
||||||
|
)
|
||||||
|
session.add(message)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(message)
|
||||||
|
return message
|
||||||
@@ -121,6 +121,16 @@ async def seed_mock_usage(session: AsyncSession, owner: User) -> None:
|
|||||||
year=year,
|
year=year,
|
||||||
plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}",
|
plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}",
|
||||||
fuel_type=fuel_type,
|
fuel_type=fuel_type,
|
||||||
|
target_consumption_l_per_100km=None if fuel_type == "electric" else Decimal("7.80"),
|
||||||
|
fuel_tank_volume_l=None if fuel_type == "electric" else Decimal("58.00"),
|
||||||
|
engine_oil_type=None if fuel_type == "electric" else "5W-30 API SP",
|
||||||
|
engine_oil_volume_l=None if fuel_type == "electric" else Decimal("4.50"),
|
||||||
|
transmission_fluid_type="EV reduction gear oil" if fuel_type == "electric" else "ATF / CVTF по допуску",
|
||||||
|
transmission_fluid_volume_l=Decimal("1.20") if fuel_type == "electric" else Decimal("7.00"),
|
||||||
|
coolant_type="LLC",
|
||||||
|
brake_fluid_type="DOT 4",
|
||||||
|
tire_pressure_front_bar=Decimal("2.30"),
|
||||||
|
tire_pressure_rear_bar=Decimal("2.20"),
|
||||||
purchase_date=date(year, min(index, 12), 10),
|
purchase_date=date(year, min(index, 12), 10),
|
||||||
purchase_price=price,
|
purchase_price=price,
|
||||||
current_odometer=start_odo,
|
current_odometer=start_odo,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api import cars, catalog, entries, ocr, users
|
from app.api import cars, catalog, entries, ocr, service_centers, users
|
||||||
|
|
||||||
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ app.include_router(catalog.router, prefix="/api")
|
|||||||
app.include_router(cars.router, prefix="/api")
|
app.include_router(cars.router, prefix="/api")
|
||||||
app.include_router(entries.router, prefix="/api")
|
app.include_router(entries.router, prefix="/api")
|
||||||
app.include_router(ocr.router, prefix="/api")
|
app.include_router(ocr.router, prefix="/api")
|
||||||
|
app.include_router(service_centers.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint, func
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -20,6 +20,16 @@ class Car(Base):
|
|||||||
plate_number: Mapped[str | None] = mapped_column(String(32))
|
plate_number: Mapped[str | None] = mapped_column(String(32))
|
||||||
vin: Mapped[str | None] = mapped_column(String(32))
|
vin: Mapped[str | None] = mapped_column(String(32))
|
||||||
fuel_type: Mapped[str | None] = mapped_column(String(32))
|
fuel_type: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
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))
|
||||||
|
engine_oil_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
||||||
|
transmission_fluid_type: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
transmission_fluid_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
||||||
|
coolant_type: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
brake_fluid_type: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
tire_pressure_front_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
||||||
|
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
||||||
purchase_date: Mapped[date | None] = mapped_column(Date)
|
purchase_date: Mapped[date | None] = mapped_column(Date)
|
||||||
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||||
current_odometer: Mapped[int | None]
|
current_odometer: Mapped[int | None]
|
||||||
@@ -31,6 +41,7 @@ class Car(Base):
|
|||||||
owner = relationship("User", back_populates="cars")
|
owner = relationship("User", back_populates="cars")
|
||||||
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
|
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
|
||||||
service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan")
|
service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan")
|
||||||
|
service_links = relationship("CarServiceLink", back_populates="car", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class CarMake(Base):
|
class CarMake(Base):
|
||||||
@@ -74,3 +85,48 @@ class CarTrim(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
model = relationship("CarModel", back_populates="trims")
|
model = relationship("CarModel", back_populates="trims")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCenter(Base):
|
||||||
|
__tablename__ = "service_centers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||||
|
telegram_chat_id: Mapped[str | None] = mapped_column(String(80), unique=True, index=True)
|
||||||
|
contact_phone: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
address: Mapped[str | None] = mapped_column(String(240))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
car_links = relationship("CarServiceLink", back_populates="service_center", cascade="all, delete-orphan")
|
||||||
|
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
|
||||||
|
|
||||||
|
|
||||||
|
class CarServiceLink(Base):
|
||||||
|
__tablename__ = "car_service_links"
|
||||||
|
__table_args__ = (UniqueConstraint("car_id", "service_center_id", name="uq_car_service_link"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||||
|
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||||
|
external_vehicle_ref: Mapped[str | None] = mapped_column(String(120), index=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
car = relationship("Car", back_populates="service_links")
|
||||||
|
service_center = relationship("ServiceCenter", back_populates="car_links")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInboxMessage(Base):
|
||||||
|
__tablename__ = "service_inbox_messages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
|
||||||
|
car_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="SET NULL"), index=True)
|
||||||
|
source_chat_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||||
|
raw_text: Mapped[str] = mapped_column(Text)
|
||||||
|
parsed_status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||||
|
parsed_payload: Mapped[str | None] = mapped_column(Text)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
service_center = relationship("ServiceCenter", back_populates="inbox_messages")
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ class CarBase(BaseModel):
|
|||||||
plate_number: str | None = None
|
plate_number: str | None = None
|
||||||
vin: str | None = None
|
vin: str | None = None
|
||||||
fuel_type: str | None = None
|
fuel_type: str | None = None
|
||||||
|
target_consumption_l_per_100km: Decimal | None = None
|
||||||
|
fuel_tank_volume_l: 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
|
||||||
purchase_date: date | None = None
|
purchase_date: date | None = None
|
||||||
purchase_price: Decimal | None = None
|
purchase_price: Decimal | None = None
|
||||||
current_odometer: int | None = None
|
current_odometer: int | None = None
|
||||||
@@ -31,6 +41,16 @@ class CarUpdate(BaseModel):
|
|||||||
plate_number: str | None = None
|
plate_number: str | None = None
|
||||||
vin: str | None = None
|
vin: str | None = None
|
||||||
fuel_type: str | None = None
|
fuel_type: str | None = None
|
||||||
|
target_consumption_l_per_100km: Decimal | None = None
|
||||||
|
fuel_tank_volume_l: 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
|
||||||
purchase_date: date | None = None
|
purchase_date: date | None = None
|
||||||
purchase_price: Decimal | None = None
|
purchase_price: Decimal | None = None
|
||||||
current_odometer: int | None = None
|
current_odometer: int | None = None
|
||||||
|
|||||||
48
app/schemas/service_center.py
Normal file
48
app/schemas/service_center.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCenterCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
telegram_chat_id: str | None = None
|
||||||
|
contact_phone: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCenterRead(ServiceCenterCreate):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CarServiceLinkCreate(BaseModel):
|
||||||
|
car_id: int
|
||||||
|
service_center_id: int
|
||||||
|
external_vehicle_ref: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class CarServiceLinkRead(CarServiceLinkCreate):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInboxCreate(BaseModel):
|
||||||
|
source_chat_id: str | None = None
|
||||||
|
raw_text: str
|
||||||
|
car_id: int | None = None
|
||||||
|
service_center_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInboxRead(ServiceInboxCreate):
|
||||||
|
id: int
|
||||||
|
parsed_status: str
|
||||||
|
parsed_payload: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -11,13 +11,14 @@
|
|||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="auth-required">
|
||||||
<div class="auth-overlay" id="authOverlay">
|
<div class="auth-overlay" id="authOverlay">
|
||||||
<div class="auth-panel">
|
<div class="auth-panel">
|
||||||
<p class="eyebrow">Drivers</p>
|
<p class="eyebrow">Drivers</p>
|
||||||
<h1>Гараж</h1>
|
<h1>Гараж</h1>
|
||||||
<p>Войди через Telegram, чтобы привязать гараж к твоему chat_id.</p>
|
<p>Войди через Telegram, чтобы привязать гараж к твоему chat_id.</p>
|
||||||
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
|
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
|
||||||
|
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" target="_blank" rel="noreferrer">Войти через Telegram</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
@@ -216,6 +217,7 @@
|
|||||||
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
|
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="menu-row" id="openCarFormBtn">Добавить автомобиль</button>
|
<button class="menu-row" id="openCarFormBtn">Добавить автомобиль</button>
|
||||||
|
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
|
||||||
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
|
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
|
||||||
<button class="menu-row" id="openNotificationsBtn">Уведомления</button>
|
<button class="menu-row" id="openNotificationsBtn">Уведомления</button>
|
||||||
<button class="menu-row" id="openScanBtn">Сканировать чек</button>
|
<button class="menu-row" id="openScanBtn">Сканировать чек</button>
|
||||||
@@ -291,6 +293,64 @@
|
|||||||
<button type="submit">Добавить авто</button>
|
<button type="submit">Добавить авто</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="carProfileSection">
|
||||||
|
<h2>Параметры авто</h2>
|
||||||
|
<div class="tip-card" id="carProfileHint">Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.</div>
|
||||||
|
<form id="carProfileForm" class="grid-form drawer-form">
|
||||||
|
<label>
|
||||||
|
Тип топлива
|
||||||
|
<select name="fuel_type">
|
||||||
|
<option value="">Не задано</option>
|
||||||
|
<option value="gasoline">Бензин</option>
|
||||||
|
<option value="diesel">Дизель</option>
|
||||||
|
<option value="hybrid">Гибрид</option>
|
||||||
|
<option value="electric">Электро</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Нормальный расход, л/100 км
|
||||||
|
<input name="target_consumption_l_per_100km" type="number" min="0" step="0.01" placeholder="8.50" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Бак, л
|
||||||
|
<input name="fuel_tank_volume_l" type="number" min="0" step="0.01" placeholder="60" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Моторное масло
|
||||||
|
<input name="engine_oil_type" placeholder="5W-30 API SP" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Объем масла, л
|
||||||
|
<input name="engine_oil_volume_l" type="number" min="0" step="0.01" placeholder="4.50" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Трансмиссионная жидкость
|
||||||
|
<input name="transmission_fluid_type" placeholder="ATF WS / DCTF / CVT" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Объем трансмиссии, л
|
||||||
|
<input name="transmission_fluid_volume_l" type="number" min="0" step="0.01" placeholder="7.20" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Антифриз
|
||||||
|
<input name="coolant_type" placeholder="G12+ / LLC" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Тормозная жидкость
|
||||||
|
<input name="brake_fluid_type" placeholder="DOT 4" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Давление перед, bar
|
||||||
|
<input name="tire_pressure_front_bar" type="number" min="0" step="0.01" placeholder="2.30" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Давление зад, bar
|
||||||
|
<input name="tire_pressure_rear_bar" type="number" min="0" step="0.01" placeholder="2.20" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Сохранить параметры</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -509,18 +509,25 @@ async function ensureUser() {
|
|||||||
|
|
||||||
function hideAuthOverlay() {
|
function hideAuthOverlay() {
|
||||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||||
|
document.body.classList.remove("auth-required");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showTelegramLogin() {
|
async function showTelegramLogin() {
|
||||||
const overlay = document.querySelector("#authOverlay");
|
const overlay = document.querySelector("#authOverlay");
|
||||||
const slot = document.querySelector("#telegramLoginSlot");
|
const slot = document.querySelector("#telegramLoginSlot");
|
||||||
|
const link = document.querySelector("#telegramLoginLink");
|
||||||
overlay?.classList.remove("hidden");
|
overlay?.classList.remove("hidden");
|
||||||
|
document.body.classList.add("auth-required");
|
||||||
if (!slot || slot.dataset.ready) return;
|
if (!slot || slot.dataset.ready) return;
|
||||||
const botUsername = state.authConfig?.bot_username;
|
const botUsername = state.authConfig?.bot_username;
|
||||||
if (!botUsername) {
|
if (!botUsername) {
|
||||||
slot.textContent = "Telegram Login временно недоступен";
|
slot.textContent = "Telegram Login временно недоступен";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (link) {
|
||||||
|
link.href = `https://t.me/${botUsername}?start=web_login`;
|
||||||
|
link.classList.remove("hidden");
|
||||||
|
}
|
||||||
window.onTelegramAuth = async (user) => {
|
window.onTelegramAuth = async (user) => {
|
||||||
state.user = await api("/users/telegram-login", {
|
state.user = await api("/users/telegram-login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -538,6 +545,9 @@ async function showTelegramLogin() {
|
|||||||
script.setAttribute("data-radius", "8");
|
script.setAttribute("data-radius", "8");
|
||||||
script.setAttribute("data-request-access", "write");
|
script.setAttribute("data-request-access", "write");
|
||||||
script.setAttribute("data-onauth", "onTelegramAuth(user)");
|
script.setAttribute("data-onauth", "onTelegramAuth(user)");
|
||||||
|
script.addEventListener("error", () => {
|
||||||
|
slot.textContent = "Кнопка Telegram не загрузилась. Используй вход ниже.";
|
||||||
|
});
|
||||||
slot.dataset.ready = "true";
|
slot.dataset.ready = "true";
|
||||||
slot.appendChild(script);
|
slot.appendChild(script);
|
||||||
}
|
}
|
||||||
@@ -565,6 +575,10 @@ function selectedCar() {
|
|||||||
return state.cars.find((car) => car.id === state.selectedCarId) || null;
|
return state.cars.find((car) => car.id === state.selectedCarId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value) {
|
||||||
|
return value === "" || value == null ? null : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
function shiftMonths(base, count) {
|
function shiftMonths(base, count) {
|
||||||
const copy = new Date(base);
|
const copy = new Date(base);
|
||||||
copy.setMonth(copy.getMonth() + count);
|
copy.setMonth(copy.getMonth() + count);
|
||||||
@@ -690,7 +704,7 @@ function updateHero(stats) {
|
|||||||
const car = selectedCar();
|
const car = selectedCar();
|
||||||
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
|
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
|
||||||
document.querySelector("#selectedCarMeta").textContent = car
|
document.querySelector("#selectedCarMeta").textContent = car
|
||||||
? [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || t("Без деталей")
|
? [car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")
|
||||||
: t("Добавь авто или выбери из списка");
|
: t("Добавь авто или выбери из списка");
|
||||||
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
|
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
|
||||||
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
|
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
|
||||||
@@ -717,7 +731,7 @@ function renderCars() {
|
|||||||
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
|
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>${car.name}</strong>
|
<strong>${car.name}</strong>
|
||||||
<small>${[car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || t("Без деталей")}</small>
|
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
@@ -728,6 +742,45 @@ function renderCars() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setInputValue(form, name, value) {
|
||||||
|
if (form?.elements[name]) form.elements[name].value = value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCarProfileForm() {
|
||||||
|
const form = document.querySelector("#carProfileForm");
|
||||||
|
const hint = document.querySelector("#carProfileHint");
|
||||||
|
const car = selectedCar();
|
||||||
|
form.querySelectorAll("input, select, button").forEach((node) => {
|
||||||
|
node.disabled = !car;
|
||||||
|
});
|
||||||
|
if (!car) {
|
||||||
|
form.reset();
|
||||||
|
hint.textContent = t("Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
|
||||||
|
[
|
||||||
|
"fuel_type",
|
||||||
|
"target_consumption_l_per_100km",
|
||||||
|
"fuel_tank_volume_l",
|
||||||
|
"engine_oil_type",
|
||||||
|
"engine_oil_volume_l",
|
||||||
|
"transmission_fluid_type",
|
||||||
|
"transmission_fluid_volume_l",
|
||||||
|
"coolant_type",
|
||||||
|
"brake_fluid_type",
|
||||||
|
"tire_pressure_front_bar",
|
||||||
|
"tire_pressure_rear_bar",
|
||||||
|
].forEach((name) => setInputValue(form, name, car[name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCarProfile() {
|
||||||
|
document.querySelector("#userDrawer").classList.remove("hidden");
|
||||||
|
document.querySelector("#carProfileSection").classList.remove("hidden");
|
||||||
|
fillCarProfileForm();
|
||||||
|
document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
function renderStats(stats) {
|
function renderStats(stats) {
|
||||||
const root = document.querySelector("#stats");
|
const root = document.querySelector("#stats");
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
@@ -1051,6 +1104,7 @@ async function loadCars() {
|
|||||||
state.selectedCarId = state.cars[0]?.id || null;
|
state.selectedCarId = state.cars[0]?.id || null;
|
||||||
}
|
}
|
||||||
renderCars();
|
renderCars();
|
||||||
|
fillCarProfileForm();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
} finally {
|
} finally {
|
||||||
document.body.classList.remove("loading");
|
document.body.classList.remove("loading");
|
||||||
@@ -1061,6 +1115,7 @@ async function loadCars() {
|
|||||||
async function selectCar(carId) {
|
async function selectCar(carId) {
|
||||||
state.selectedCarId = carId;
|
state.selectedCarId = carId;
|
||||||
renderCars();
|
renderCars();
|
||||||
|
fillCarProfileForm();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,6 +1200,41 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("#carProfileForm").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const car = selectedCar();
|
||||||
|
if (!car) {
|
||||||
|
toast("Выбери автомобиль", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||||||
|
const data = formData(form);
|
||||||
|
const updated = await api(`/cars/${car.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
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),
|
||||||
|
engine_oil_type: data.engine_oil_type || null,
|
||||||
|
engine_oil_volume_l: numberOrNull(data.engine_oil_volume_l),
|
||||||
|
transmission_fluid_type: data.transmission_fluid_type || null,
|
||||||
|
transmission_fluid_volume_l: numberOrNull(data.transmission_fluid_volume_l),
|
||||||
|
coolant_type: data.coolant_type || null,
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
|
||||||
|
renderCars();
|
||||||
|
fillCarProfileForm();
|
||||||
|
await loadSelectedCar();
|
||||||
|
toast("Параметры сохранены");
|
||||||
|
haptic("success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
|
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
@@ -1272,6 +1362,8 @@ document.querySelector("#openCarFormBtn").addEventListener("click", () => {
|
|||||||
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("#openCarProfileBtn").addEventListener("click", openCarProfile);
|
||||||
|
|
||||||
document.querySelector("#openSettingsBtn").addEventListener("click", () => {
|
document.querySelector("#openSettingsBtn").addEventListener("click", () => {
|
||||||
document.querySelector("#settingsSection").classList.remove("hidden");
|
document.querySelector("#settingsSection").classList.remove("hidden");
|
||||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
--shadow: 0 14px 40px rgba(24, 33, 31, 0.08);
|
--shadow: 0 14px 40px rgba(24, 33, 31, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.auth-required .shell {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.top-actions {
|
.top-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1194,6 +1198,24 @@ select {
|
|||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.telegram-login-link {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #16806a;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-login-link.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.scan-form {
|
.scan-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user