Add service platform foundation
This commit is contained in:
226
PROJECT_PLAN.md
Normal file
226
PROJECT_PLAN.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Drivers Bot Platform Plan
|
||||||
|
|
||||||
|
## 1. Current Architecture Summary
|
||||||
|
|
||||||
|
- FastAPI serves JSON API and static Telegram Mini App files from `web/`.
|
||||||
|
- aiogram bot opens the Mini App with `WEBAPP_URL` and talks to the API through `INTERNAL_API_TOKEN`.
|
||||||
|
- Users are Telegram-backed; user API access is based on verified Telegram WebApp `initData`.
|
||||||
|
- Current domain model covers personal garage use: `users`, `cars`, `fuel_entries`, `service_entries`, catalog tables, push subscriptions, service center stubs, and inbox messages.
|
||||||
|
- Existing MVP must remain compatible: personal cars, fuel logs, service logs, reminders, stats, catalog, OCR text parsing, bot start/cars/add_car flows.
|
||||||
|
|
||||||
|
## 2. Target Product Model
|
||||||
|
|
||||||
|
Drivers Bot evolves from a personal car diary into a controlled multi-sided platform:
|
||||||
|
|
||||||
|
- Vehicle owners manage cars and personal expense/service history.
|
||||||
|
- Verified service centers create service visits and work items for vehicles.
|
||||||
|
- Owners control access to vehicles and confirm sensitive changes.
|
||||||
|
- Service centers are tenants: employees can only see their own service center data and vehicle data for granted access or active visits.
|
||||||
|
- Moderators/admins verify service centers, inspect disputes, and audit platform actions.
|
||||||
|
|
||||||
|
Open product decisions:
|
||||||
|
|
||||||
|
- Whether verified service centers can create temporary vehicle records before owner claim. Initial implementation allows service visits only when vehicle exists or owner grants access; temporary cards are deferred to avoid ownership fraud.
|
||||||
|
- Whether `service_entries` and `service_visits` should be merged. Initial implementation keeps existing `service_entries` for MVP and adds `service_visits/work_items` as the platform layer.
|
||||||
|
|
||||||
|
## 3. UX/UI Flows
|
||||||
|
|
||||||
|
Owner flows:
|
||||||
|
|
||||||
|
1. Create/edit vehicle manually with make, model, year, plate, VIN, odometer, oil data, and service norms.
|
||||||
|
2. Open "Connected services" and grant/revoke service access using invite/access code or request from service center.
|
||||||
|
3. See service history from personal entries and service-center visits.
|
||||||
|
4. Receive confirmation requests for service visits and critical vehicle changes.
|
||||||
|
5. Confirm, dispute, reject, or hide sensitive details.
|
||||||
|
|
||||||
|
Service center flows:
|
||||||
|
|
||||||
|
1. Create service profile.
|
||||||
|
2. Submit verification details/documents.
|
||||||
|
3. After verification, invite employees.
|
||||||
|
4. Employee starts a visit, searches/scans plate/VIN, requests access if needed, then adds work items.
|
||||||
|
5. Manager/owner completes visit, moving it to owner confirmation.
|
||||||
|
|
||||||
|
Admin/moderator flows:
|
||||||
|
|
||||||
|
1. Review pending service centers.
|
||||||
|
2. Approve/reject/suspend service centers.
|
||||||
|
3. Inspect audit log, disputes, and suspicious searches.
|
||||||
|
|
||||||
|
## 4. Roles And Permissions
|
||||||
|
|
||||||
|
Platform roles:
|
||||||
|
|
||||||
|
- `user`: owns/drives/views vehicles according to `vehicle_access`.
|
||||||
|
- `service_owner`: owns a service center, manages verification and employees.
|
||||||
|
- `service_employee`: works inside one service center.
|
||||||
|
- `verifier` / `moderator`: reviews verification and disputes.
|
||||||
|
- `admin`: full moderation and platform operations.
|
||||||
|
|
||||||
|
Service employee roles:
|
||||||
|
|
||||||
|
- `receptionist`: create visits and access requests.
|
||||||
|
- `mechanic`: add work items and recommendations.
|
||||||
|
- `manager`: complete visits and manage employees.
|
||||||
|
- `owner`: manage center, employees, and verification.
|
||||||
|
|
||||||
|
Permission rule: backend checks every endpoint with `current_user`; frontend permissions are only UX hints.
|
||||||
|
|
||||||
|
## 5. Data Access Rules
|
||||||
|
|
||||||
|
- Owners can see and modify their active vehicles and personal logs.
|
||||||
|
- A service center can see only:
|
||||||
|
- its own profile, employees, verification requests, visits;
|
||||||
|
- minimal masked vehicle data during access request;
|
||||||
|
- visit details for vehicles with active access or active visit.
|
||||||
|
- Plate/VIN search must not reveal owner identity, Telegram ID, phone, full expense history, or sensitive attributes.
|
||||||
|
- `user_id` from request body is not trusted.
|
||||||
|
- `vehicle_id` access requires owner role, active `VehicleAccess`, or a service visit scoped to the service center.
|
||||||
|
|
||||||
|
## 6. Anti-Fraud Model
|
||||||
|
|
||||||
|
- No automatic ownership claim by VIN/license plate.
|
||||||
|
- VIN and license plate are normalized; VIN is unique when present.
|
||||||
|
- License plate uniqueness is scoped by country.
|
||||||
|
- Plate/VIN changes create audit and may create `VehicleDataChangeRequest`.
|
||||||
|
- Service-created visits are not trusted until owner confirmation.
|
||||||
|
- Critical fields requiring owner approval: VIN, license plate, odometer, oil type/volume, service norms, engine parameters.
|
||||||
|
- Service center actions are auditable and cannot be hard-deleted.
|
||||||
|
- Rate limiting for plate/VIN/OCR/access requests is required before broad public launch. Initial implementation logs searches/actions; hard rate limit can be added via middleware/Redis.
|
||||||
|
|
||||||
|
## 7. Database Changes
|
||||||
|
|
||||||
|
Extend `cars` as Vehicle-compatible table:
|
||||||
|
|
||||||
|
- `license_plate_display`
|
||||||
|
- `license_plate_normalized`
|
||||||
|
- `license_plate_country`
|
||||||
|
- `vin_normalized`
|
||||||
|
- unique `vin_normalized` when not null
|
||||||
|
- unique `(license_plate_country, license_plate_normalized)` when not null
|
||||||
|
|
||||||
|
New tables:
|
||||||
|
|
||||||
|
- `vehicle_access`
|
||||||
|
- `service_center_verifications`
|
||||||
|
- `service_employees`
|
||||||
|
- `service_visits`
|
||||||
|
- `service_work_items`
|
||||||
|
- `vehicle_data_change_requests`
|
||||||
|
- `audit_logs`
|
||||||
|
|
||||||
|
Existing tables kept:
|
||||||
|
|
||||||
|
- `service_entries` remain the personal/MVP service diary.
|
||||||
|
- `service_centers` is expanded instead of replaced.
|
||||||
|
|
||||||
|
## 8. API Design
|
||||||
|
|
||||||
|
Owner API:
|
||||||
|
|
||||||
|
- `GET /api/me`
|
||||||
|
- `GET /api/my/vehicles`
|
||||||
|
- `POST /api/my/vehicles`
|
||||||
|
- `PATCH /api/my/vehicles/{vehicle_id}`
|
||||||
|
- `GET /api/my/vehicles/{vehicle_id}/service-history`
|
||||||
|
- `POST /api/my/vehicles/{vehicle_id}/grant-service-access`
|
||||||
|
- `POST /api/service-visits/{visit_id}/confirm`
|
||||||
|
- `POST /api/service-visits/{visit_id}/dispute`
|
||||||
|
- `POST /api/vehicle-change-requests/{id}/approve`
|
||||||
|
- `POST /api/vehicle-change-requests/{id}/reject`
|
||||||
|
|
||||||
|
Service API:
|
||||||
|
|
||||||
|
- `POST /api/service-centers`
|
||||||
|
- `GET /api/service-centers/my`
|
||||||
|
- `POST /api/service-centers/{id}/verification`
|
||||||
|
- `POST /api/service-centers/{id}/employees/invite`
|
||||||
|
- `GET /api/service-centers/{id}/visits`
|
||||||
|
- `POST /api/service-centers/{id}/visits`
|
||||||
|
- `POST /api/service-visits/{id}/work-items`
|
||||||
|
- `POST /api/service-visits/{id}/complete`
|
||||||
|
- `POST /api/service-centers/{id}/vehicle-access/request`
|
||||||
|
|
||||||
|
Admin API:
|
||||||
|
|
||||||
|
- `GET /api/admin/service-centers/pending`
|
||||||
|
- `POST /api/admin/service-centers/{id}/verify`
|
||||||
|
- `POST /api/admin/service-centers/{id}/reject`
|
||||||
|
- `POST /api/admin/service-centers/{id}/suspend`
|
||||||
|
- `GET /api/admin/audit-log`
|
||||||
|
- `GET /api/admin/disputes`
|
||||||
|
|
||||||
|
## 9. OCR Design
|
||||||
|
|
||||||
|
OCR is provider-based:
|
||||||
|
|
||||||
|
- `OCRProvider` interface.
|
||||||
|
- `StubOCRProvider` default: extracts candidates from text/file name and never claims real image OCR.
|
||||||
|
- Future: `TesseractOCRProvider`, cloud OCR, or VLM provider.
|
||||||
|
|
||||||
|
OCR response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recognized_text": "...",
|
||||||
|
"candidates": [
|
||||||
|
{"type": "license_plate", "value": "...", "confidence": 0.91}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
OCR endpoints create suggestions only. They must not update vehicle data directly.
|
||||||
|
|
||||||
|
## 10. Migration Plan
|
||||||
|
|
||||||
|
1. Add nullable columns and new tables with conservative constraints.
|
||||||
|
2. Backfill current `cars.plate_number`/`cars.vin` into display/normalized columns where valid.
|
||||||
|
3. Create owner `vehicle_access` rows for existing `cars.owner_id`.
|
||||||
|
4. Add unique indexes for VIN and country/plate.
|
||||||
|
5. Keep existing endpoints compatible while new `/api/my/*` and service-center endpoints roll out.
|
||||||
|
|
||||||
|
## 11. Test Plan
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- VIN normalization/validation.
|
||||||
|
- Plate normalization.
|
||||||
|
- Owner cannot claim another vehicle by VIN/plate.
|
||||||
|
- Service employee cannot see another service center data.
|
||||||
|
- Unverified service center cannot complete trusted visits.
|
||||||
|
- Owner confirmation changes visit status.
|
||||||
|
- Critical data change request approve/reject updates only after owner action.
|
||||||
|
- OCR endpoint returns candidates without writing data.
|
||||||
|
- Existing fuel/service/reminders tests remain green.
|
||||||
|
|
||||||
|
Manual:
|
||||||
|
|
||||||
|
- Telegram Mini App opens with local test bot.
|
||||||
|
- Existing garage/fuel/service flows still work.
|
||||||
|
- Direct browser page shows Telegram-only message unless dev auth enabled.
|
||||||
|
|
||||||
|
## 12. Implementation Phases
|
||||||
|
|
||||||
|
Phase 1:
|
||||||
|
|
||||||
|
- Expand models and migrations.
|
||||||
|
- Add permission helpers.
|
||||||
|
- Add basic service center create/my/verification/employee flow.
|
||||||
|
|
||||||
|
Phase 2:
|
||||||
|
|
||||||
|
- Add vehicle identity fields, ownership/access model.
|
||||||
|
- Add service visits/work items.
|
||||||
|
- Add owner confirmation and vehicle data change requests.
|
||||||
|
|
||||||
|
Phase 3:
|
||||||
|
|
||||||
|
- Add OCR provider interface and license plate/VIN/service document endpoints.
|
||||||
|
- Add lightweight frontend sections for service center panel, visits, confirmations, OCR.
|
||||||
|
- Add admin moderation endpoints.
|
||||||
|
|
||||||
|
Phase 4:
|
||||||
|
|
||||||
|
- Add tests, README, `.env.example`.
|
||||||
|
- Run Docker compose, Alembic, API health, bot startup with local test token.
|
||||||
|
- Keep production deploy separate unless explicitly requested.
|
||||||
65
README.md
65
README.md
@@ -22,7 +22,7 @@ https://drivers.smartsoltech.kr
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
/setdomain
|
/setdomain
|
||||||
@seoulmate_officialbot
|
@your_bot_username
|
||||||
drivers.smartsoltech.kr
|
drivers.smartsoltech.kr
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ POSTGRES_PORT=5433
|
|||||||
DATABASE_URL=postgresql+asyncpg://drivers:change-this-db-password@db:5432/drivers
|
DATABASE_URL=postgresql+asyncpg://drivers:change-this-db-password@db:5432/drivers
|
||||||
|
|
||||||
BOT_TOKEN=123456:telegram-token
|
BOT_TOKEN=123456:telegram-token
|
||||||
BOT_USERNAME=seoulmate_officialbot
|
BOT_USERNAME=your_bot_username
|
||||||
WEBAPP_URL=https://drivers.smartsoltech.kr
|
WEBAPP_URL=https://drivers.smartsoltech.kr
|
||||||
PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr
|
PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr
|
||||||
API_BASE_URL=http://api:8000
|
API_BASE_URL=http://api:8000
|
||||||
@@ -122,6 +122,12 @@ Backend проверяет подпись Telegram, создает/обновл
|
|||||||
## Основные endpoint-ы
|
## Основные endpoint-ы
|
||||||
|
|
||||||
- `GET /api/users/me`
|
- `GET /api/users/me`
|
||||||
|
- `GET /api/me`
|
||||||
|
- `GET /api/my/vehicles`
|
||||||
|
- `POST /api/my/vehicles`
|
||||||
|
- `PATCH /api/my/vehicles/{vehicle_id}`
|
||||||
|
- `GET /api/my/vehicles/{vehicle_id}/service-history`
|
||||||
|
- `POST /api/my/vehicles/{vehicle_id}/grant-service-access`
|
||||||
- `POST /api/cars`, `GET /api/cars`, `GET/PATCH/DELETE /api/cars/{id}`
|
- `POST /api/cars`, `GET /api/cars`, `GET/PATCH/DELETE /api/cars/{id}`
|
||||||
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel?limit=50&offset=0`
|
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel?limit=50&offset=0`
|
||||||
- `PATCH /api/fuel/{id}`, `DELETE /api/fuel/{id}`
|
- `PATCH /api/fuel/{id}`, `DELETE /api/fuel/{id}`
|
||||||
@@ -129,10 +135,65 @@ Backend проверяет подпись Telegram, создает/обновл
|
|||||||
- `PATCH /api/service/{id}`, `DELETE /api/service/{id}`
|
- `PATCH /api/service/{id}`, `DELETE /api/service/{id}`
|
||||||
- `GET /api/cars/{car_id}/stats`
|
- `GET /api/cars/{car_id}/stats`
|
||||||
- `GET /api/users/{user_id}/reminders?limit=50&offset=0`
|
- `GET /api/users/{user_id}/reminders?limit=50&offset=0`
|
||||||
|
- `POST /api/service-centers`
|
||||||
|
- `GET /api/service-centers/my`
|
||||||
|
- `POST /api/service-centers/{id}/verification`
|
||||||
|
- `POST /api/service-centers/{id}/employees/invite`
|
||||||
|
- `GET /api/service-centers/{id}/visits`
|
||||||
|
- `POST /api/service-centers/{id}/visits`
|
||||||
|
- `POST /api/service-visits/{id}/work-items`
|
||||||
|
- `POST /api/service-visits/{id}/complete`
|
||||||
|
- `POST /api/service-visits/{id}/confirm`
|
||||||
|
- `POST /api/service-visits/{id}/dispute`
|
||||||
|
- `POST /api/service-visits/{id}/vehicle-change-requests`
|
||||||
|
- `POST /api/vehicle-change-requests/{id}/approve`
|
||||||
|
- `POST /api/vehicle-change-requests/{id}/reject`
|
||||||
|
- `GET /api/admin/service-centers/pending`
|
||||||
|
- `POST /api/admin/service-centers/{id}/verify`
|
||||||
|
- `POST /api/admin/service-centers/{id}/reject`
|
||||||
|
- `POST /api/admin/service-centers/{id}/suspend`
|
||||||
|
- `GET /api/admin/audit-log`
|
||||||
|
- `GET /api/admin/disputes`
|
||||||
- `POST /api/ocr/parse-text-receipt`
|
- `POST /api/ocr/parse-text-receipt`
|
||||||
|
- `POST /api/ocr/license-plate`
|
||||||
|
- `POST /api/ocr/vin`
|
||||||
|
- `POST /api/ocr/service-document`
|
||||||
|
|
||||||
Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру.
|
Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру.
|
||||||
|
|
||||||
## OCR
|
## OCR
|
||||||
|
|
||||||
Настоящий OCR по фото/PDF пока не подключен. Endpoint `POST /api/ocr/parse-text-receipt` честно разбирает только текстовый чек. Старый `/api/ocr/fuel-receipt` оставлен как deprecated-совместимость.
|
Настоящий OCR по фото/PDF пока не подключен. Endpoint `POST /api/ocr/parse-text-receipt` честно разбирает только текстовый чек. Старый `/api/ocr/fuel-receipt` оставлен как deprecated-совместимость.
|
||||||
|
|
||||||
|
Новая OCR-архитектура использует заменяемый provider:
|
||||||
|
|
||||||
|
- `OCRProvider`
|
||||||
|
- `StubOCRProvider`
|
||||||
|
- будущие `TesseractOCRProvider`, cloud OCR или VLM provider
|
||||||
|
|
||||||
|
OCR возвращает кандидаты и не меняет данные автомобиля напрямую:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recognized_text": "VIN KMHCT41BAHU123456",
|
||||||
|
"candidates": [
|
||||||
|
{"type": "vin", "value": "KMHCT41BAHU123456", "confidence": 0.84}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Roadmap
|
||||||
|
|
||||||
|
Проектное расширение в сторону владельцев авто и СТО описано в `PROJECT_PLAN.md`. В код добавлен первый совместимый слой платформы:
|
||||||
|
|
||||||
|
- расширенный `ServiceCenter`;
|
||||||
|
- верификация СТО;
|
||||||
|
- сотрудники СТО;
|
||||||
|
- `VehicleAccess`;
|
||||||
|
- `ServiceVisit`;
|
||||||
|
- `ServiceWorkItem`;
|
||||||
|
- `VehicleDataChangeRequest`;
|
||||||
|
- `AuditLog`;
|
||||||
|
- нормализация VIN и госномера.
|
||||||
|
|
||||||
|
СТО не получает персональные данные владельца по VIN/номеру. Поиск возвращает только минимальную маскированную карточку и пишет действие в аудит. Критичные изменения автомобиля проходят через запрос подтверждения владельцем.
|
||||||
|
|||||||
303
alembic/versions/202605120005_platform_service_visits.py
Normal file
303
alembic/versions/202605120005_platform_service_visits.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
"""platform service visits
|
||||||
|
|
||||||
|
Revision ID: 202605120005
|
||||||
|
Revises: 202605120004
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "202605120005"
|
||||||
|
down_revision: str | None = "202605120004"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("platform_role", sa.String(length=32), server_default="user", nullable=False))
|
||||||
|
op.create_index(op.f("ix_users_platform_role"), "users", ["platform_role"])
|
||||||
|
|
||||||
|
op.add_column("cars", sa.Column("license_plate_display", sa.String(length=32), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("license_plate_normalized", sa.String(length=32), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("license_plate_country", sa.String(length=2), nullable=True))
|
||||||
|
op.add_column("cars", sa.Column("vin_normalized", sa.String(length=17), nullable=True))
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
update cars
|
||||||
|
set license_plate_display = plate_number,
|
||||||
|
license_plate_normalized = upper(replace(replace(plate_number, ' ', ''), '-', '')),
|
||||||
|
vin_normalized = upper(vin)
|
||||||
|
where plate_number is not null or vin is not null
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_cars_license_plate_normalized"), "cars", ["license_plate_normalized"])
|
||||||
|
op.create_index(op.f("ix_cars_license_plate_country"), "cars", ["license_plate_country"])
|
||||||
|
op.create_index(op.f("ix_cars_vin_normalized"), "cars", ["vin_normalized"], unique=True)
|
||||||
|
op.create_index(
|
||||||
|
"uq_cars_country_license_plate",
|
||||||
|
"cars",
|
||||||
|
["license_plate_country", "license_plate_normalized"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("license_plate_country is not null and license_plate_normalized is not null"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column("service_centers", sa.Column("legal_name", sa.String(length=240), nullable=True))
|
||||||
|
op.add_column("service_centers", sa.Column("display_name", sa.String(length=160), nullable=True))
|
||||||
|
op.add_column("service_centers", sa.Column("country", sa.String(length=2), nullable=True))
|
||||||
|
op.add_column("service_centers", sa.Column("city", sa.String(length=120), nullable=True))
|
||||||
|
op.add_column("service_centers", sa.Column("phone", sa.String(length=40), nullable=True))
|
||||||
|
op.add_column("service_centers", sa.Column("business_registration_number", sa.String(length=80), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"service_centers",
|
||||||
|
sa.Column("verification_status", sa.String(length=24), server_default="pending", nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column("service_centers", sa.Column("owner_user_id", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("service_centers", sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("service_centers", sa.Column("suspended_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_service_centers_owner_user_id_users",
|
||||||
|
"service_centers",
|
||||||
|
"users",
|
||||||
|
["owner_user_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_service_centers_display_name"), "service_centers", ["display_name"])
|
||||||
|
op.create_index(op.f("ix_service_centers_country"), "service_centers", ["country"])
|
||||||
|
op.create_index(op.f("ix_service_centers_verification_status"), "service_centers", ["verification_status"])
|
||||||
|
op.create_index(op.f("ix_service_centers_owner_user_id"), "service_centers", ["owner_user_id"])
|
||||||
|
op.execute("update service_centers set display_name = name where display_name is null")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"vehicle_access",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vehicle_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=24), server_default="owner", nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="active", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("vehicle_id", "user_id", "role", name="uq_vehicle_access_user_role"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_vehicle_access_vehicle_id"), "vehicle_access", ["vehicle_id"])
|
||||||
|
op.create_index(op.f("ix_vehicle_access_user_id"), "vehicle_access", ["user_id"])
|
||||||
|
op.create_index(op.f("ix_vehicle_access_role"), "vehicle_access", ["role"])
|
||||||
|
op.create_index(op.f("ix_vehicle_access_status"), "vehicle_access", ["status"])
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
insert into vehicle_access (vehicle_id, user_id, role, status)
|
||||||
|
select id, owner_id, 'owner', 'active'
|
||||||
|
from cars
|
||||||
|
on conflict do nothing
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"service_center_verifications",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_center_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("submitted_documents", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("comment", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="pending", nullable=False),
|
||||||
|
sa.Column("reviewed_by", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_service_center_verifications_service_center_id"), "service_center_verifications", ["service_center_id"])
|
||||||
|
op.create_index(op.f("ix_service_center_verifications_status"), "service_center_verifications", ["status"])
|
||||||
|
op.create_index(op.f("ix_service_center_verifications_reviewed_by"), "service_center_verifications", ["reviewed_by"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"service_employees",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_center_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=32), server_default="receptionist", nullable=False),
|
||||||
|
sa.Column("permissions", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="active", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("service_center_id", "user_id", name="uq_service_employee_user"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_service_employees_service_center_id"), "service_employees", ["service_center_id"])
|
||||||
|
op.create_index(op.f("ix_service_employees_user_id"), "service_employees", ["user_id"])
|
||||||
|
op.create_index(op.f("ix_service_employees_role"), "service_employees", ["role"])
|
||||||
|
op.create_index(op.f("ix_service_employees_status"), "service_employees", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"service_visits",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_center_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vehicle_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_by_employee_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("visit_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("odometer", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=40), server_default="draft", nullable=False),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("total_cost", sa.Numeric(12, 2), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(length=3), server_default="RUB", nullable=False),
|
||||||
|
sa.Column("owner_resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_employee_id"], ["service_employees.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_service_visits_service_center_id"), "service_visits", ["service_center_id"])
|
||||||
|
op.create_index(op.f("ix_service_visits_vehicle_id"), "service_visits", ["vehicle_id"])
|
||||||
|
op.create_index(op.f("ix_service_visits_created_by_employee_id"), "service_visits", ["created_by_employee_id"])
|
||||||
|
op.create_index(op.f("ix_service_visits_visit_date"), "service_visits", ["visit_date"])
|
||||||
|
op.create_index(op.f("ix_service_visits_status"), "service_visits", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"service_work_items",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("service_visit_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("work_type", sa.String(length=40), server_default="other", nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("parts", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("oil_brand", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("oil_viscosity", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("oil_volume", sa.Numeric(5, 2), nullable=True),
|
||||||
|
sa.Column("next_due_odometer", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("next_due_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("price", sa.Numeric(12, 2), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["service_visit_id"], ["service_visits.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_service_work_items_service_visit_id"), "service_work_items", ["service_visit_id"])
|
||||||
|
op.create_index(op.f("ix_service_work_items_work_type"), "service_work_items", ["work_type"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"vehicle_data_change_requests",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vehicle_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("requested_by_service_center_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("requested_by_employee_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("field_name", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("old_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("new_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="pending", nullable=False),
|
||||||
|
sa.Column("owner_user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["requested_by_employee_id"], ["service_employees.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["requested_by_service_center_id"], ["service_centers.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_vehicle_data_change_requests_vehicle_id"), "vehicle_data_change_requests", ["vehicle_id"])
|
||||||
|
op.create_index(op.f("ix_vehicle_data_change_requests_requested_by_service_center_id"), "vehicle_data_change_requests", ["requested_by_service_center_id"])
|
||||||
|
op.create_index(op.f("ix_vehicle_data_change_requests_requested_by_employee_id"), "vehicle_data_change_requests", ["requested_by_employee_id"])
|
||||||
|
op.create_index(op.f("ix_vehicle_data_change_requests_field_name"), "vehicle_data_change_requests", ["field_name"])
|
||||||
|
op.create_index(op.f("ix_vehicle_data_change_requests_status"), "vehicle_data_change_requests", ["status"])
|
||||||
|
op.create_index(op.f("ix_vehicle_data_change_requests_owner_user_id"), "vehicle_data_change_requests", ["owner_user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("actor_role", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("target_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("target_id", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_audit_logs_actor_user_id"), "audit_logs", ["actor_user_id"])
|
||||||
|
op.create_index(op.f("ix_audit_logs_action"), "audit_logs", ["action"])
|
||||||
|
op.create_index(op.f("ix_audit_logs_target_type"), "audit_logs", ["target_type"])
|
||||||
|
op.create_index(op.f("ix_audit_logs_target_id"), "audit_logs", ["target_id"])
|
||||||
|
op.create_index(op.f("ix_audit_logs_created_at"), "audit_logs", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_audit_logs_created_at"), table_name="audit_logs")
|
||||||
|
op.drop_index(op.f("ix_audit_logs_target_id"), table_name="audit_logs")
|
||||||
|
op.drop_index(op.f("ix_audit_logs_target_type"), table_name="audit_logs")
|
||||||
|
op.drop_index(op.f("ix_audit_logs_action"), table_name="audit_logs")
|
||||||
|
op.drop_index(op.f("ix_audit_logs_actor_user_id"), table_name="audit_logs")
|
||||||
|
op.drop_table("audit_logs")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_vehicle_data_change_requests_owner_user_id"), table_name="vehicle_data_change_requests")
|
||||||
|
op.drop_index(op.f("ix_vehicle_data_change_requests_status"), table_name="vehicle_data_change_requests")
|
||||||
|
op.drop_index(op.f("ix_vehicle_data_change_requests_field_name"), table_name="vehicle_data_change_requests")
|
||||||
|
op.drop_index(op.f("ix_vehicle_data_change_requests_requested_by_employee_id"), table_name="vehicle_data_change_requests")
|
||||||
|
op.drop_index(op.f("ix_vehicle_data_change_requests_requested_by_service_center_id"), table_name="vehicle_data_change_requests")
|
||||||
|
op.drop_index(op.f("ix_vehicle_data_change_requests_vehicle_id"), table_name="vehicle_data_change_requests")
|
||||||
|
op.drop_table("vehicle_data_change_requests")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_service_work_items_work_type"), table_name="service_work_items")
|
||||||
|
op.drop_index(op.f("ix_service_work_items_service_visit_id"), table_name="service_work_items")
|
||||||
|
op.drop_table("service_work_items")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_service_visits_status"), table_name="service_visits")
|
||||||
|
op.drop_index(op.f("ix_service_visits_visit_date"), table_name="service_visits")
|
||||||
|
op.drop_index(op.f("ix_service_visits_created_by_employee_id"), table_name="service_visits")
|
||||||
|
op.drop_index(op.f("ix_service_visits_vehicle_id"), table_name="service_visits")
|
||||||
|
op.drop_index(op.f("ix_service_visits_service_center_id"), table_name="service_visits")
|
||||||
|
op.drop_table("service_visits")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_service_employees_status"), table_name="service_employees")
|
||||||
|
op.drop_index(op.f("ix_service_employees_role"), table_name="service_employees")
|
||||||
|
op.drop_index(op.f("ix_service_employees_user_id"), table_name="service_employees")
|
||||||
|
op.drop_index(op.f("ix_service_employees_service_center_id"), table_name="service_employees")
|
||||||
|
op.drop_table("service_employees")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_service_center_verifications_reviewed_by"), table_name="service_center_verifications")
|
||||||
|
op.drop_index(op.f("ix_service_center_verifications_status"), table_name="service_center_verifications")
|
||||||
|
op.drop_index(op.f("ix_service_center_verifications_service_center_id"), table_name="service_center_verifications")
|
||||||
|
op.drop_table("service_center_verifications")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_vehicle_access_status"), table_name="vehicle_access")
|
||||||
|
op.drop_index(op.f("ix_vehicle_access_role"), table_name="vehicle_access")
|
||||||
|
op.drop_index(op.f("ix_vehicle_access_user_id"), table_name="vehicle_access")
|
||||||
|
op.drop_index(op.f("ix_vehicle_access_vehicle_id"), table_name="vehicle_access")
|
||||||
|
op.drop_table("vehicle_access")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_service_centers_owner_user_id"), table_name="service_centers")
|
||||||
|
op.drop_index(op.f("ix_service_centers_verification_status"), table_name="service_centers")
|
||||||
|
op.drop_index(op.f("ix_service_centers_country"), table_name="service_centers")
|
||||||
|
op.drop_index(op.f("ix_service_centers_display_name"), table_name="service_centers")
|
||||||
|
op.drop_constraint("fk_service_centers_owner_user_id_users", "service_centers", type_="foreignkey")
|
||||||
|
op.drop_column("service_centers", "suspended_at")
|
||||||
|
op.drop_column("service_centers", "verified_at")
|
||||||
|
op.drop_column("service_centers", "owner_user_id")
|
||||||
|
op.drop_column("service_centers", "verification_status")
|
||||||
|
op.drop_column("service_centers", "business_registration_number")
|
||||||
|
op.drop_column("service_centers", "phone")
|
||||||
|
op.drop_column("service_centers", "city")
|
||||||
|
op.drop_column("service_centers", "country")
|
||||||
|
op.drop_column("service_centers", "display_name")
|
||||||
|
op.drop_column("service_centers", "legal_name")
|
||||||
|
|
||||||
|
op.drop_index("uq_cars_country_license_plate", table_name="cars")
|
||||||
|
op.drop_index(op.f("ix_cars_vin_normalized"), table_name="cars")
|
||||||
|
op.drop_index(op.f("ix_cars_license_plate_country"), table_name="cars")
|
||||||
|
op.drop_index(op.f("ix_cars_license_plate_normalized"), table_name="cars")
|
||||||
|
op.drop_column("cars", "vin_normalized")
|
||||||
|
op.drop_column("cars", "license_plate_country")
|
||||||
|
op.drop_column("cars", "license_plate_normalized")
|
||||||
|
op.drop_column("cars", "license_plate_display")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_users_platform_role"), table_name="users")
|
||||||
|
op.drop_column("users", "platform_role")
|
||||||
141
app/api/admin.py
Normal file
141
app/api/admin.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
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.db.session import get_session
|
||||||
|
from app.models.car import AuditLog, ServiceCenter, ServiceCenterVerification, ServiceVisit
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.service_center import ServiceCenterRead, ServiceVisitRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_or_verifier(user: User) -> None:
|
||||||
|
require_platform_role(user, {"admin", "verifier", "moderator"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/service-centers/pending", response_model=list[ServiceCenterRead])
|
||||||
|
async def pending_service_centers(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> list[ServiceCenter]:
|
||||||
|
require_admin_or_verifier(current_user)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceCenter)
|
||||||
|
.where(ServiceCenter.verification_status == "pending")
|
||||||
|
.order_by(ServiceCenter.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead)
|
||||||
|
async def verify_service_center(
|
||||||
|
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")
|
||||||
|
center.verification_status = "verified"
|
||||||
|
center.verified_at = datetime.now(UTC)
|
||||||
|
await mark_latest_verification(session, center.id, "verified", current_user.id)
|
||||||
|
await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(center)
|
||||||
|
return center
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead)
|
||||||
|
async def reject_service_center(
|
||||||
|
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")
|
||||||
|
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)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(center)
|
||||||
|
return center
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead)
|
||||||
|
async def suspend_service_center(
|
||||||
|
service_center_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceCenter:
|
||||||
|
require_platform_role(current_user, {"admin"})
|
||||||
|
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 = "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)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(center)
|
||||||
|
return center
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-log")
|
||||||
|
async def audit_log(
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> list[dict]:
|
||||||
|
require_platform_role(current_user, {"admin", "verifier", "moderator"})
|
||||||
|
limit = min(max(limit, 1), 200)
|
||||||
|
result = await session.execute(
|
||||||
|
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit).offset(max(offset, 0))
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"actor_user_id": item.actor_user_id,
|
||||||
|
"actor_role": item.actor_role,
|
||||||
|
"action": item.action,
|
||||||
|
"target_type": item.target_type,
|
||||||
|
"target_id": item.target_id,
|
||||||
|
"metadata_json": item.metadata_json,
|
||||||
|
"created_at": item.created_at,
|
||||||
|
}
|
||||||
|
for item in result.scalars()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/disputes", response_model=list[ServiceVisitRead])
|
||||||
|
async def disputes(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> list[ServiceVisit]:
|
||||||
|
require_admin_or_verifier(current_user)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceVisit).where(ServiceVisit.status == "disputed").order_by(ServiceVisit.updated_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_latest_verification(
|
||||||
|
session: AsyncSession, service_center_id: int, status: str, reviewed_by: int
|
||||||
|
) -> None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceCenterVerification)
|
||||||
|
.where(ServiceCenterVerification.service_center_id == service_center_id)
|
||||||
|
.order_by(ServiceCenterVerification.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
verification = result.scalar_one_or_none()
|
||||||
|
if verification:
|
||||||
|
verification.status = status
|
||||||
|
verification.reviewed_by = reviewed_by
|
||||||
|
verification.reviewed_at = datetime.now(UTC)
|
||||||
@@ -7,17 +7,27 @@ from app.db.session import get_session
|
|||||||
from app.models.car import Car
|
from app.models.car import Car
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||||
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||||
|
|
||||||
router = APIRouter(prefix="/cars", tags=["cars"])
|
router = APIRouter(prefix="/cars", tags=["cars"])
|
||||||
|
|
||||||
|
|
||||||
|
def apply_identity_fields(data: dict) -> dict:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_car(
|
async def create_car(
|
||||||
payload: CarCreate,
|
payload: CarCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: User = Depends(get_current_telegram_user),
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> Car:
|
) -> Car:
|
||||||
data = payload.model_dump(exclude={"owner_id"})
|
data = apply_identity_fields(payload.model_dump(exclude={"owner_id"}))
|
||||||
car = Car(**data, owner_id=current_user.id)
|
car = Car(**data, owner_id=current_user.id)
|
||||||
session.add(car)
|
session.add(car)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -65,7 +75,7 @@ async def update_car(
|
|||||||
raise HTTPException(status_code=404, detail="Car not found")
|
raise HTTPException(status_code=404, detail="Car not found")
|
||||||
if car.owner_id != current_user.id:
|
if car.owner_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
for field, value in apply_identity_fields(payload.model_dump(exclude_unset=True)).items():
|
||||||
setattr(car, field, value)
|
setattr(car, field, value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(car)
|
await session.refresh(car)
|
||||||
|
|||||||
67
app/api/change_requests.py
Normal file
67
app/api/change_requests.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_telegram_user, log_audit
|
||||||
|
from app.api.service_visits import apply_vehicle_change
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.car import Car, VehicleDataChangeRequest
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.service_center import VehicleDataChangeRequestRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vehicle-change-requests", tags=["vehicle-change-requests"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
|
||||||
|
async def approve_vehicle_change_request(
|
||||||
|
request_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> VehicleDataChangeRequest:
|
||||||
|
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Change request not found")
|
||||||
|
if request.owner_user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
vehicle = await session.get(Car, request.vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
apply_vehicle_change(vehicle, request.field_name, request.new_value)
|
||||||
|
request.status = "approved"
|
||||||
|
request.resolved_at = datetime.now(UTC)
|
||||||
|
await log_audit(
|
||||||
|
session,
|
||||||
|
actor=current_user,
|
||||||
|
action="vehicle_change_request.approve",
|
||||||
|
target_type="vehicle_change_request",
|
||||||
|
target_id=request_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(request)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
|
||||||
|
async def reject_vehicle_change_request(
|
||||||
|
request_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> VehicleDataChangeRequest:
|
||||||
|
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Change request not found")
|
||||||
|
if request.owner_user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
request.status = "rejected"
|
||||||
|
request.resolved_at = datetime.now(UTC)
|
||||||
|
await log_audit(
|
||||||
|
session,
|
||||||
|
actor=current_user,
|
||||||
|
action="vehicle_change_request.reject",
|
||||||
|
target_type="vehicle_change_request",
|
||||||
|
target_id=request_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(request)
|
||||||
|
return request
|
||||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import Car
|
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.telegram_auth import verify_webapp_init_data
|
from app.services.telegram_auth import verify_webapp_init_data
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ async def get_or_create_telegram_user(
|
|||||||
last_name: str | None = None,
|
last_name: str | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
currency: str | None = None,
|
currency: str | None = None,
|
||||||
|
platform_role: str | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
|
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -30,6 +31,7 @@ async def get_or_create_telegram_user(
|
|||||||
"last_name": last_name,
|
"last_name": last_name,
|
||||||
"locale": locale,
|
"locale": locale,
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
|
"platform_role": platform_role,
|
||||||
}
|
}
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(**{key: value for key, value in payload.items() if value is not None})
|
user = User(**{key: value for key, value in payload.items() if value is not None})
|
||||||
@@ -92,3 +94,95 @@ async def get_owned_car(
|
|||||||
if car.owner_id != current_user.id:
|
if car.owner_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
return car
|
return car
|
||||||
|
|
||||||
|
|
||||||
|
async def user_has_vehicle_access(
|
||||||
|
session: AsyncSession, vehicle_id: int, user_id: int, roles: set[str] | None = None
|
||||||
|
) -> bool:
|
||||||
|
stmt = select(VehicleAccess).where(
|
||||||
|
VehicleAccess.vehicle_id == vehicle_id,
|
||||||
|
VehicleAccess.user_id == user_id,
|
||||||
|
VehicleAccess.status == "active",
|
||||||
|
)
|
||||||
|
if roles:
|
||||||
|
stmt = stmt.where(VehicleAccess.role.in_(roles))
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_vehicle_owner_or_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
vehicle_id: int,
|
||||||
|
user: User,
|
||||||
|
roles: set[str] | None = None,
|
||||||
|
) -> Car:
|
||||||
|
car = await session.get(Car, vehicle_id)
|
||||||
|
if car is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
if car.owner_id == user.id:
|
||||||
|
return car
|
||||||
|
if await user_has_vehicle_access(session, vehicle_id, user.id, roles):
|
||||||
|
return car
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
def require_platform_role(user: User, allowed: set[str]) -> None:
|
||||||
|
if user.platform_role not in allowed:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_service_employee(
|
||||||
|
session: AsyncSession,
|
||||||
|
service_center_id: int,
|
||||||
|
user: User,
|
||||||
|
allowed_roles: set[str] | None = None,
|
||||||
|
) -> ServiceEmployee:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceEmployee).where(
|
||||||
|
ServiceEmployee.service_center_id == service_center_id,
|
||||||
|
ServiceEmployee.user_id == user.id,
|
||||||
|
ServiceEmployee.status == "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
employee = result.scalar_one_or_none()
|
||||||
|
center = await session.get(ServiceCenter, service_center_id)
|
||||||
|
owner_allowed = center is not None and center.owner_user_id == user.id
|
||||||
|
if employee is None and owner_allowed:
|
||||||
|
employee = ServiceEmployee(
|
||||||
|
service_center_id=service_center_id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="owner",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
session.add(employee)
|
||||||
|
await session.flush()
|
||||||
|
if employee is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Service center access required")
|
||||||
|
if allowed_roles and employee.role not in allowed_roles:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient service role")
|
||||||
|
return employee
|
||||||
|
|
||||||
|
|
||||||
|
async def log_audit(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User | None,
|
||||||
|
action: str,
|
||||||
|
target_type: str,
|
||||||
|
target_id: int | str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
session.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_user_id=actor.id if actor else None,
|
||||||
|
actor_role=actor.platform_role if actor else None,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=str(target_id) if target_id is not None else None,
|
||||||
|
metadata_json=metadata,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent[:256] if user_agent else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
157
app/api/my.py
Normal file
157
app/api/my.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from sqlalchemy import 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.user import User
|
||||||
|
from app.schemas.service_center import (
|
||||||
|
VehicleAccessGrant,
|
||||||
|
VehicleAccessRead,
|
||||||
|
VehicleCreate,
|
||||||
|
VehicleRead,
|
||||||
|
VehicleUpdate,
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserRead
|
||||||
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||||
|
|
||||||
|
router = APIRouter(tags=["my"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
async def me(current_user: User = Depends(get_current_telegram_user)) -> User:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def vehicle_data(payload: VehicleCreate | VehicleUpdate, *, partial: bool = False) -> dict:
|
||||||
|
raw = payload.model_dump(exclude_unset=partial)
|
||||||
|
data = {
|
||||||
|
key: value
|
||||||
|
for key, value in raw.items()
|
||||||
|
if key not in {"license_plate", "license_plate_country", "vin"}
|
||||||
|
}
|
||||||
|
if "license_plate" in raw:
|
||||||
|
data["license_plate_display"] = raw["license_plate"]
|
||||||
|
data["license_plate_normalized"] = normalize_license_plate(raw["license_plate"])
|
||||||
|
data["plate_number"] = raw["license_plate"]
|
||||||
|
if "license_plate_country" in raw:
|
||||||
|
data["license_plate_country"] = (
|
||||||
|
raw["license_plate_country"].upper() if raw["license_plate_country"] else None
|
||||||
|
)
|
||||||
|
if "vin" in raw:
|
||||||
|
data["vin_normalized"] = validate_vin(raw["vin"])
|
||||||
|
data["vin"] = raw["vin"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my/vehicles", response_model=list[VehicleRead])
|
||||||
|
async def my_vehicles(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> 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")
|
||||||
|
.order_by(Car.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/my/vehicles", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_vehicle(
|
||||||
|
payload: VehicleCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> Car:
|
||||||
|
car = Car(**vehicle_data(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"))
|
||||||
|
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(car)
|
||||||
|
return car
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/my/vehicles/{vehicle_id}", response_model=VehicleRead)
|
||||||
|
async def update_vehicle(
|
||||||
|
vehicle_id: int,
|
||||||
|
payload: VehicleUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> Car:
|
||||||
|
car = await session.get(Car, vehicle_id)
|
||||||
|
if car is None:
|
||||||
|
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():
|
||||||
|
setattr(car, field, value)
|
||||||
|
await log_audit(session, actor=current_user, action="vehicle.update", target_type="vehicle", target_id=car.id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(car)
|
||||||
|
return car
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my/vehicles/{vehicle_id}/service-history")
|
||||||
|
async def vehicle_service_history(
|
||||||
|
vehicle_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> dict:
|
||||||
|
car = await session.get(Car, vehicle_id)
|
||||||
|
if car is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
if car.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceVisit)
|
||||||
|
.where(ServiceVisit.vehicle_id == vehicle_id)
|
||||||
|
.order_by(ServiceVisit.visit_date.desc())
|
||||||
|
)
|
||||||
|
visits = list(result.scalars())
|
||||||
|
return {"vehicle_id": vehicle_id, "service_visits": jsonable_encoder(visits)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
|
||||||
|
async def grant_vehicle_access(
|
||||||
|
vehicle_id: int,
|
||||||
|
payload: VehicleAccessGrant,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> VehicleAccess:
|
||||||
|
car = await session.get(Car, vehicle_id)
|
||||||
|
if car is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
if car.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
if not payload.user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="user_id is required for access grants")
|
||||||
|
result = await session.execute(
|
||||||
|
select(VehicleAccess).where(
|
||||||
|
VehicleAccess.vehicle_id == vehicle_id,
|
||||||
|
VehicleAccess.user_id == payload.user_id,
|
||||||
|
VehicleAccess.role == payload.role,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
access = result.scalar_one_or_none()
|
||||||
|
if access is None:
|
||||||
|
access = VehicleAccess(vehicle_id=vehicle_id, user_id=payload.user_id, role=payload.role, status="active")
|
||||||
|
session.add(access)
|
||||||
|
else:
|
||||||
|
access.status = "active"
|
||||||
|
access.revoked_at = None
|
||||||
|
await log_audit(
|
||||||
|
session,
|
||||||
|
actor=current_user,
|
||||||
|
action="vehicle_access.grant",
|
||||||
|
target_type="vehicle",
|
||||||
|
target_id=vehicle_id,
|
||||||
|
metadata={"granted_user_id": payload.user_id, "role": payload.role},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(access)
|
||||||
|
return access
|
||||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from app.api.deps import get_current_telegram_user
|
from app.api.deps import get_current_telegram_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.ocr_provider import get_ocr_provider
|
||||||
|
|
||||||
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
||||||
|
|
||||||
@@ -19,6 +20,17 @@ class ReceiptSuggestion(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class OCRCandidateRead(BaseModel):
|
||||||
|
type: str
|
||||||
|
value: str
|
||||||
|
confidence: float
|
||||||
|
|
||||||
|
|
||||||
|
class OCRResultRead(BaseModel):
|
||||||
|
recognized_text: str
|
||||||
|
candidates: list[OCRCandidateRead]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||||
async def parse_text_receipt(
|
async def parse_text_receipt(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -81,6 +93,42 @@ async def scan_fuel_receipt(
|
|||||||
return await parse_text_receipt(file, current_user)
|
return await parse_text_receipt(file, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/license-plate", response_model=OCRResultRead)
|
||||||
|
async def recognize_license_plate(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> OCRResultRead:
|
||||||
|
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||||
|
return OCRResultRead(
|
||||||
|
recognized_text=result.recognized_text,
|
||||||
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vin", response_model=OCRResultRead)
|
||||||
|
async def recognize_vin(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> OCRResultRead:
|
||||||
|
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||||
|
return OCRResultRead(
|
||||||
|
recognized_text=result.recognized_text,
|
||||||
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/service-document", response_model=OCRResultRead)
|
||||||
|
async def recognize_service_document(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> OCRResultRead:
|
||||||
|
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||||
|
return OCRResultRead(
|
||||||
|
recognized_text=result.recognized_text,
|
||||||
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def detect_station(text: str) -> str | None:
|
def detect_station(text: str) -> str | None:
|
||||||
stations = {
|
stations = {
|
||||||
"shell": "Shell",
|
"shell": "Shell",
|
||||||
|
|||||||
@@ -2,35 +2,93 @@ from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import require_internal_api_token
|
from app.api.deps import (
|
||||||
|
ensure_service_employee,
|
||||||
|
get_current_telegram_user,
|
||||||
|
get_or_create_telegram_user,
|
||||||
|
log_audit,
|
||||||
|
require_internal_api_token,
|
||||||
|
)
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
|
from app.models.car import (
|
||||||
|
Car,
|
||||||
|
CarServiceLink,
|
||||||
|
ServiceCenter,
|
||||||
|
ServiceCenterVerification,
|
||||||
|
ServiceEmployee,
|
||||||
|
ServiceInboxMessage,
|
||||||
|
ServiceVisit,
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.service_center import (
|
from app.schemas.service_center import (
|
||||||
CarServiceLinkCreate,
|
CarServiceLinkCreate,
|
||||||
CarServiceLinkRead,
|
CarServiceLinkRead,
|
||||||
ServiceCenterCreate,
|
ServiceCenterCreate,
|
||||||
ServiceCenterRead,
|
ServiceCenterRead,
|
||||||
|
ServiceCenterVerificationCreate,
|
||||||
|
ServiceCenterVerificationRead,
|
||||||
|
ServiceEmployeeInvite,
|
||||||
|
ServiceEmployeeRead,
|
||||||
ServiceInboxCreate,
|
ServiceInboxCreate,
|
||||||
ServiceInboxRead,
|
ServiceInboxRead,
|
||||||
|
ServiceVisitCreate,
|
||||||
|
ServiceVisitRead,
|
||||||
|
VehicleSearchRequest,
|
||||||
|
VehicleSearchResult,
|
||||||
)
|
)
|
||||||
|
from app.services.vehicle_identity import mask_license_plate, mask_vin
|
||||||
|
|
||||||
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
|
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_service_center(
|
async def create_service_center(
|
||||||
payload: ServiceCenterCreate,
|
payload: ServiceCenterCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
) -> ServiceCenter:
|
) -> ServiceCenter:
|
||||||
require_internal_api_token(x_internal_api_token)
|
center = ServiceCenter(
|
||||||
center = ServiceCenter(**payload.model_dump())
|
name=payload.display_name,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
legal_name=payload.legal_name,
|
||||||
|
country=payload.country.upper() if payload.country else None,
|
||||||
|
city=payload.city,
|
||||||
|
address=payload.address,
|
||||||
|
phone=payload.phone,
|
||||||
|
contact_phone=payload.contact_phone or payload.phone,
|
||||||
|
telegram_chat_id=payload.telegram_chat_id,
|
||||||
|
business_registration_number=payload.business_registration_number,
|
||||||
|
owner_user_id=current_user.id,
|
||||||
|
verification_status="pending",
|
||||||
|
)
|
||||||
session.add(center)
|
session.add(center)
|
||||||
|
await session.flush()
|
||||||
|
employee = ServiceEmployee(
|
||||||
|
service_center_id=center.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
role="owner",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
session.add(employee)
|
||||||
|
await log_audit(session, actor=current_user, action="service_center.create", target_type="service_center", target_id=center.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(center)
|
await session.refresh(center)
|
||||||
return center
|
return center
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my", response_model=list[ServiceCenterRead])
|
||||||
|
async def my_service_centers(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> list[ServiceCenter]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceCenter)
|
||||||
|
.join(ServiceEmployee, ServiceEmployee.service_center_id == ServiceCenter.id)
|
||||||
|
.where(ServiceEmployee.user_id == current_user.id, ServiceEmployee.status == "active")
|
||||||
|
.order_by(ServiceCenter.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ServiceCenterRead])
|
@router.get("", response_model=list[ServiceCenterRead])
|
||||||
async def list_service_centers(
|
async def list_service_centers(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -41,6 +99,152 @@ async def list_service_centers(
|
|||||||
return list(result.scalars())
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{service_center_id}/verification", response_model=ServiceCenterVerificationRead)
|
||||||
|
async def submit_verification(
|
||||||
|
service_center_id: int,
|
||||||
|
payload: ServiceCenterVerificationCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceCenterVerification:
|
||||||
|
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
|
||||||
|
verification = ServiceCenterVerification(
|
||||||
|
service_center_id=service_center_id,
|
||||||
|
submitted_documents=payload.submitted_documents,
|
||||||
|
comment=payload.comment,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
session.add(verification)
|
||||||
|
center = await session.get(ServiceCenter, service_center_id)
|
||||||
|
if center:
|
||||||
|
center.verification_status = "pending"
|
||||||
|
await log_audit(session, actor=current_user, action="service_center.verification.submit", target_type="service_center", target_id=service_center_id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(verification)
|
||||||
|
return verification
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{service_center_id}/employees/invite", response_model=ServiceEmployeeRead)
|
||||||
|
async def invite_employee(
|
||||||
|
service_center_id: int,
|
||||||
|
payload: ServiceEmployeeInvite,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceEmployee:
|
||||||
|
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
|
||||||
|
user = await get_or_create_telegram_user(session, telegram_id=payload.telegram_id)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceEmployee).where(
|
||||||
|
ServiceEmployee.service_center_id == service_center_id,
|
||||||
|
ServiceEmployee.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
employee = result.scalar_one_or_none()
|
||||||
|
if employee is None:
|
||||||
|
employee = ServiceEmployee(
|
||||||
|
service_center_id=service_center_id,
|
||||||
|
user_id=user.id,
|
||||||
|
role=payload.role,
|
||||||
|
permissions=payload.permissions,
|
||||||
|
status="invited",
|
||||||
|
)
|
||||||
|
session.add(employee)
|
||||||
|
else:
|
||||||
|
employee.role = payload.role
|
||||||
|
employee.permissions = payload.permissions
|
||||||
|
employee.status = "invited"
|
||||||
|
await log_audit(session, actor=current_user, action="service_employee.invite", target_type="service_center", target_id=service_center_id, metadata={"telegram_id": payload.telegram_id})
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(employee)
|
||||||
|
return employee
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{service_center_id}/visits", response_model=list[ServiceVisitRead])
|
||||||
|
async def service_center_visits(
|
||||||
|
service_center_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> list[ServiceVisit]:
|
||||||
|
await ensure_service_employee(session, service_center_id, current_user)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceVisit)
|
||||||
|
.where(ServiceVisit.service_center_id == service_center_id)
|
||||||
|
.order_by(ServiceVisit.visit_date.desc(), ServiceVisit.id.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{service_center_id}/visits", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_visit(
|
||||||
|
service_center_id: int,
|
||||||
|
payload: ServiceVisitCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceVisit:
|
||||||
|
employee = await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||||
|
vehicle = await session.get(Car, payload.vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
center = await session.get(ServiceCenter, service_center_id)
|
||||||
|
if center and center.verification_status not in {"verified", "pending"}:
|
||||||
|
raise HTTPException(status_code=403, detail="Service center is not allowed to create visits")
|
||||||
|
visit = ServiceVisit(
|
||||||
|
service_center_id=service_center_id,
|
||||||
|
vehicle_id=payload.vehicle_id,
|
||||||
|
created_by_employee_id=employee.id,
|
||||||
|
visit_date=payload.visit_date,
|
||||||
|
odometer=payload.odometer,
|
||||||
|
notes=payload.notes,
|
||||||
|
total_cost=payload.total_cost,
|
||||||
|
currency=payload.currency,
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
session.add(visit)
|
||||||
|
await log_audit(session, actor=current_user, action="service_visit.create", target_type="service_visit", metadata={"vehicle_id": payload.vehicle_id})
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(visit)
|
||||||
|
return visit
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{service_center_id}/vehicle-access/request")
|
||||||
|
async def request_vehicle_access(
|
||||||
|
service_center_id: int,
|
||||||
|
payload: VehicleSearchRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> VehicleSearchResult:
|
||||||
|
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||||
|
stmt = select(Car)
|
||||||
|
if payload.vin:
|
||||||
|
stmt = stmt.where(Car.vin_normalized == payload.vin)
|
||||||
|
elif payload.license_plate:
|
||||||
|
stmt = stmt.where(Car.license_plate_normalized == payload.license_plate)
|
||||||
|
if payload.country_code:
|
||||||
|
stmt = stmt.where(Car.license_plate_country == payload.country_code.upper())
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="license_plate or vin is required")
|
||||||
|
vehicle = (await session.execute(stmt.limit(1))).scalar_one_or_none()
|
||||||
|
await log_audit(
|
||||||
|
session,
|
||||||
|
actor=current_user,
|
||||||
|
action="vehicle_access.request",
|
||||||
|
target_type="vehicle",
|
||||||
|
target_id=vehicle.id if vehicle else None,
|
||||||
|
metadata={"service_center_id": service_center_id, "found": bool(vehicle)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
if vehicle is None:
|
||||||
|
return VehicleSearchResult(access_status="not_found")
|
||||||
|
return VehicleSearchResult(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
year=vehicle.year,
|
||||||
|
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
|
||||||
|
masked_vin=mask_vin(vehicle.vin_normalized or vehicle.vin),
|
||||||
|
access_status="request_logged",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def link_car_to_service(
|
async def link_car_to_service(
|
||||||
payload: CarServiceLinkCreate,
|
payload: CarServiceLinkCreate,
|
||||||
|
|||||||
205
app/api/service_visits.py
Normal file
205
app/api/service_visits.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.car import Car, ServiceVisit, ServiceWorkItem, VehicleDataChangeRequest
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.service_center import (
|
||||||
|
ServiceVisitRead,
|
||||||
|
ServiceWorkItemCreate,
|
||||||
|
ServiceWorkItemRead,
|
||||||
|
VehicleDataChangeRequestCreate,
|
||||||
|
VehicleDataChangeRequestRead,
|
||||||
|
)
|
||||||
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/service-visits", tags=["service-visits"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_visit_or_404(session: AsyncSession, visit_id: int) -> ServiceVisit:
|
||||||
|
visit = await session.get(ServiceVisit, visit_id)
|
||||||
|
if visit is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Service visit not found")
|
||||||
|
return visit
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{visit_id}/work-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_work_item(
|
||||||
|
visit_id: int,
|
||||||
|
payload: ServiceWorkItemCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceWorkItem:
|
||||||
|
visit = await get_visit_or_404(session, visit_id)
|
||||||
|
await ensure_service_employee(
|
||||||
|
session,
|
||||||
|
visit.service_center_id,
|
||||||
|
current_user,
|
||||||
|
{"owner", "manager", "mechanic"},
|
||||||
|
)
|
||||||
|
if visit.status not in {"draft", "pending_owner_confirmation"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Visit cannot be edited in current status")
|
||||||
|
item = ServiceWorkItem(service_visit_id=visit_id, **payload.model_dump())
|
||||||
|
session.add(item)
|
||||||
|
if payload.price is not None:
|
||||||
|
visit.total_cost = (visit.total_cost or 0) + payload.price
|
||||||
|
await log_audit(session, actor=current_user, action="service_work_item.create", target_type="service_visit", target_id=visit_id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{visit_id}/complete", response_model=ServiceVisitRead)
|
||||||
|
async def complete_visit(
|
||||||
|
visit_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceVisit:
|
||||||
|
visit = await get_visit_or_404(session, visit_id)
|
||||||
|
await ensure_service_employee(session, visit.service_center_id, current_user, {"owner", "manager"})
|
||||||
|
if visit.status not in {"draft", "pending_owner_confirmation"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Visit cannot be completed")
|
||||||
|
visit.status = "pending_owner_confirmation"
|
||||||
|
await log_audit(session, actor=current_user, action="service_visit.complete", target_type="service_visit", target_id=visit_id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(visit)
|
||||||
|
return visit
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{visit_id}/confirm", response_model=ServiceVisitRead)
|
||||||
|
async def confirm_visit(
|
||||||
|
visit_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceVisit:
|
||||||
|
visit = await get_visit_or_404(session, visit_id)
|
||||||
|
vehicle = await session.get(Car, visit.vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
if vehicle.owner_id != current_user.id:
|
||||||
|
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 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)
|
||||||
|
return visit
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{visit_id}/dispute", response_model=ServiceVisitRead)
|
||||||
|
async def dispute_visit(
|
||||||
|
visit_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> ServiceVisit:
|
||||||
|
visit = await get_visit_or_404(session, visit_id)
|
||||||
|
vehicle = await session.get(Car, visit.vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
if vehicle.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
visit.status = "disputed"
|
||||||
|
visit.owner_resolved_at = datetime.now(UTC)
|
||||||
|
await log_audit(session, actor=current_user, action="service_visit.dispute", target_type="service_visit", target_id=visit_id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(visit)
|
||||||
|
return visit
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{visit_id}/vehicle-change-requests", response_model=VehicleDataChangeRequestRead)
|
||||||
|
async def create_vehicle_change_request(
|
||||||
|
visit_id: int,
|
||||||
|
payload: VehicleDataChangeRequestCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> VehicleDataChangeRequest:
|
||||||
|
visit = await get_visit_or_404(session, visit_id)
|
||||||
|
employee = await ensure_service_employee(
|
||||||
|
session,
|
||||||
|
visit.service_center_id,
|
||||||
|
current_user,
|
||||||
|
{"owner", "manager", "mechanic", "receptionist"},
|
||||||
|
)
|
||||||
|
if visit.vehicle_id != payload.vehicle_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Vehicle does not match visit")
|
||||||
|
vehicle = await session.get(Car, payload.vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
old_value = getattr(vehicle, payload.field_name, None)
|
||||||
|
request = VehicleDataChangeRequest(
|
||||||
|
vehicle_id=payload.vehicle_id,
|
||||||
|
requested_by_service_center_id=visit.service_center_id,
|
||||||
|
requested_by_employee_id=employee.id,
|
||||||
|
field_name=payload.field_name,
|
||||||
|
old_value=str(old_value) if old_value is not None else None,
|
||||||
|
new_value=payload.new_value,
|
||||||
|
status="pending",
|
||||||
|
owner_user_id=vehicle.owner_id,
|
||||||
|
)
|
||||||
|
session.add(request)
|
||||||
|
await log_audit(session, actor=current_user, action="vehicle_change_request.create", target_type="vehicle", target_id=payload.vehicle_id, metadata={"field_name": payload.field_name})
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(request)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vehicle-change-requests/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
|
||||||
|
async def approve_vehicle_change_request(
|
||||||
|
request_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> VehicleDataChangeRequest:
|
||||||
|
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Change request not found")
|
||||||
|
if request.owner_user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
vehicle = await session.get(Car, request.vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
apply_vehicle_change(vehicle, request.field_name, request.new_value)
|
||||||
|
request.status = "approved"
|
||||||
|
request.resolved_at = datetime.now(UTC)
|
||||||
|
await log_audit(session, actor=current_user, action="vehicle_change_request.approve", target_type="vehicle_change_request", target_id=request_id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(request)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vehicle-change-requests/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
|
||||||
|
async def reject_vehicle_change_request(
|
||||||
|
request_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_telegram_user),
|
||||||
|
) -> VehicleDataChangeRequest:
|
||||||
|
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Change request not found")
|
||||||
|
if request.owner_user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
request.status = "rejected"
|
||||||
|
request.resolved_at = datetime.now(UTC)
|
||||||
|
await log_audit(session, actor=current_user, action="vehicle_change_request.reject", target_type="vehicle_change_request", target_id=request_id)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(request)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def apply_vehicle_change(vehicle: Car, field_name: str, value: str | None) -> None:
|
||||||
|
if field_name in {"license_plate", "license_plate_display"}:
|
||||||
|
vehicle.license_plate_display = value
|
||||||
|
vehicle.license_plate_normalized = normalize_license_plate(value)
|
||||||
|
vehicle.plate_number = value
|
||||||
|
return
|
||||||
|
if field_name in {"vin", "vin_normalized"}:
|
||||||
|
vehicle.vin = value
|
||||||
|
vehicle.vin_normalized = validate_vin(value)
|
||||||
|
return
|
||||||
|
if not hasattr(vehicle, field_name):
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported vehicle field")
|
||||||
|
setattr(vehicle, field_name, value)
|
||||||
@@ -47,7 +47,7 @@ async def upsert_user(
|
|||||||
@router.get("/auth/config", response_model=AuthConfig)
|
@router.get("/auth/config", response_model=AuthConfig)
|
||||||
async def auth_config() -> AuthConfig:
|
async def auth_config() -> AuthConfig:
|
||||||
return AuthConfig(
|
return AuthConfig(
|
||||||
bot_username=settings.bot_username or "seoulmate_officialbot",
|
bot_username=settings.bot_username or None,
|
||||||
vapid_public_key=settings.vapid_public_key or None,
|
vapid_public_key=settings.vapid_public_key or None,
|
||||||
app_env=settings.app_env,
|
app_env=settings.app_env,
|
||||||
allow_dev_auth=settings.allow_dev_auth and not settings.is_production,
|
allow_dev_auth=settings.allow_dev_auth and not settings.is_production,
|
||||||
|
|||||||
17
app/main.py
17
app/main.py
@@ -2,7 +2,18 @@ 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, service_centers, users
|
from app.api import (
|
||||||
|
admin,
|
||||||
|
cars,
|
||||||
|
catalog,
|
||||||
|
change_requests,
|
||||||
|
entries,
|
||||||
|
my,
|
||||||
|
ocr,
|
||||||
|
service_centers,
|
||||||
|
service_visits,
|
||||||
|
users,
|
||||||
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
||||||
@@ -19,11 +30,15 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(users.router, prefix="/api")
|
app.include_router(users.router, prefix="/api")
|
||||||
|
app.include_router(my.router, prefix="/api")
|
||||||
app.include_router(catalog.router, prefix="/api")
|
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.include_router(service_centers.router, prefix="/api")
|
||||||
|
app.include_router(service_visits.router, prefix="/api")
|
||||||
|
app.include_router(change_requests.router, prefix="/api")
|
||||||
|
app.include_router(admin.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import date, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
@@ -29,6 +30,10 @@ class Car(Base):
|
|||||||
year: Mapped[int | None]
|
year: Mapped[int | None]
|
||||||
plate_number: Mapped[str | None] = mapped_column(String(32))
|
plate_number: Mapped[str | None] = mapped_column(String(32))
|
||||||
vin: Mapped[str | None] = mapped_column(String(32))
|
vin: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
license_plate_display: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
license_plate_normalized: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||||
|
license_plate_country: Mapped[str | None] = mapped_column(String(2), index=True)
|
||||||
|
vin_normalized: Mapped[str | None] = mapped_column(String(17), unique=True, index=True)
|
||||||
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))
|
target_consumption_l_per_100km: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
|
||||||
fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
|
fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
|
||||||
@@ -102,13 +107,25 @@ class ServiceCenter(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||||
|
legal_name: Mapped[str | None] = mapped_column(String(240))
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(160), index=True)
|
||||||
|
country: Mapped[str | None] = mapped_column(String(2), index=True)
|
||||||
|
city: Mapped[str | None] = mapped_column(String(120))
|
||||||
telegram_chat_id: Mapped[str | None] = mapped_column(String(80), unique=True, index=True)
|
telegram_chat_id: Mapped[str | None] = mapped_column(String(80), unique=True, index=True)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(40))
|
||||||
contact_phone: Mapped[str | None] = mapped_column(String(40))
|
contact_phone: Mapped[str | None] = mapped_column(String(40))
|
||||||
address: Mapped[str | None] = mapped_column(String(240))
|
address: Mapped[str | None] = mapped_column(String(240))
|
||||||
|
business_registration_number: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
verification_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||||
|
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
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())
|
||||||
|
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
suspended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
car_links = relationship("CarServiceLink", back_populates="service_center", cascade="all, delete-orphan")
|
car_links = relationship("CarServiceLink", back_populates="service_center", cascade="all, delete-orphan")
|
||||||
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
|
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
|
||||||
|
employees = relationship("ServiceEmployee", back_populates="service_center", cascade="all, delete-orphan")
|
||||||
|
visits = relationship("ServiceVisit", back_populates="service_center")
|
||||||
|
|
||||||
|
|
||||||
class CarServiceLink(Base):
|
class CarServiceLink(Base):
|
||||||
@@ -140,3 +157,118 @@ class ServiceInboxMessage(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())
|
||||||
|
|
||||||
service_center = relationship("ServiceCenter", back_populates="inbox_messages")
|
service_center = relationship("ServiceCenter", back_populates="inbox_messages")
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAccess(Base):
|
||||||
|
__tablename__ = "vehicle_access"
|
||||||
|
__table_args__ = (UniqueConstraint("vehicle_id", "user_id", "role", name="uq_vehicle_access_user_role"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(24), default="owner", server_default="owner", index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCenterVerification(Base):
|
||||||
|
__tablename__ = "service_center_verifications"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||||
|
submitted_documents: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||||
|
reviewed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
|
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceEmployee(Base):
|
||||||
|
__tablename__ = "service_employees"
|
||||||
|
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_employee_user"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(32), default="receptionist", server_default="receptionist", index=True)
|
||||||
|
permissions: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
service_center = relationship("ServiceCenter", back_populates="employees")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceVisit(Base):
|
||||||
|
__tablename__ = "service_visits"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||||
|
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||||
|
created_by_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
|
||||||
|
visit_date: Mapped[date] = mapped_column(Date, index=True)
|
||||||
|
odometer: Mapped[int | None]
|
||||||
|
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
total_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||||
|
owner_resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
service_center = relationship("ServiceCenter", back_populates="visits")
|
||||||
|
work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceWorkItem(Base):
|
||||||
|
__tablename__ = "service_work_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
|
||||||
|
work_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(180))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
parts: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
oil_brand: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
oil_viscosity: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
oil_volume: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
||||||
|
next_due_odometer: Mapped[int | None]
|
||||||
|
next_due_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
visit = relationship("ServiceVisit", back_populates="work_items")
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleDataChangeRequest(Base):
|
||||||
|
__tablename__ = "vehicle_data_change_requests"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||||
|
requested_by_service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
|
||||||
|
requested_by_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
|
||||||
|
field_name: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
old_value: Mapped[str | None] = mapped_column(Text)
|
||||||
|
new_value: Mapped[str | None] = mapped_column(Text)
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||||
|
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
|
actor_role: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
action: Mapped[str] = mapped_column(String(120), index=True)
|
||||||
|
target_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
target_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class User(Base):
|
|||||||
username: Mapped[str | None] = mapped_column(String(128))
|
username: Mapped[str | None] = mapped_column(String(128))
|
||||||
first_name: Mapped[str | None] = mapped_column(String(128))
|
first_name: Mapped[str | None] = mapped_column(String(128))
|
||||||
last_name: Mapped[str | None] = mapped_column(String(128))
|
last_name: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
platform_role: Mapped[str] = mapped_column(String(32), default="user", server_default="user", index=True)
|
||||||
locale: Mapped[str] = mapped_column(String(8), default="ru", server_default="ru")
|
locale: Mapped[str] = mapped_column(String(8), default="ru", server_default="ru")
|
||||||
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -1,22 +1,227 @@
|
|||||||
from datetime import datetime
|
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 normalize_license_plate, validate_vin
|
||||||
|
|
||||||
|
|
||||||
class ServiceCenterCreate(BaseModel):
|
class ServiceCenterCreate(BaseModel):
|
||||||
name: str
|
legal_name: str | None = None
|
||||||
|
display_name: str
|
||||||
|
country: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
business_registration_number: str | None = None
|
||||||
telegram_chat_id: str | None = None
|
telegram_chat_id: str | None = None
|
||||||
contact_phone: str | None = None
|
contact_phone: str | None = None
|
||||||
address: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceCenterRead(ServiceCenterCreate):
|
class ServiceCenterRead(ServiceCenterCreate):
|
||||||
id: int
|
id: int
|
||||||
|
name: str
|
||||||
|
verification_status: str
|
||||||
|
owner_user_id: int | None = None
|
||||||
|
created_at: datetime
|
||||||
|
verified_at: datetime | None = None
|
||||||
|
suspended_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCenterVerificationCreate(BaseModel):
|
||||||
|
submitted_documents: list[dict] | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCenterVerificationRead(ServiceCenterVerificationCreate):
|
||||||
|
id: int
|
||||||
|
service_center_id: int
|
||||||
|
status: str
|
||||||
|
reviewed_by: int | None = None
|
||||||
|
reviewed_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceEmployeeInvite(BaseModel):
|
||||||
|
telegram_id: int
|
||||||
|
role: str = "receptionist"
|
||||||
|
permissions: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceEmployeeRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
service_center_id: int
|
||||||
|
user_id: int
|
||||||
|
role: str
|
||||||
|
permissions: dict | None = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAccessGrant(BaseModel):
|
||||||
|
service_center_id: int | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
role: str = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAccessRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
vehicle_id: int
|
||||||
|
user_id: int
|
||||||
|
role: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
make: str | None = None
|
||||||
|
model: 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
|
||||||
|
engine_oil_type: str | None = None
|
||||||
|
engine_oil_volume_l: Decimal | None = None
|
||||||
|
|
||||||
|
@field_validator("vin")
|
||||||
|
@classmethod
|
||||||
|
def validate_vin_field(cls, value: str | None) -> str | None:
|
||||||
|
return validate_vin(value)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
make: str | None = None
|
||||||
|
model: 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
|
||||||
|
engine_oil_type: str | None = None
|
||||||
|
engine_oil_volume_l: Decimal | None = None
|
||||||
|
|
||||||
|
@field_validator("vin")
|
||||||
|
@classmethod
|
||||||
|
def validate_vin_field(cls, value: str | None) -> str | None:
|
||||||
|
return validate_vin(value)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
owner_id: int
|
||||||
|
name: str
|
||||||
|
make: str | None = None
|
||||||
|
model: 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
|
||||||
|
engine_oil_type: str | None = None
|
||||||
|
engine_oil_volume_l: Decimal | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceVisitCreate(BaseModel):
|
||||||
|
vehicle_id: int
|
||||||
|
visit_date: date
|
||||||
|
odometer: int | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
total_cost: Decimal | None = None
|
||||||
|
currency: str = "RUB"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceVisitRead(ServiceVisitCreate):
|
||||||
|
id: int
|
||||||
|
service_center_id: int
|
||||||
|
created_by_employee_id: int | None = None
|
||||||
|
status: str
|
||||||
|
owner_resolved_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceWorkItemCreate(BaseModel):
|
||||||
|
work_type: str = "other"
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
parts: list[dict] | None = None
|
||||||
|
oil_brand: str | None = None
|
||||||
|
oil_viscosity: str | None = None
|
||||||
|
oil_volume: Decimal | None = None
|
||||||
|
next_due_odometer: int | None = None
|
||||||
|
next_due_date: date | None = None
|
||||||
|
price: Decimal | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceWorkItemRead(ServiceWorkItemCreate):
|
||||||
|
id: int
|
||||||
|
service_visit_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleDataChangeRequestCreate(BaseModel):
|
||||||
|
vehicle_id: int
|
||||||
|
field_name: str
|
||||||
|
new_value: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleDataChangeRequestRead(VehicleDataChangeRequestCreate):
|
||||||
|
id: int
|
||||||
|
requested_by_service_center_id: int | None = None
|
||||||
|
requested_by_employee_id: int | None = None
|
||||||
|
old_value: str | None = None
|
||||||
|
status: str
|
||||||
|
owner_user_id: int
|
||||||
|
created_at: datetime
|
||||||
|
resolved_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleSearchRequest(BaseModel):
|
||||||
|
license_plate: str | None = None
|
||||||
|
country_code: str | None = None
|
||||||
|
vin: str | None = None
|
||||||
|
|
||||||
|
@field_validator("vin")
|
||||||
|
@classmethod
|
||||||
|
def validate_vin_field(cls, value: str | None) -> str | None:
|
||||||
|
return validate_vin(value)
|
||||||
|
|
||||||
|
@field_validator("license_plate")
|
||||||
|
@classmethod
|
||||||
|
def normalize_plate_field(cls, value: str | None) -> str | None:
|
||||||
|
return normalize_license_plate(value)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleSearchResult(BaseModel):
|
||||||
|
vehicle_id: int | None = None
|
||||||
|
make: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
year: int | None = None
|
||||||
|
masked_license_plate: str | None = None
|
||||||
|
masked_vin: str | None = None
|
||||||
|
access_status: str = "none"
|
||||||
|
|
||||||
|
|
||||||
class CarServiceLinkCreate(BaseModel):
|
class CarServiceLinkCreate(BaseModel):
|
||||||
car_id: int
|
car_id: int
|
||||||
service_center_id: int
|
service_center_id: int
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class UserUpsert(BaseModel):
|
|||||||
last_name: str | None = None
|
last_name: str | None = None
|
||||||
locale: str | None = None
|
locale: str | None = None
|
||||||
currency: str | None = None
|
currency: str | None = None
|
||||||
|
platform_role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WebAppAuthRequest(BaseModel):
|
class WebAppAuthRequest(BaseModel):
|
||||||
@@ -27,7 +28,7 @@ class TelegramLoginRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
bot_username: str
|
bot_username: str | None = None
|
||||||
vapid_public_key: str | None = None
|
vapid_public_key: str | None = None
|
||||||
app_env: str
|
app_env: str
|
||||||
allow_dev_auth: bool = False
|
allow_dev_auth: bool = False
|
||||||
|
|||||||
48
app/services/ocr_provider.py
Normal file
48
app/services/ocr_provider.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OcrCandidate:
|
||||||
|
type: str
|
||||||
|
value: str
|
||||||
|
confidence: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OcrResult:
|
||||||
|
recognized_text: str
|
||||||
|
candidates: list[OcrCandidate]
|
||||||
|
|
||||||
|
|
||||||
|
class OCRProvider:
|
||||||
|
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class StubOCRProvider(OCRProvider):
|
||||||
|
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||||
|
text = " ".join(
|
||||||
|
[
|
||||||
|
filename or "",
|
||||||
|
content.decode("utf-8", errors="ignore"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
compact = re.sub(r"\s+", " ", text).strip()
|
||||||
|
candidates: list[OcrCandidate] = []
|
||||||
|
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", compact.upper()):
|
||||||
|
try:
|
||||||
|
candidates.append(OcrCandidate(type="vin", value=validate_vin(raw) or raw, confidence=0.84))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
for raw in re.findall(r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b", compact.upper()):
|
||||||
|
normalized = normalize_license_plate(raw)
|
||||||
|
if normalized and 5 <= len(normalized) <= 10 and not any(item.value == normalized for item in candidates):
|
||||||
|
candidates.append(OcrCandidate(type="license_plate", value=normalized, confidence=0.62))
|
||||||
|
return OcrResult(recognized_text=compact, candidates=candidates[:8])
|
||||||
|
|
||||||
|
|
||||||
|
def get_ocr_provider() -> OCRProvider:
|
||||||
|
return StubOCRProvider()
|
||||||
44
app/services/vehicle_identity.py
Normal file
44
app/services/vehicle_identity.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_vin(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = re.sub(r"[\s-]+", "", value).upper()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_vin(value: str | None) -> str | None:
|
||||||
|
normalized = normalize_vin(value)
|
||||||
|
if normalized is None:
|
||||||
|
return None
|
||||||
|
if not VIN_RE.match(normalized):
|
||||||
|
raise ValueError("VIN must contain 17 characters and cannot include I, O, or Q")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_license_plate(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = re.sub(r"[\s\-_.]+", "", value).upper()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def mask_vin(value: str | None) -> str | None:
|
||||||
|
normalized = normalize_vin(value)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if len(normalized) <= 6:
|
||||||
|
return "*" * len(normalized)
|
||||||
|
return f"{normalized[:3]}{'*' * 10}{normalized[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def mask_license_plate(value: str | None) -> str | None:
|
||||||
|
normalized = normalize_license_plate(value)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if len(normalized) <= 3:
|
||||||
|
return "*" * len(normalized)
|
||||||
|
return f"{normalized[:2]}{'*' * max(len(normalized) - 4, 2)}{normalized[-2:]}"
|
||||||
75
tests/test_platform.py
Normal file
75
tests/test_platform.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vin_validation_rejects_invalid_value(client, auth_headers) -> None:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/my/vehicles",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Bad VIN", "vin": "IOQ123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_visit_owner_confirmation_and_change_request(client, auth_headers) -> None:
|
||||||
|
vehicle = (
|
||||||
|
await client.post(
|
||||||
|
"/api/my/vehicles",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Platform car", "license_plate": "12 가 3456", "license_plate_country": "KR"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
center = (
|
||||||
|
await client.post(
|
||||||
|
"/api/service-centers",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"display_name": "Careful Service", "country": "KR", "city": "Seoul"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
visit = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/service-centers/{center['id']}/visits",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"vehicle_id": vehicle["id"], "visit_date": "2026-05-12", "odometer": 12345},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
item_response = await client.post(
|
||||||
|
f"/api/service-visits/{visit['id']}/work-items",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"work_type": "oil_change", "title": "Engine oil", "oil_viscosity": "5W-30", "price": 100},
|
||||||
|
)
|
||||||
|
complete_response = await client.post(f"/api/service-visits/{visit['id']}/complete", headers=auth_headers)
|
||||||
|
confirm_response = await client.post(f"/api/service-visits/{visit['id']}/confirm", headers=auth_headers)
|
||||||
|
change_request = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/service-visits/{visit['id']}/vehicle-change-requests",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"vehicle_id": vehicle["id"], "field_name": "vin", "new_value": "KMHCT41BAHU123456"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
approve_response = await client.post(
|
||||||
|
f"/api/vehicle-change-requests/{change_request['id']}/approve",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item_response.status_code == 201
|
||||||
|
assert complete_response.json()["status"] == "pending_owner_confirmation"
|
||||||
|
assert confirm_response.json()["status"] == "confirmed"
|
||||||
|
assert approve_response.json()["status"] == "approved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/ocr/vin",
|
||||||
|
headers=auth_headers,
|
||||||
|
files={"file": ("vin.txt", BytesIO(b"VIN KMHCT41BAHU123456"), "text/plain")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["candidates"][0]["type"] == "vin"
|
||||||
@@ -220,6 +220,9 @@
|
|||||||
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</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="openConfirmationsBtn">Запросы на подтверждение</button>
|
||||||
|
<button class="menu-row" id="openConnectedServicesBtn">Подключенные автосервисы</button>
|
||||||
|
<button class="menu-row" id="openServicePanelBtn">Панель СТО</button>
|
||||||
<button class="menu-row" id="openScanBtn">Разобрать чек</button>
|
<button class="menu-row" id="openScanBtn">Разобрать чек</button>
|
||||||
|
|
||||||
<section class="drawer-section hidden" id="settingsSection">
|
<section class="drawer-section hidden" id="settingsSection">
|
||||||
@@ -253,6 +256,54 @@
|
|||||||
<button type="button" class="wide-btn" id="enableNotificationsBtn">Включить уведомления</button>
|
<button type="button" class="wide-btn" id="enableNotificationsBtn">Включить уведомления</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="confirmationsSection">
|
||||||
|
<h2>Запросы на подтверждение</h2>
|
||||||
|
<div class="tip-card">Здесь будут визиты СТО и изменения данных авто, которые ждут твоего решения.</div>
|
||||||
|
<div id="confirmationRequests" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="connectedServicesSection">
|
||||||
|
<h2>Подключенные автосервисы</h2>
|
||||||
|
<div class="tip-card">Автосервис видит машину только после твоего разрешения или в рамках визита.</div>
|
||||||
|
<div id="connectedServices" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section hidden" id="servicePanelSection">
|
||||||
|
<h2>Панель СТО</h2>
|
||||||
|
<form id="serviceCenterForm" class="grid-form drawer-form">
|
||||||
|
<label>
|
||||||
|
Название СТО
|
||||||
|
<input name="display_name" placeholder="Smart Service" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Юридическое название
|
||||||
|
<input name="legal_name" placeholder="ООО Smart Service" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Страна
|
||||||
|
<input name="country" maxlength="2" placeholder="KR" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Город
|
||||||
|
<input name="city" placeholder="Seoul" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Адрес
|
||||||
|
<input name="address" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Телефон
|
||||||
|
<input name="phone" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Регистрационный номер
|
||||||
|
<input name="business_registration_number" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Создать СТО</button>
|
||||||
|
</form>
|
||||||
|
<div id="serviceCentersList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section" id="carFormSection">
|
<section class="drawer-section" id="carFormSection">
|
||||||
<h2>Новое авто</h2>
|
<h2>Новое авто</h2>
|
||||||
<form id="carForm" class="grid-form drawer-form">
|
<form id="carForm" class="grid-form drawer-form">
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ const state = {
|
|||||||
latestStats: null,
|
latestStats: null,
|
||||||
allStats: null,
|
allStats: null,
|
||||||
analytics: null,
|
analytics: null,
|
||||||
|
serviceCenters: [],
|
||||||
receiptFile: null,
|
receiptFile: null,
|
||||||
serviceWorkerRegistration: null,
|
serviceWorkerRegistration: null,
|
||||||
period: {
|
period: {
|
||||||
@@ -798,6 +799,36 @@ function openCarProfile() {
|
|||||||
document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadServiceCenters() {
|
||||||
|
state.serviceCenters = await api("/service-centers/my");
|
||||||
|
renderServiceCenters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServiceCenters() {
|
||||||
|
const root = document.querySelector("#serviceCentersList");
|
||||||
|
if (!root) return;
|
||||||
|
if (!state.serviceCenters.length) {
|
||||||
|
root.innerHTML = `<div class="empty">СТО пока не создано</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.innerHTML = state.serviceCenters
|
||||||
|
.map(
|
||||||
|
(center) => `
|
||||||
|
<div class="stack-item">
|
||||||
|
<strong>${center.display_name || center.name}</strong>
|
||||||
|
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
|
||||||
|
<small>Статус: ${center.verification_status}</small>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaceholderList(selector, message) {
|
||||||
|
const root = document.querySelector(selector);
|
||||||
|
if (root) root.innerHTML = `<div class="empty">${message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderStats(stats) {
|
function renderStats(stats) {
|
||||||
const root = document.querySelector("#stats");
|
const root = document.querySelector("#stats");
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
@@ -1398,6 +1429,49 @@ document.querySelector("#openNotificationsBtn").addEventListener("click", () =>
|
|||||||
document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("#openConfirmationsBtn").addEventListener("click", () => {
|
||||||
|
document.querySelector("#confirmationsSection").classList.remove("hidden");
|
||||||
|
renderPlaceholderList("#confirmationRequests", "Новых запросов нет");
|
||||||
|
document.querySelector("#confirmationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#openConnectedServicesBtn").addEventListener("click", () => {
|
||||||
|
document.querySelector("#connectedServicesSection").classList.remove("hidden");
|
||||||
|
renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет");
|
||||||
|
document.querySelector("#connectedServicesSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#openServicePanelBtn").addEventListener("click", async (event) => {
|
||||||
|
await runAction(event.currentTarget, "Загружаю СТО...", async () => {
|
||||||
|
document.querySelector("#servicePanelSection").classList.remove("hidden");
|
||||||
|
await loadServiceCenters();
|
||||||
|
document.querySelector("#servicePanelSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#serviceCenterForm").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
await runAction(form.querySelector('button[type="submit"]'), "Создаю СТО...", async () => {
|
||||||
|
const data = formData(form);
|
||||||
|
await api("/service-centers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
display_name: data.display_name,
|
||||||
|
legal_name: data.legal_name || null,
|
||||||
|
country: data.country || null,
|
||||||
|
city: data.city || null,
|
||||||
|
address: data.address || null,
|
||||||
|
phone: data.phone || null,
|
||||||
|
business_registration_number: data.business_registration_number || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
await loadServiceCenters();
|
||||||
|
toast("СТО создано");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
|
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
|
||||||
|
|
||||||
document.querySelector("#openScanBtn").addEventListener("click", () => {
|
document.querySelector("#openScanBtn").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -1243,6 +1243,25 @@ select {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stack-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-item small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user