СТО
+Создать запись
+Заявка
+diff --git a/app/api/my.py b/app/api/my.py index edfa920..0d56b3e 100644 --- a/app/api/my.py +++ b/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, diff --git a/app/api/sto_booking.py b/app/api/sto_booking.py index 2e3f37e..7243a28 100644 --- a/app/api/sto_booking.py +++ b/app/api/sto_booking.py @@ -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, diff --git a/app/api/work_orders.py b/app/api/work_orders.py index d62146f..5c9cd4a 100644 --- a/app/api/work_orders.py +++ b/app/api/work_orders.py @@ -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: diff --git a/tests/test_entries.py b/tests/test_entries.py index 40f9bd5..4fe3a7d 100644 --- a/tests/test_entries.py +++ b/tests/test_entries.py @@ -128,3 +128,90 @@ async def test_expense_crud_and_insurance_allocation(client, auth_headers) -> No deleted = await client.delete(f"/api/expenses/{entry_id}", headers=auth_headers) assert deleted.status_code == 204 + + +@pytest.mark.asyncio +async def test_my_data_export_preview_and_import(client, auth_headers, other_auth_headers) -> None: + car = ( + await client.post( + "/api/cars", + headers=auth_headers, + json={ + "name": "Export car", + "make": "Kia", + "model": "K5", + "plate_number": "12가3456", + "vin": "1HGCM82633A004352", + "engine_oil_type": "5W-30 API SP", + "current_odometer": 10000, + }, + ) + ).json() + await client.post( + "/api/fuel", + headers=auth_headers, + json={ + "car_id": car["id"], + "entry_date": "2026-05-12", + "odometer": 10100, + "liters": 40, + "price_per_liter": 2, + }, + ) + await client.post( + "/api/service", + headers=auth_headers, + json={ + "car_id": car["id"], + "entry_date": "2026-05-13", + "odometer": 10200, + "service_type": "maintenance", + "title": "Oil change", + "total_cost": 120, + }, + ) + await client.post( + "/api/expenses", + headers=auth_headers, + json={ + "car_id": car["id"], + "entry_date": "2026-05-14", + "category": "parking", + "title": "Parking", + "total_cost": 10, + }, + ) + + exported = await client.get("/api/my/export", headers=auth_headers) + assert exported.status_code == 200 + payload = exported.json() + assert payload["schema"] == "carpass.exchange.v1" + assert payload["vehicles"][0]["vehicle"]["name"] == "Export car" + + preview = await client.post("/api/my/import/preview", headers=other_auth_headers, json=payload) + assert preview.status_code == 200 + assert preview.json()["counts"] == { + "vehicles": 1, + "fuel_entries": 1, + "service_entries": 1, + "expense_entries": 1, + "appointments": 0, + "service_visits": 0, + } + + imported = await client.post("/api/my/import", headers=other_auth_headers, json=payload) + assert imported.status_code == 200 + assert imported.json()["imported"]["vehicles_created"] == 1 + assert imported.json()["imported"]["fuel_entries"] == 1 + + repeated = await client.post("/api/my/import", headers=other_auth_headers, json=payload) + assert repeated.status_code == 200 + assert repeated.json()["imported"]["vehicles_matched"] == 1 + assert repeated.json()["imported"]["fuel_entries"] == 0 + + imported_cars = await client.get("/api/cars", headers=other_auth_headers) + assert imported_cars.status_code == 200 + imported_car = imported_cars.json()[0] + assert imported_car["name"] == "Export car" + assert imported_car["engine_oil_type"] == "5W-30 API SP" + assert imported_car["current_odometer"] == 10200 diff --git a/tests/test_sto_booking.py b/tests/test_sto_booking.py index cc0918d..a31618f 100644 --- a/tests/test_sto_booking.py +++ b/tests/test_sto_booking.py @@ -74,6 +74,10 @@ async def test_available_slots_skip_weekend_lunch_and_holidays( "accepts_online_booking": True, }, ) + settings = await client.get(f"/api/sto/settings/booking?service_center_id={center['id']}", headers=auth_headers) + assert settings.status_code == 200 + assert settings.json()["working_days"] == [0] + assert settings.json()["open_time"] == "09:00:00" await client.post( "/api/sto/settings/holidays", headers=auth_headers, diff --git a/web/book_sto.html b/web/book_sto.html new file mode 100644 index 0000000..434152a --- /dev/null +++ b/web/book_sto.html @@ -0,0 +1,132 @@ + + +
+ + + +СТО
+Заявка
+Автомобиль
+Карточка авто
+Данные
+Обмен данными
+Экспорт
+Импорт
+