This commit is contained in:
457
app/api/my.py
457
app/api/my.py
@@ -1,3 +1,6 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import or_, select
|
||||
@@ -8,11 +11,13 @@ from app.db.session import get_session
|
||||
from app.models.car import (
|
||||
Car,
|
||||
CarServiceLink,
|
||||
ServiceAppointment,
|
||||
ServiceCenter,
|
||||
ServiceVisit,
|
||||
VehicleAccess,
|
||||
VehicleDataChangeRequest,
|
||||
)
|
||||
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
|
||||
from app.models.user import User
|
||||
from app.schemas.service_center import (
|
||||
VehicleAccessGrant,
|
||||
@@ -22,11 +27,294 @@ 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.odometer import (
|
||||
add_odometer_history,
|
||||
recalculate_current_odometer,
|
||||
validate_odometer_change,
|
||||
)
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
router = APIRouter(tags=["my"])
|
||||
|
||||
EXPORT_SCHEMA = "carpass.exchange.v1"
|
||||
|
||||
VEHICLE_IMPORT_FIELDS = {
|
||||
"name",
|
||||
"make",
|
||||
"model",
|
||||
"trim",
|
||||
"generation",
|
||||
"body_type",
|
||||
"year",
|
||||
"plate_number",
|
||||
"vin",
|
||||
"fuel_type",
|
||||
"engine_volume_l",
|
||||
"transmission",
|
||||
"drive_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",
|
||||
"tire_size",
|
||||
"oil_change_interval_km",
|
||||
"oil_change_interval_months",
|
||||
"purchase_date",
|
||||
"purchase_price",
|
||||
"purchase_currency",
|
||||
"purchase_type",
|
||||
"currency",
|
||||
"include_depreciation",
|
||||
"expected_ownership_months",
|
||||
"expected_residual_value",
|
||||
"loan_principal",
|
||||
"loan_down_payment",
|
||||
"loan_term_months",
|
||||
"loan_annual_interest_rate",
|
||||
"loan_first_payment_date",
|
||||
"loan_payment_day",
|
||||
"loan_payment_type",
|
||||
"loan_currency",
|
||||
"loan_comment",
|
||||
"current_odometer",
|
||||
"notes",
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(value: str | date | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
|
||||
def _decimal(value: object) -> Decimal | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _vehicle_import_data(raw: dict) -> dict:
|
||||
data = {key: raw.get(key) for key in VEHICLE_IMPORT_FIELDS if key in raw}
|
||||
for key in ("purchase_date", "loan_first_payment_date"):
|
||||
if key in data:
|
||||
data[key] = _parse_date(data[key])
|
||||
if "plate_number" in data:
|
||||
data["license_plate_display"] = data["plate_number"]
|
||||
data["license_plate_normalized"] = normalize_license_plate(data["plate_number"])
|
||||
if "vin" in data:
|
||||
data["vin_normalized"] = validate_vin(data["vin"])
|
||||
return data
|
||||
|
||||
|
||||
def _exchange_counts(payload: dict) -> dict:
|
||||
vehicles = payload.get("vehicles") or []
|
||||
return {
|
||||
"vehicles": len(vehicles),
|
||||
"fuel_entries": sum(len(item.get("fuel_entries") or []) for item in vehicles),
|
||||
"service_entries": sum(len(item.get("service_entries") or []) for item in vehicles),
|
||||
"expense_entries": sum(len(item.get("expense_entries") or []) for item in vehicles),
|
||||
"appointments": sum(len(item.get("appointments") or []) for item in vehicles),
|
||||
"service_visits": sum(len(item.get("service_visits") or []) for item in vehicles),
|
||||
}
|
||||
|
||||
|
||||
def _model_dict(item) -> dict:
|
||||
return {column.name: getattr(item, column.name) for column in item.__table__.columns}
|
||||
|
||||
|
||||
async def _find_import_vehicle(session: AsyncSession, current_user: User, data: dict) -> Car | None:
|
||||
if data.get("vin_normalized"):
|
||||
found = (
|
||||
await session.execute(
|
||||
select(Car).where(Car.owner_id == current_user.id, Car.vin_normalized == data["vin_normalized"])
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if found is not None:
|
||||
return found
|
||||
if data.get("license_plate_normalized"):
|
||||
found = (
|
||||
await session.execute(
|
||||
select(Car).where(
|
||||
Car.owner_id == current_user.id,
|
||||
Car.license_plate_normalized == data["license_plate_normalized"],
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if found is not None:
|
||||
return found
|
||||
return (
|
||||
await session.execute(
|
||||
select(Car).where(
|
||||
Car.owner_id == current_user.id,
|
||||
Car.name == data.get("name", "Импортированное авто"),
|
||||
Car.make == data.get("make"),
|
||||
Car.model == data.get("model"),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def _clear_conflicting_unique_identity(session: AsyncSession, current_user: User, data: dict) -> None:
|
||||
if not data.get("vin_normalized"):
|
||||
return
|
||||
existing_owner_id = (
|
||||
await session.execute(
|
||||
select(Car.owner_id).where(
|
||||
Car.vin_normalized == data["vin_normalized"],
|
||||
Car.owner_id != current_user.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing_owner_id is not None:
|
||||
data.pop("vin", None)
|
||||
data.pop("vin_normalized", None)
|
||||
|
||||
|
||||
def _supplement_empty_vehicle_fields(car: Car, data: dict) -> list[str]:
|
||||
updated: list[str] = []
|
||||
for field, value in data.items():
|
||||
if field in {"id", "owner_id", "created_at", "updated_at"} or value in (None, ""):
|
||||
continue
|
||||
if getattr(car, field, None) in (None, ""):
|
||||
setattr(car, field, value)
|
||||
updated.append(field)
|
||||
return updated
|
||||
|
||||
|
||||
async def _import_fuel_entries(session: AsyncSession, car: Car, rows: list[dict]) -> int:
|
||||
imported = 0
|
||||
for raw in rows:
|
||||
entry_date = _parse_date(raw.get("entry_date"))
|
||||
if entry_date is None or raw.get("odometer") is None:
|
||||
continue
|
||||
liters = _decimal(raw.get("liters"))
|
||||
price_per_liter = _decimal(raw.get("price_per_liter"))
|
||||
if liters is None or price_per_liter is None:
|
||||
continue
|
||||
total_cost = _decimal(raw.get("total_cost")) or (liters * price_per_liter)
|
||||
exists = (
|
||||
await session.execute(
|
||||
select(FuelEntry.id).where(
|
||||
FuelEntry.car_id == car.id,
|
||||
FuelEntry.entry_date == entry_date,
|
||||
FuelEntry.odometer == int(raw["odometer"]),
|
||||
FuelEntry.liters == liters,
|
||||
FuelEntry.total_cost == total_cost,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
continue
|
||||
session.add(
|
||||
FuelEntry(
|
||||
car_id=car.id,
|
||||
entry_date=entry_date,
|
||||
odometer=int(raw["odometer"]),
|
||||
liters=liters,
|
||||
price_per_liter=price_per_liter,
|
||||
total_cost=total_cost,
|
||||
station=raw.get("station"),
|
||||
fuel_brand=raw.get("fuel_brand"),
|
||||
is_full_tank=raw.get("is_full_tank"),
|
||||
notes=raw.get("notes"),
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
async def _import_service_entries(session: AsyncSession, car: Car, rows: list[dict]) -> int:
|
||||
imported = 0
|
||||
for raw in rows:
|
||||
entry_date = _parse_date(raw.get("entry_date"))
|
||||
title = (raw.get("title") or "").strip()
|
||||
total_cost = _decimal(raw.get("total_cost"))
|
||||
if entry_date is None or not title or total_cost is None:
|
||||
continue
|
||||
exists = (
|
||||
await session.execute(
|
||||
select(ServiceEntry.id).where(
|
||||
ServiceEntry.car_id == car.id,
|
||||
ServiceEntry.entry_date == entry_date,
|
||||
ServiceEntry.title == title,
|
||||
ServiceEntry.total_cost == total_cost,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
continue
|
||||
session.add(
|
||||
ServiceEntry(
|
||||
car_id=car.id,
|
||||
entry_date=entry_date,
|
||||
odometer=raw.get("odometer"),
|
||||
service_type=raw.get("service_type") or "maintenance",
|
||||
title=title,
|
||||
category=raw.get("category"),
|
||||
vendor=raw.get("vendor"),
|
||||
total_cost=total_cost,
|
||||
next_due_date=_parse_date(raw.get("next_due_date")),
|
||||
next_due_odometer=raw.get("next_due_odometer"),
|
||||
notes=raw.get("notes"),
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
async def _import_expense_entries(session: AsyncSession, car: Car, rows: list[dict]) -> int:
|
||||
imported = 0
|
||||
for raw in rows:
|
||||
entry_date = _parse_date(raw.get("entry_date"))
|
||||
title = (raw.get("title") or "").strip()
|
||||
total_cost = _decimal(raw.get("total_cost"))
|
||||
if entry_date is None or not title or total_cost is None:
|
||||
continue
|
||||
exists = (
|
||||
await session.execute(
|
||||
select(ExpenseEntry.id).where(
|
||||
ExpenseEntry.car_id == car.id,
|
||||
ExpenseEntry.entry_date == entry_date,
|
||||
ExpenseEntry.title == title,
|
||||
ExpenseEntry.total_cost == total_cost,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
continue
|
||||
session.add(
|
||||
ExpenseEntry(
|
||||
car_id=car.id,
|
||||
entry_date=entry_date,
|
||||
category=raw.get("category") or "other",
|
||||
title=title,
|
||||
vendor=raw.get("vendor"),
|
||||
total_cost=total_cost,
|
||||
currency=raw.get("currency") or car.currency or "RUB",
|
||||
odometer=raw.get("odometer"),
|
||||
period_start=_parse_date(raw.get("period_start")),
|
||||
period_end=_parse_date(raw.get("period_end")),
|
||||
period_months=raw.get("period_months"),
|
||||
is_recurring=bool(raw.get("is_recurring")),
|
||||
policy_number=raw.get("policy_number"),
|
||||
insurance_type=raw.get("insurance_type"),
|
||||
payment_period_months=raw.get("payment_period_months"),
|
||||
document_urls=raw.get("document_urls"),
|
||||
metadata_json=raw.get("metadata_json"),
|
||||
notes=raw.get("notes"),
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def me(current_user: User = Depends(get_current_telegram_user)) -> User:
|
||||
@@ -232,6 +520,173 @@ async def my_service_links(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/my/export")
|
||||
async def export_my_data(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> dict:
|
||||
cars = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Car).where(Car.owner_id == current_user.id).order_by(Car.created_at.asc(), Car.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
exported_vehicles = []
|
||||
for car in cars:
|
||||
fuel_entries = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(FuelEntry).where(FuelEntry.car_id == car.id).order_by(FuelEntry.entry_date.asc(), FuelEntry.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
service_entries = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceEntry)
|
||||
.where(ServiceEntry.car_id == car.id)
|
||||
.order_by(ServiceEntry.entry_date.asc(), ServiceEntry.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
expense_entries = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ExpenseEntry)
|
||||
.where(ExpenseEntry.car_id == car.id)
|
||||
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
appointments = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceAppointment)
|
||||
.where(ServiceAppointment.vehicle_id == car.id, ServiceAppointment.owner_id == current_user.id)
|
||||
.order_by(ServiceAppointment.created_at.asc(), ServiceAppointment.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
visits = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceVisit)
|
||||
.where(ServiceVisit.vehicle_id == car.id)
|
||||
.order_by(ServiceVisit.visit_date.asc(), ServiceVisit.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
exported_vehicles.append(
|
||||
{
|
||||
"vehicle": _model_dict(car),
|
||||
"fuel_entries": [_model_dict(item) for item in fuel_entries],
|
||||
"service_entries": [_model_dict(item) for item in service_entries],
|
||||
"expense_entries": [_model_dict(item) for item in expense_entries],
|
||||
"appointments": [_model_dict(item) for item in appointments],
|
||||
"service_visits": [_model_dict(item) for item in visits],
|
||||
}
|
||||
)
|
||||
centers = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceCenter)
|
||||
.where(ServiceCenter.owner_user_id == current_user.id)
|
||||
.order_by(ServiceCenter.created_at.asc(), ServiceCenter.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
payload = {
|
||||
"schema": EXPORT_SCHEMA,
|
||||
"exported_at": datetime.now(UTC),
|
||||
"user": {
|
||||
"telegram_id": current_user.telegram_id,
|
||||
"username": current_user.username,
|
||||
"locale": current_user.locale,
|
||||
"currency": current_user.currency,
|
||||
},
|
||||
"vehicles": exported_vehicles,
|
||||
"service_centers": [_model_dict(item) for item in centers],
|
||||
"exchange_policy": {
|
||||
"import_mode": "create_missing",
|
||||
"dedupe": ["vin", "license_plate", "vehicle_name_make_model", "entry_date_title_amount"],
|
||||
"work_orders": "archived_export_only",
|
||||
},
|
||||
}
|
||||
return jsonable_encoder(payload)
|
||||
|
||||
|
||||
@router.post("/my/import/preview")
|
||||
async def preview_my_data_import(
|
||||
payload: dict,
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> dict:
|
||||
warnings = []
|
||||
if payload.get("schema") != EXPORT_SCHEMA:
|
||||
warnings.append("Импорт ожидает JSON CarPass exchange v1. Данные будут обработаны в режиме совместимости.")
|
||||
counts = _exchange_counts(payload)
|
||||
if counts["service_visits"] or counts["appointments"]:
|
||||
warnings.append("Брони и заказ-наряды импортируются как архивные данные экспорта, без создания активных заявок.")
|
||||
return {
|
||||
"valid": bool(payload.get("vehicles")),
|
||||
"owner_telegram_id": current_user.telegram_id,
|
||||
"counts": counts,
|
||||
"warnings": warnings,
|
||||
"mode": "create_missing",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/my/import")
|
||||
async def import_my_data(
|
||||
payload: dict,
|
||||
dry_run: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> dict:
|
||||
preview = await preview_my_data_import(payload, current_user)
|
||||
if dry_run:
|
||||
return preview
|
||||
imported = {
|
||||
"vehicles_created": 0,
|
||||
"vehicles_matched": 0,
|
||||
"vehicle_fields_updated": 0,
|
||||
"fuel_entries": 0,
|
||||
"service_entries": 0,
|
||||
"expense_entries": 0,
|
||||
}
|
||||
for bundle in payload.get("vehicles") or []:
|
||||
raw_vehicle = bundle.get("vehicle") or bundle
|
||||
vehicle_payload = _vehicle_import_data(raw_vehicle)
|
||||
if not vehicle_payload.get("name"):
|
||||
vehicle_payload["name"] = "Импортированное авто"
|
||||
await _clear_conflicting_unique_identity(session, current_user, vehicle_payload)
|
||||
car = await _find_import_vehicle(session, current_user, vehicle_payload)
|
||||
if car is None:
|
||||
car = Car(**vehicle_payload, 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"))
|
||||
imported["vehicles_created"] += 1
|
||||
else:
|
||||
imported["vehicles_matched"] += 1
|
||||
imported["vehicle_fields_updated"] += len(_supplement_empty_vehicle_fields(car, vehicle_payload))
|
||||
imported["fuel_entries"] += await _import_fuel_entries(session, car, bundle.get("fuel_entries") or [])
|
||||
imported["service_entries"] += await _import_service_entries(session, car, bundle.get("service_entries") or [])
|
||||
imported["expense_entries"] += await _import_expense_entries(session, car, bundle.get("expense_entries") or [])
|
||||
await session.flush()
|
||||
await recalculate_current_odometer(session, car.id, changed_by=current_user.id, source_record_type="data_import")
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="data_exchange.import",
|
||||
target_type="user",
|
||||
target_id=current_user.id,
|
||||
metadata={"imported": imported, "counts": preview["counts"]},
|
||||
)
|
||||
await session.commit()
|
||||
return {"status": "imported", "imported": imported, "preview": preview}
|
||||
|
||||
|
||||
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
|
||||
async def grant_vehicle_access(
|
||||
vehicle_id: int,
|
||||
|
||||
@@ -683,6 +683,16 @@ async def create_work_order_from_appointment(
|
||||
return visit
|
||||
|
||||
|
||||
@router.get("/sto/settings/booking", response_model=ServiceCenterBookingSettingsRead)
|
||||
async def read_booking_settings(
|
||||
service_center_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenterBookingSettings:
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||
return await get_booking_settings(session, service_center_id)
|
||||
|
||||
|
||||
@router.post("/sto/settings/booking", response_model=ServiceCenterBookingSettingsRead)
|
||||
async def upsert_booking_settings(
|
||||
payload: ServiceCenterBookingSettingsUpsert,
|
||||
|
||||
@@ -62,7 +62,7 @@ def work_order_webapp_url(work_order_id: int) -> str:
|
||||
|
||||
|
||||
def vehicle_profile_webapp_url(vehicle_id: int) -> str:
|
||||
return webapp_url(f"?section=carProfile&car_id={vehicle_id}")
|
||||
return webapp_url(f"car_profile.html?car_id={vehicle_id}")
|
||||
|
||||
|
||||
async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVisit:
|
||||
|
||||
Reference in New Issue
Block a user