Add service platform foundation

This commit is contained in:
VPN SaaS Dev
2026-05-12 19:45:08 +09:00
parent 2ba2e88432
commit 34035a27cb
23 changed files with 2199 additions and 18 deletions

226
PROJECT_PLAN.md Normal file
View 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.

View File

@@ -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/номеру. Поиск возвращает только минимальную маскированную карточку и пишет действие в аудит. Критичные изменения автомобиля проходят через запрос подтверждения владельцем.

View 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
View 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)

View File

@@ -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)

View 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

View File

@@ -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
View 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

View File

@@ -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",

View File

@@ -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
View 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)

View File

@@ -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,

View File

@@ -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")

View File

@@ -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)

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View 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()

View 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
View 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"

View File

@@ -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">

View File

@@ -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", () => {

View File

@@ -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;