Add service platform foundation

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

226
PROJECT_PLAN.md Normal file
View File

@@ -0,0 +1,226 @@
# Drivers Bot Platform Plan
## 1. Current Architecture Summary
- FastAPI serves JSON API and static Telegram Mini App files from `web/`.
- aiogram bot opens the Mini App with `WEBAPP_URL` and talks to the API through `INTERNAL_API_TOKEN`.
- Users are Telegram-backed; user API access is based on verified Telegram WebApp `initData`.
- Current domain model covers personal garage use: `users`, `cars`, `fuel_entries`, `service_entries`, catalog tables, push subscriptions, service center stubs, and inbox messages.
- Existing MVP must remain compatible: personal cars, fuel logs, service logs, reminders, stats, catalog, OCR text parsing, bot start/cars/add_car flows.
## 2. Target Product Model
Drivers Bot evolves from a personal car diary into a controlled multi-sided platform:
- Vehicle owners manage cars and personal expense/service history.
- Verified service centers create service visits and work items for vehicles.
- Owners control access to vehicles and confirm sensitive changes.
- Service centers are tenants: employees can only see their own service center data and vehicle data for granted access or active visits.
- Moderators/admins verify service centers, inspect disputes, and audit platform actions.
Open product decisions:
- Whether verified service centers can create temporary vehicle records before owner claim. Initial implementation allows service visits only when vehicle exists or owner grants access; temporary cards are deferred to avoid ownership fraud.
- Whether `service_entries` and `service_visits` should be merged. Initial implementation keeps existing `service_entries` for MVP and adds `service_visits/work_items` as the platform layer.
## 3. UX/UI Flows
Owner flows:
1. Create/edit vehicle manually with make, model, year, plate, VIN, odometer, oil data, and service norms.
2. Open "Connected services" and grant/revoke service access using invite/access code or request from service center.
3. See service history from personal entries and service-center visits.
4. Receive confirmation requests for service visits and critical vehicle changes.
5. Confirm, dispute, reject, or hide sensitive details.
Service center flows:
1. Create service profile.
2. Submit verification details/documents.
3. After verification, invite employees.
4. Employee starts a visit, searches/scans plate/VIN, requests access if needed, then adds work items.
5. Manager/owner completes visit, moving it to owner confirmation.
Admin/moderator flows:
1. Review pending service centers.
2. Approve/reject/suspend service centers.
3. Inspect audit log, disputes, and suspicious searches.
## 4. Roles And Permissions
Platform roles:
- `user`: owns/drives/views vehicles according to `vehicle_access`.
- `service_owner`: owns a service center, manages verification and employees.
- `service_employee`: works inside one service center.
- `verifier` / `moderator`: reviews verification and disputes.
- `admin`: full moderation and platform operations.
Service employee roles:
- `receptionist`: create visits and access requests.
- `mechanic`: add work items and recommendations.
- `manager`: complete visits and manage employees.
- `owner`: manage center, employees, and verification.
Permission rule: backend checks every endpoint with `current_user`; frontend permissions are only UX hints.
## 5. Data Access Rules
- Owners can see and modify their active vehicles and personal logs.
- A service center can see only:
- its own profile, employees, verification requests, visits;
- minimal masked vehicle data during access request;
- visit details for vehicles with active access or active visit.
- Plate/VIN search must not reveal owner identity, Telegram ID, phone, full expense history, or sensitive attributes.
- `user_id` from request body is not trusted.
- `vehicle_id` access requires owner role, active `VehicleAccess`, or a service visit scoped to the service center.
## 6. Anti-Fraud Model
- No automatic ownership claim by VIN/license plate.
- VIN and license plate are normalized; VIN is unique when present.
- License plate uniqueness is scoped by country.
- Plate/VIN changes create audit and may create `VehicleDataChangeRequest`.
- Service-created visits are not trusted until owner confirmation.
- Critical fields requiring owner approval: VIN, license plate, odometer, oil type/volume, service norms, engine parameters.
- Service center actions are auditable and cannot be hard-deleted.
- Rate limiting for plate/VIN/OCR/access requests is required before broad public launch. Initial implementation logs searches/actions; hard rate limit can be added via middleware/Redis.
## 7. Database Changes
Extend `cars` as Vehicle-compatible table:
- `license_plate_display`
- `license_plate_normalized`
- `license_plate_country`
- `vin_normalized`
- unique `vin_normalized` when not null
- unique `(license_plate_country, license_plate_normalized)` when not null
New tables:
- `vehicle_access`
- `service_center_verifications`
- `service_employees`
- `service_visits`
- `service_work_items`
- `vehicle_data_change_requests`
- `audit_logs`
Existing tables kept:
- `service_entries` remain the personal/MVP service diary.
- `service_centers` is expanded instead of replaced.
## 8. API Design
Owner API:
- `GET /api/me`
- `GET /api/my/vehicles`
- `POST /api/my/vehicles`
- `PATCH /api/my/vehicles/{vehicle_id}`
- `GET /api/my/vehicles/{vehicle_id}/service-history`
- `POST /api/my/vehicles/{vehicle_id}/grant-service-access`
- `POST /api/service-visits/{visit_id}/confirm`
- `POST /api/service-visits/{visit_id}/dispute`
- `POST /api/vehicle-change-requests/{id}/approve`
- `POST /api/vehicle-change-requests/{id}/reject`
Service API:
- `POST /api/service-centers`
- `GET /api/service-centers/my`
- `POST /api/service-centers/{id}/verification`
- `POST /api/service-centers/{id}/employees/invite`
- `GET /api/service-centers/{id}/visits`
- `POST /api/service-centers/{id}/visits`
- `POST /api/service-visits/{id}/work-items`
- `POST /api/service-visits/{id}/complete`
- `POST /api/service-centers/{id}/vehicle-access/request`
Admin API:
- `GET /api/admin/service-centers/pending`
- `POST /api/admin/service-centers/{id}/verify`
- `POST /api/admin/service-centers/{id}/reject`
- `POST /api/admin/service-centers/{id}/suspend`
- `GET /api/admin/audit-log`
- `GET /api/admin/disputes`
## 9. OCR Design
OCR is provider-based:
- `OCRProvider` interface.
- `StubOCRProvider` default: extracts candidates from text/file name and never claims real image OCR.
- Future: `TesseractOCRProvider`, cloud OCR, or VLM provider.
OCR response:
```json
{
"recognized_text": "...",
"candidates": [
{"type": "license_plate", "value": "...", "confidence": 0.91}
]
}
```
OCR endpoints create suggestions only. They must not update vehicle data directly.
## 10. Migration Plan
1. Add nullable columns and new tables with conservative constraints.
2. Backfill current `cars.plate_number`/`cars.vin` into display/normalized columns where valid.
3. Create owner `vehicle_access` rows for existing `cars.owner_id`.
4. Add unique indexes for VIN and country/plate.
5. Keep existing endpoints compatible while new `/api/my/*` and service-center endpoints roll out.
## 11. Test Plan
Backend:
- VIN normalization/validation.
- Plate normalization.
- Owner cannot claim another vehicle by VIN/plate.
- Service employee cannot see another service center data.
- Unverified service center cannot complete trusted visits.
- Owner confirmation changes visit status.
- Critical data change request approve/reject updates only after owner action.
- OCR endpoint returns candidates without writing data.
- Existing fuel/service/reminders tests remain green.
Manual:
- Telegram Mini App opens with local test bot.
- Existing garage/fuel/service flows still work.
- Direct browser page shows Telegram-only message unless dev auth enabled.
## 12. Implementation Phases
Phase 1:
- Expand models and migrations.
- Add permission helpers.
- Add basic service center create/my/verification/employee flow.
Phase 2:
- Add vehicle identity fields, ownership/access model.
- Add service visits/work items.
- Add owner confirmation and vehicle data change requests.
Phase 3:
- Add OCR provider interface and license plate/VIN/service document endpoints.
- Add lightweight frontend sections for service center panel, visits, confirmations, OCR.
- Add admin moderation endpoints.
Phase 4:
- Add tests, README, `.env.example`.
- Run Docker compose, Alembic, API health, bot startup with local test token.
- Keep production deploy separate unless explicitly requested.

View File

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

View File

@@ -0,0 +1,303 @@
"""platform service visits
Revision ID: 202605120005
Revises: 202605120004
Create Date: 2026-05-12
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605120005"
down_revision: str | None = "202605120004"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("platform_role", sa.String(length=32), server_default="user", nullable=False))
op.create_index(op.f("ix_users_platform_role"), "users", ["platform_role"])
op.add_column("cars", sa.Column("license_plate_display", sa.String(length=32), nullable=True))
op.add_column("cars", sa.Column("license_plate_normalized", sa.String(length=32), nullable=True))
op.add_column("cars", sa.Column("license_plate_country", sa.String(length=2), nullable=True))
op.add_column("cars", sa.Column("vin_normalized", sa.String(length=17), nullable=True))
op.execute(
"""
update cars
set license_plate_display = plate_number,
license_plate_normalized = upper(replace(replace(plate_number, ' ', ''), '-', '')),
vin_normalized = upper(vin)
where plate_number is not null or vin is not null
"""
)
op.create_index(op.f("ix_cars_license_plate_normalized"), "cars", ["license_plate_normalized"])
op.create_index(op.f("ix_cars_license_plate_country"), "cars", ["license_plate_country"])
op.create_index(op.f("ix_cars_vin_normalized"), "cars", ["vin_normalized"], unique=True)
op.create_index(
"uq_cars_country_license_plate",
"cars",
["license_plate_country", "license_plate_normalized"],
unique=True,
postgresql_where=sa.text("license_plate_country is not null and license_plate_normalized is not null"),
)
op.add_column("service_centers", sa.Column("legal_name", sa.String(length=240), nullable=True))
op.add_column("service_centers", sa.Column("display_name", sa.String(length=160), nullable=True))
op.add_column("service_centers", sa.Column("country", sa.String(length=2), nullable=True))
op.add_column("service_centers", sa.Column("city", sa.String(length=120), nullable=True))
op.add_column("service_centers", sa.Column("phone", sa.String(length=40), nullable=True))
op.add_column("service_centers", sa.Column("business_registration_number", sa.String(length=80), nullable=True))
op.add_column(
"service_centers",
sa.Column("verification_status", sa.String(length=24), server_default="pending", nullable=False),
)
op.add_column("service_centers", sa.Column("owner_user_id", sa.Integer(), nullable=True))
op.add_column("service_centers", sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("service_centers", sa.Column("suspended_at", sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(
"fk_service_centers_owner_user_id_users",
"service_centers",
"users",
["owner_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_service_centers_display_name"), "service_centers", ["display_name"])
op.create_index(op.f("ix_service_centers_country"), "service_centers", ["country"])
op.create_index(op.f("ix_service_centers_verification_status"), "service_centers", ["verification_status"])
op.create_index(op.f("ix_service_centers_owner_user_id"), "service_centers", ["owner_user_id"])
op.execute("update service_centers set display_name = name where display_name is null")
op.create_table(
"vehicle_access",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vehicle_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("role", sa.String(length=24), server_default="owner", nullable=False),
sa.Column("status", sa.String(length=24), server_default="active", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("vehicle_id", "user_id", "role", name="uq_vehicle_access_user_role"),
)
op.create_index(op.f("ix_vehicle_access_vehicle_id"), "vehicle_access", ["vehicle_id"])
op.create_index(op.f("ix_vehicle_access_user_id"), "vehicle_access", ["user_id"])
op.create_index(op.f("ix_vehicle_access_role"), "vehicle_access", ["role"])
op.create_index(op.f("ix_vehicle_access_status"), "vehicle_access", ["status"])
op.execute(
"""
insert into vehicle_access (vehicle_id, user_id, role, status)
select id, owner_id, 'owner', 'active'
from cars
on conflict do nothing
"""
)
op.create_table(
"service_center_verifications",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_center_id", sa.Integer(), nullable=False),
sa.Column("submitted_documents", sa.JSON(), nullable=True),
sa.Column("comment", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), server_default="pending", nullable=False),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_service_center_verifications_service_center_id"), "service_center_verifications", ["service_center_id"])
op.create_index(op.f("ix_service_center_verifications_status"), "service_center_verifications", ["status"])
op.create_index(op.f("ix_service_center_verifications_reviewed_by"), "service_center_verifications", ["reviewed_by"])
op.create_table(
"service_employees",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_center_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("role", sa.String(length=32), server_default="receptionist", nullable=False),
sa.Column("permissions", sa.JSON(), nullable=True),
sa.Column("status", sa.String(length=24), server_default="active", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("service_center_id", "user_id", name="uq_service_employee_user"),
)
op.create_index(op.f("ix_service_employees_service_center_id"), "service_employees", ["service_center_id"])
op.create_index(op.f("ix_service_employees_user_id"), "service_employees", ["user_id"])
op.create_index(op.f("ix_service_employees_role"), "service_employees", ["role"])
op.create_index(op.f("ix_service_employees_status"), "service_employees", ["status"])
op.create_table(
"service_visits",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_center_id", sa.Integer(), nullable=False),
sa.Column("vehicle_id", sa.Integer(), nullable=False),
sa.Column("created_by_employee_id", sa.Integer(), nullable=True),
sa.Column("visit_date", sa.Date(), nullable=False),
sa.Column("odometer", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=40), server_default="draft", nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("total_cost", sa.Numeric(12, 2), nullable=True),
sa.Column("currency", sa.String(length=3), server_default="RUB", nullable=False),
sa.Column("owner_resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["created_by_employee_id"], ["service_employees.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_service_visits_service_center_id"), "service_visits", ["service_center_id"])
op.create_index(op.f("ix_service_visits_vehicle_id"), "service_visits", ["vehicle_id"])
op.create_index(op.f("ix_service_visits_created_by_employee_id"), "service_visits", ["created_by_employee_id"])
op.create_index(op.f("ix_service_visits_visit_date"), "service_visits", ["visit_date"])
op.create_index(op.f("ix_service_visits_status"), "service_visits", ["status"])
op.create_table(
"service_work_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_visit_id", sa.Integer(), nullable=False),
sa.Column("work_type", sa.String(length=40), server_default="other", nullable=False),
sa.Column("title", sa.String(length=180), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("parts", sa.JSON(), nullable=True),
sa.Column("oil_brand", sa.String(length=80), nullable=True),
sa.Column("oil_viscosity", sa.String(length=40), nullable=True),
sa.Column("oil_volume", sa.Numeric(5, 2), nullable=True),
sa.Column("next_due_odometer", sa.Integer(), nullable=True),
sa.Column("next_due_date", sa.Date(), nullable=True),
sa.Column("price", sa.Numeric(12, 2), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["service_visit_id"], ["service_visits.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_service_work_items_service_visit_id"), "service_work_items", ["service_visit_id"])
op.create_index(op.f("ix_service_work_items_work_type"), "service_work_items", ["work_type"])
op.create_table(
"vehicle_data_change_requests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vehicle_id", sa.Integer(), nullable=False),
sa.Column("requested_by_service_center_id", sa.Integer(), nullable=True),
sa.Column("requested_by_employee_id", sa.Integer(), nullable=True),
sa.Column("field_name", sa.String(length=80), nullable=False),
sa.Column("old_value", sa.Text(), nullable=True),
sa.Column("new_value", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), server_default="pending", nullable=False),
sa.Column("owner_user_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requested_by_employee_id"], ["service_employees.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["requested_by_service_center_id"], ["service_centers.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_vehicle_data_change_requests_vehicle_id"), "vehicle_data_change_requests", ["vehicle_id"])
op.create_index(op.f("ix_vehicle_data_change_requests_requested_by_service_center_id"), "vehicle_data_change_requests", ["requested_by_service_center_id"])
op.create_index(op.f("ix_vehicle_data_change_requests_requested_by_employee_id"), "vehicle_data_change_requests", ["requested_by_employee_id"])
op.create_index(op.f("ix_vehicle_data_change_requests_field_name"), "vehicle_data_change_requests", ["field_name"])
op.create_index(op.f("ix_vehicle_data_change_requests_status"), "vehicle_data_change_requests", ["status"])
op.create_index(op.f("ix_vehicle_data_change_requests_owner_user_id"), "vehicle_data_change_requests", ["owner_user_id"])
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("actor_role", sa.String(length=64), nullable=True),
sa.Column("action", sa.String(length=120), nullable=False),
sa.Column("target_type", sa.String(length=80), nullable=False),
sa.Column("target_id", sa.String(length=80), nullable=True),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.Column("user_agent", sa.String(length=256), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_audit_logs_actor_user_id"), "audit_logs", ["actor_user_id"])
op.create_index(op.f("ix_audit_logs_action"), "audit_logs", ["action"])
op.create_index(op.f("ix_audit_logs_target_type"), "audit_logs", ["target_type"])
op.create_index(op.f("ix_audit_logs_target_id"), "audit_logs", ["target_id"])
op.create_index(op.f("ix_audit_logs_created_at"), "audit_logs", ["created_at"])
def downgrade() -> None:
op.drop_index(op.f("ix_audit_logs_created_at"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_target_id"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_target_type"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_action"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_actor_user_id"), table_name="audit_logs")
op.drop_table("audit_logs")
op.drop_index(op.f("ix_vehicle_data_change_requests_owner_user_id"), table_name="vehicle_data_change_requests")
op.drop_index(op.f("ix_vehicle_data_change_requests_status"), table_name="vehicle_data_change_requests")
op.drop_index(op.f("ix_vehicle_data_change_requests_field_name"), table_name="vehicle_data_change_requests")
op.drop_index(op.f("ix_vehicle_data_change_requests_requested_by_employee_id"), table_name="vehicle_data_change_requests")
op.drop_index(op.f("ix_vehicle_data_change_requests_requested_by_service_center_id"), table_name="vehicle_data_change_requests")
op.drop_index(op.f("ix_vehicle_data_change_requests_vehicle_id"), table_name="vehicle_data_change_requests")
op.drop_table("vehicle_data_change_requests")
op.drop_index(op.f("ix_service_work_items_work_type"), table_name="service_work_items")
op.drop_index(op.f("ix_service_work_items_service_visit_id"), table_name="service_work_items")
op.drop_table("service_work_items")
op.drop_index(op.f("ix_service_visits_status"), table_name="service_visits")
op.drop_index(op.f("ix_service_visits_visit_date"), table_name="service_visits")
op.drop_index(op.f("ix_service_visits_created_by_employee_id"), table_name="service_visits")
op.drop_index(op.f("ix_service_visits_vehicle_id"), table_name="service_visits")
op.drop_index(op.f("ix_service_visits_service_center_id"), table_name="service_visits")
op.drop_table("service_visits")
op.drop_index(op.f("ix_service_employees_status"), table_name="service_employees")
op.drop_index(op.f("ix_service_employees_role"), table_name="service_employees")
op.drop_index(op.f("ix_service_employees_user_id"), table_name="service_employees")
op.drop_index(op.f("ix_service_employees_service_center_id"), table_name="service_employees")
op.drop_table("service_employees")
op.drop_index(op.f("ix_service_center_verifications_reviewed_by"), table_name="service_center_verifications")
op.drop_index(op.f("ix_service_center_verifications_status"), table_name="service_center_verifications")
op.drop_index(op.f("ix_service_center_verifications_service_center_id"), table_name="service_center_verifications")
op.drop_table("service_center_verifications")
op.drop_index(op.f("ix_vehicle_access_status"), table_name="vehicle_access")
op.drop_index(op.f("ix_vehicle_access_role"), table_name="vehicle_access")
op.drop_index(op.f("ix_vehicle_access_user_id"), table_name="vehicle_access")
op.drop_index(op.f("ix_vehicle_access_vehicle_id"), table_name="vehicle_access")
op.drop_table("vehicle_access")
op.drop_index(op.f("ix_service_centers_owner_user_id"), table_name="service_centers")
op.drop_index(op.f("ix_service_centers_verification_status"), table_name="service_centers")
op.drop_index(op.f("ix_service_centers_country"), table_name="service_centers")
op.drop_index(op.f("ix_service_centers_display_name"), table_name="service_centers")
op.drop_constraint("fk_service_centers_owner_user_id_users", "service_centers", type_="foreignkey")
op.drop_column("service_centers", "suspended_at")
op.drop_column("service_centers", "verified_at")
op.drop_column("service_centers", "owner_user_id")
op.drop_column("service_centers", "verification_status")
op.drop_column("service_centers", "business_registration_number")
op.drop_column("service_centers", "phone")
op.drop_column("service_centers", "city")
op.drop_column("service_centers", "country")
op.drop_column("service_centers", "display_name")
op.drop_column("service_centers", "legal_name")
op.drop_index("uq_cars_country_license_plate", table_name="cars")
op.drop_index(op.f("ix_cars_vin_normalized"), table_name="cars")
op.drop_index(op.f("ix_cars_license_plate_country"), table_name="cars")
op.drop_index(op.f("ix_cars_license_plate_normalized"), table_name="cars")
op.drop_column("cars", "vin_normalized")
op.drop_column("cars", "license_plate_country")
op.drop_column("cars", "license_plate_normalized")
op.drop_column("cars", "license_plate_display")
op.drop_index(op.f("ix_users_platform_role"), table_name="users")
op.drop_column("users", "platform_role")

141
app/api/admin.py Normal file
View File

@@ -0,0 +1,141 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit, require_platform_role
from app.db.session import get_session
from app.models.car import AuditLog, ServiceCenter, ServiceCenterVerification, ServiceVisit
from app.models.user import User
from app.schemas.service_center import ServiceCenterRead, ServiceVisitRead
router = APIRouter(prefix="/admin", tags=["admin"])
def require_admin_or_verifier(user: User) -> None:
require_platform_role(user, {"admin", "verifier", "moderator"})
@router.get("/service-centers/pending", response_model=list[ServiceCenterRead])
async def pending_service_centers(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
require_admin_or_verifier(current_user)
result = await session.execute(
select(ServiceCenter)
.where(ServiceCenter.verification_status == "pending")
.order_by(ServiceCenter.created_at.asc())
)
return list(result.scalars())
@router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead)
async def verify_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "verified"
center.verified_at = datetime.now(UTC)
await mark_latest_verification(session, center.id, "verified", current_user.id)
await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead)
async def reject_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_admin_or_verifier(current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "rejected"
await mark_latest_verification(session, center.id, "rejected", current_user.id)
await log_audit(session, actor=current_user, action="service_center.reject", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead)
async def suspend_service_center(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenter:
require_platform_role(current_user, {"admin"})
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
center.verification_status = "suspended"
center.suspended_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_center.suspend", target_type="service_center", target_id=center.id)
await session.commit()
await session.refresh(center)
return center
@router.get("/audit-log")
async def audit_log(
limit: int = 100,
offset: int = 0,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[dict]:
require_platform_role(current_user, {"admin", "verifier", "moderator"})
limit = min(max(limit, 1), 200)
result = await session.execute(
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit).offset(max(offset, 0))
)
return [
{
"id": item.id,
"actor_user_id": item.actor_user_id,
"actor_role": item.actor_role,
"action": item.action,
"target_type": item.target_type,
"target_id": item.target_id,
"metadata_json": item.metadata_json,
"created_at": item.created_at,
}
for item in result.scalars()
]
@router.get("/disputes", response_model=list[ServiceVisitRead])
async def disputes(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceVisit]:
require_admin_or_verifier(current_user)
result = await session.execute(
select(ServiceVisit).where(ServiceVisit.status == "disputed").order_by(ServiceVisit.updated_at.desc())
)
return list(result.scalars())
async def mark_latest_verification(
session: AsyncSession, service_center_id: int, status: str, reviewed_by: int
) -> None:
result = await session.execute(
select(ServiceCenterVerification)
.where(ServiceCenterVerification.service_center_id == service_center_id)
.order_by(ServiceCenterVerification.created_at.desc())
.limit(1)
)
verification = result.scalar_one_or_none()
if verification:
verification.status = status
verification.reviewed_by = reviewed_by
verification.reviewed_at = datetime.now(UTC)

View File

@@ -7,17 +7,27 @@ from app.db.session import get_session
from app.models.car import Car
from app.models.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)

View File

@@ -0,0 +1,67 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit
from app.api.service_visits import apply_vehicle_change
from app.db.session import get_session
from app.models.car import Car, VehicleDataChangeRequest
from app.models.user import User
from app.schemas.service_center import VehicleDataChangeRequestRead
router = APIRouter(prefix="/vehicle-change-requests", tags=["vehicle-change-requests"])
@router.post("/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
async def approve_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
vehicle = await session.get(Car, request.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
apply_vehicle_change(vehicle, request.field_name, request.new_value)
request.status = "approved"
request.resolved_at = datetime.now(UTC)
await log_audit(
session,
actor=current_user,
action="vehicle_change_request.approve",
target_type="vehicle_change_request",
target_id=request_id,
)
await session.commit()
await session.refresh(request)
return request
@router.post("/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
async def reject_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
request.status = "rejected"
request.resolved_at = datetime.now(UTC)
await log_audit(
session,
actor=current_user,
action="vehicle_change_request.reject",
target_type="vehicle_change_request",
target_id=request_id,
)
await session.commit()
await session.refresh(request)
return request

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.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
View File

@@ -0,0 +1,157 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user, log_audit
from app.db.session import get_session
from app.models.car import Car, ServiceVisit, VehicleAccess
from app.models.user import User
from app.schemas.service_center import (
VehicleAccessGrant,
VehicleAccessRead,
VehicleCreate,
VehicleRead,
VehicleUpdate,
)
from app.schemas.user import UserRead
from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(tags=["my"])
@router.get("/me", response_model=UserRead)
async def me(current_user: User = Depends(get_current_telegram_user)) -> User:
return current_user
def vehicle_data(payload: VehicleCreate | VehicleUpdate, *, partial: bool = False) -> dict:
raw = payload.model_dump(exclude_unset=partial)
data = {
key: value
for key, value in raw.items()
if key not in {"license_plate", "license_plate_country", "vin"}
}
if "license_plate" in raw:
data["license_plate_display"] = raw["license_plate"]
data["license_plate_normalized"] = normalize_license_plate(raw["license_plate"])
data["plate_number"] = raw["license_plate"]
if "license_plate_country" in raw:
data["license_plate_country"] = (
raw["license_plate_country"].upper() if raw["license_plate_country"] else None
)
if "vin" in raw:
data["vin_normalized"] = validate_vin(raw["vin"])
data["vin"] = raw["vin"]
return data
@router.get("/my/vehicles", response_model=list[VehicleRead])
async def my_vehicles(
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[Car]:
result = await session.execute(
select(Car)
.join(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
.where(VehicleAccess.user_id == current_user.id, VehicleAccess.status == "active")
.order_by(Car.created_at.desc())
)
return list(result.scalars())
@router.post("/my/vehicles", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
async def create_vehicle(
payload: VehicleCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
car = Car(**vehicle_data(payload), owner_id=current_user.id)
session.add(car)
await session.flush()
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
await session.commit()
await session.refresh(car)
return car
@router.patch("/my/vehicles/{vehicle_id}", response_model=VehicleRead)
async def update_vehicle(
vehicle_id: int,
payload: VehicleUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
car = await session.get(Car, vehicle_id)
if car is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
for field, value in vehicle_data(payload, partial=True).items():
setattr(car, field, value)
await log_audit(session, actor=current_user, action="vehicle.update", target_type="vehicle", target_id=car.id)
await session.commit()
await session.refresh(car)
return car
@router.get("/my/vehicles/{vehicle_id}/service-history")
async def vehicle_service_history(
vehicle_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> dict:
car = await session.get(Car, vehicle_id)
if car is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
result = await session.execute(
select(ServiceVisit)
.where(ServiceVisit.vehicle_id == vehicle_id)
.order_by(ServiceVisit.visit_date.desc())
)
visits = list(result.scalars())
return {"vehicle_id": vehicle_id, "service_visits": jsonable_encoder(visits)}
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
async def grant_vehicle_access(
vehicle_id: int,
payload: VehicleAccessGrant,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleAccess:
car = await session.get(Car, vehicle_id)
if car is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
if not payload.user_id:
raise HTTPException(status_code=400, detail="user_id is required for access grants")
result = await session.execute(
select(VehicleAccess).where(
VehicleAccess.vehicle_id == vehicle_id,
VehicleAccess.user_id == payload.user_id,
VehicleAccess.role == payload.role,
)
)
access = result.scalar_one_or_none()
if access is None:
access = VehicleAccess(vehicle_id=vehicle_id, user_id=payload.user_id, role=payload.role, status="active")
session.add(access)
else:
access.status = "active"
access.revoked_at = None
await log_audit(
session,
actor=current_user,
action="vehicle_access.grant",
target_type="vehicle",
target_id=vehicle_id,
metadata={"granted_user_id": payload.user_id, "role": payload.role},
)
await session.commit()
await session.refresh(access)
return access

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel
from app.api.deps import get_current_telegram_user
from app.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",

View File

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

@@ -0,0 +1,205 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
from app.db.session import get_session
from app.models.car import Car, ServiceVisit, ServiceWorkItem, VehicleDataChangeRequest
from app.models.user import User
from app.schemas.service_center import (
ServiceVisitRead,
ServiceWorkItemCreate,
ServiceWorkItemRead,
VehicleDataChangeRequestCreate,
VehicleDataChangeRequestRead,
)
from app.services.vehicle_identity import normalize_license_plate, validate_vin
router = APIRouter(prefix="/service-visits", tags=["service-visits"])
async def get_visit_or_404(session: AsyncSession, visit_id: int) -> ServiceVisit:
visit = await session.get(ServiceVisit, visit_id)
if visit is None:
raise HTTPException(status_code=404, detail="Service visit not found")
return visit
@router.post("/{visit_id}/work-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
async def add_work_item(
visit_id: int,
payload: ServiceWorkItemCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceWorkItem:
visit = await get_visit_or_404(session, visit_id)
await ensure_service_employee(
session,
visit.service_center_id,
current_user,
{"owner", "manager", "mechanic"},
)
if visit.status not in {"draft", "pending_owner_confirmation"}:
raise HTTPException(status_code=409, detail="Visit cannot be edited in current status")
item = ServiceWorkItem(service_visit_id=visit_id, **payload.model_dump())
session.add(item)
if payload.price is not None:
visit.total_cost = (visit.total_cost or 0) + payload.price
await log_audit(session, actor=current_user, action="service_work_item.create", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(item)
return item
@router.post("/{visit_id}/complete", response_model=ServiceVisitRead)
async def complete_visit(
visit_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_visit_or_404(session, visit_id)
await ensure_service_employee(session, visit.service_center_id, current_user, {"owner", "manager"})
if visit.status not in {"draft", "pending_owner_confirmation"}:
raise HTTPException(status_code=409, detail="Visit cannot be completed")
visit.status = "pending_owner_confirmation"
await log_audit(session, actor=current_user, action="service_visit.complete", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{visit_id}/confirm", response_model=ServiceVisitRead)
async def confirm_visit(
visit_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_visit_or_404(session, visit_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "confirmed"
visit.owner_resolved_at = datetime.now(UTC)
if visit.odometer and (vehicle.current_odometer is None or visit.odometer > vehicle.current_odometer):
vehicle.current_odometer = visit.odometer
await log_audit(session, actor=current_user, action="service_visit.confirm", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{visit_id}/dispute", response_model=ServiceVisitRead)
async def dispute_visit(
visit_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_visit_or_404(session, visit_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "disputed"
visit.owner_resolved_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="service_visit.dispute", target_type="service_visit", target_id=visit_id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{visit_id}/vehicle-change-requests", response_model=VehicleDataChangeRequestRead)
async def create_vehicle_change_request(
visit_id: int,
payload: VehicleDataChangeRequestCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
visit = await get_visit_or_404(session, visit_id)
employee = await ensure_service_employee(
session,
visit.service_center_id,
current_user,
{"owner", "manager", "mechanic", "receptionist"},
)
if visit.vehicle_id != payload.vehicle_id:
raise HTTPException(status_code=400, detail="Vehicle does not match visit")
vehicle = await session.get(Car, payload.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
old_value = getattr(vehicle, payload.field_name, None)
request = VehicleDataChangeRequest(
vehicle_id=payload.vehicle_id,
requested_by_service_center_id=visit.service_center_id,
requested_by_employee_id=employee.id,
field_name=payload.field_name,
old_value=str(old_value) if old_value is not None else None,
new_value=payload.new_value,
status="pending",
owner_user_id=vehicle.owner_id,
)
session.add(request)
await log_audit(session, actor=current_user, action="vehicle_change_request.create", target_type="vehicle", target_id=payload.vehicle_id, metadata={"field_name": payload.field_name})
await session.commit()
await session.refresh(request)
return request
@router.post("/vehicle-change-requests/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
async def approve_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
vehicle = await session.get(Car, request.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
apply_vehicle_change(vehicle, request.field_name, request.new_value)
request.status = "approved"
request.resolved_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="vehicle_change_request.approve", target_type="vehicle_change_request", target_id=request_id)
await session.commit()
await session.refresh(request)
return request
@router.post("/vehicle-change-requests/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
async def reject_vehicle_change_request(
request_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleDataChangeRequest:
request = await session.get(VehicleDataChangeRequest, request_id)
if request is None:
raise HTTPException(status_code=404, detail="Change request not found")
if request.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
request.status = "rejected"
request.resolved_at = datetime.now(UTC)
await log_audit(session, actor=current_user, action="vehicle_change_request.reject", target_type="vehicle_change_request", target_id=request_id)
await session.commit()
await session.refresh(request)
return request
def apply_vehicle_change(vehicle: Car, field_name: str, value: str | None) -> None:
if field_name in {"license_plate", "license_plate_display"}:
vehicle.license_plate_display = value
vehicle.license_plate_normalized = normalize_license_plate(value)
vehicle.plate_number = value
return
if field_name in {"vin", "vin_normalized"}:
vehicle.vin = value
vehicle.vin_normalized = validate_vin(value)
return
if not hasattr(vehicle, field_name):
raise HTTPException(status_code=400, detail="Unsupported vehicle field")
setattr(vehicle, field_name, value)

View File

@@ -47,7 +47,7 @@ async def upsert_user(
@router.get("/auth/config", response_model=AuthConfig)
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,

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,227 @@
from datetime import datetime
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from app.services.vehicle_identity import normalize_license_plate, validate_vin
class ServiceCenterCreate(BaseModel):
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

View File

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

View File

@@ -0,0 +1,48 @@
import re
from dataclasses import dataclass
from app.services.vehicle_identity import normalize_license_plate, validate_vin
@dataclass
class OcrCandidate:
type: str
value: str
confidence: float
@dataclass
class OcrResult:
recognized_text: str
candidates: list[OcrCandidate]
class OCRProvider:
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
raise NotImplementedError
class StubOCRProvider(OCRProvider):
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
text = " ".join(
[
filename or "",
content.decode("utf-8", errors="ignore"),
]
)
compact = re.sub(r"\s+", " ", text).strip()
candidates: list[OcrCandidate] = []
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", compact.upper()):
try:
candidates.append(OcrCandidate(type="vin", value=validate_vin(raw) or raw, confidence=0.84))
except ValueError:
continue
for raw in re.findall(r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b", compact.upper()):
normalized = normalize_license_plate(raw)
if normalized and 5 <= len(normalized) <= 10 and not any(item.value == normalized for item in candidates):
candidates.append(OcrCandidate(type="license_plate", value=normalized, confidence=0.62))
return OcrResult(recognized_text=compact, candidates=candidates[:8])
def get_ocr_provider() -> OCRProvider:
return StubOCRProvider()

View File

@@ -0,0 +1,44 @@
import re
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
def normalize_vin(value: str | None) -> str | None:
if not value:
return None
normalized = re.sub(r"[\s-]+", "", value).upper()
return normalized or None
def validate_vin(value: str | None) -> str | None:
normalized = normalize_vin(value)
if normalized is None:
return None
if not VIN_RE.match(normalized):
raise ValueError("VIN must contain 17 characters and cannot include I, O, or Q")
return normalized
def normalize_license_plate(value: str | None) -> str | None:
if not value:
return None
normalized = re.sub(r"[\s\-_.]+", "", value).upper()
return normalized or None
def mask_vin(value: str | None) -> str | None:
normalized = normalize_vin(value)
if not normalized:
return None
if len(normalized) <= 6:
return "*" * len(normalized)
return f"{normalized[:3]}{'*' * 10}{normalized[-4:]}"
def mask_license_plate(value: str | None) -> str | None:
normalized = normalize_license_plate(value)
if not normalized:
return None
if len(normalized) <= 3:
return "*" * len(normalized)
return f"{normalized[:2]}{'*' * max(len(normalized) - 4, 2)}{normalized[-2:]}"

75
tests/test_platform.py Normal file
View File

@@ -0,0 +1,75 @@
from io import BytesIO
import pytest
@pytest.mark.asyncio
async def test_vin_validation_rejects_invalid_value(client, auth_headers) -> None:
response = await client.post(
"/api/my/vehicles",
headers=auth_headers,
json={"name": "Bad VIN", "vin": "IOQ123"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_service_visit_owner_confirmation_and_change_request(client, auth_headers) -> None:
vehicle = (
await client.post(
"/api/my/vehicles",
headers=auth_headers,
json={"name": "Platform car", "license_plate": "12 가 3456", "license_plate_country": "KR"},
)
).json()
center = (
await client.post(
"/api/service-centers",
headers=auth_headers,
json={"display_name": "Careful Service", "country": "KR", "city": "Seoul"},
)
).json()
visit = (
await client.post(
f"/api/service-centers/{center['id']}/visits",
headers=auth_headers,
json={"vehicle_id": vehicle["id"], "visit_date": "2026-05-12", "odometer": 12345},
)
).json()
item_response = await client.post(
f"/api/service-visits/{visit['id']}/work-items",
headers=auth_headers,
json={"work_type": "oil_change", "title": "Engine oil", "oil_viscosity": "5W-30", "price": 100},
)
complete_response = await client.post(f"/api/service-visits/{visit['id']}/complete", headers=auth_headers)
confirm_response = await client.post(f"/api/service-visits/{visit['id']}/confirm", headers=auth_headers)
change_request = (
await client.post(
f"/api/service-visits/{visit['id']}/vehicle-change-requests",
headers=auth_headers,
json={"vehicle_id": vehicle["id"], "field_name": "vin", "new_value": "KMHCT41BAHU123456"},
)
).json()
approve_response = await client.post(
f"/api/vehicle-change-requests/{change_request['id']}/approve",
headers=auth_headers,
)
assert item_response.status_code == 201
assert complete_response.json()["status"] == "pending_owner_confirmation"
assert confirm_response.json()["status"] == "confirmed"
assert approve_response.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
response = await client.post(
"/api/ocr/vin",
headers=auth_headers,
files={"file": ("vin.txt", BytesIO(b"VIN KMHCT41BAHU123456"), "text/plain")},
)
assert response.status_code == 200
assert response.json()["candidates"][0]["type"] == "vin"

View File

@@ -220,6 +220,9 @@
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
<button class="menu-row" id="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">

View File

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

View File

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