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