diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..cd4cad9 --- /dev/null +++ b/PROJECT_PLAN.md @@ -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. diff --git a/README.md b/README.md index 6d5e851..41b6b23 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ https://drivers.smartsoltech.kr ```text /setdomain -@seoulmate_officialbot +@your_bot_username drivers.smartsoltech.kr ``` @@ -43,7 +43,7 @@ POSTGRES_PORT=5433 DATABASE_URL=postgresql+asyncpg://drivers:change-this-db-password@db:5432/drivers BOT_TOKEN=123456:telegram-token -BOT_USERNAME=seoulmate_officialbot +BOT_USERNAME=your_bot_username WEBAPP_URL=https://drivers.smartsoltech.kr PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr API_BASE_URL=http://api:8000 @@ -122,6 +122,12 @@ Backend проверяет подпись Telegram, создает/обновл ## Основные endpoint-ы - `GET /api/users/me` +- `GET /api/me` +- `GET /api/my/vehicles` +- `POST /api/my/vehicles` +- `PATCH /api/my/vehicles/{vehicle_id}` +- `GET /api/my/vehicles/{vehicle_id}/service-history` +- `POST /api/my/vehicles/{vehicle_id}/grant-service-access` - `POST /api/cars`, `GET /api/cars`, `GET/PATCH/DELETE /api/cars/{id}` - `POST /api/fuel`, `GET /api/cars/{car_id}/fuel?limit=50&offset=0` - `PATCH /api/fuel/{id}`, `DELETE /api/fuel/{id}` @@ -129,10 +135,65 @@ Backend проверяет подпись Telegram, создает/обновл - `PATCH /api/service/{id}`, `DELETE /api/service/{id}` - `GET /api/cars/{car_id}/stats` - `GET /api/users/{user_id}/reminders?limit=50&offset=0` +- `POST /api/service-centers` +- `GET /api/service-centers/my` +- `POST /api/service-centers/{id}/verification` +- `POST /api/service-centers/{id}/employees/invite` +- `GET /api/service-centers/{id}/visits` +- `POST /api/service-centers/{id}/visits` +- `POST /api/service-visits/{id}/work-items` +- `POST /api/service-visits/{id}/complete` +- `POST /api/service-visits/{id}/confirm` +- `POST /api/service-visits/{id}/dispute` +- `POST /api/service-visits/{id}/vehicle-change-requests` +- `POST /api/vehicle-change-requests/{id}/approve` +- `POST /api/vehicle-change-requests/{id}/reject` +- `GET /api/admin/service-centers/pending` +- `POST /api/admin/service-centers/{id}/verify` +- `POST /api/admin/service-centers/{id}/reject` +- `POST /api/admin/service-centers/{id}/suspend` +- `GET /api/admin/audit-log` +- `GET /api/admin/disputes` - `POST /api/ocr/parse-text-receipt` +- `POST /api/ocr/license-plate` +- `POST /api/ocr/vin` +- `POST /api/ocr/service-document` Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру. ## OCR Настоящий OCR по фото/PDF пока не подключен. Endpoint `POST /api/ocr/parse-text-receipt` честно разбирает только текстовый чек. Старый `/api/ocr/fuel-receipt` оставлен как deprecated-совместимость. + +Новая OCR-архитектура использует заменяемый provider: + +- `OCRProvider` +- `StubOCRProvider` +- будущие `TesseractOCRProvider`, cloud OCR или VLM provider + +OCR возвращает кандидаты и не меняет данные автомобиля напрямую: + +```json +{ + "recognized_text": "VIN KMHCT41BAHU123456", + "candidates": [ + {"type": "vin", "value": "KMHCT41BAHU123456", "confidence": 0.84} + ] +} +``` + +## Platform Roadmap + +Проектное расширение в сторону владельцев авто и СТО описано в `PROJECT_PLAN.md`. В код добавлен первый совместимый слой платформы: + +- расширенный `ServiceCenter`; +- верификация СТО; +- сотрудники СТО; +- `VehicleAccess`; +- `ServiceVisit`; +- `ServiceWorkItem`; +- `VehicleDataChangeRequest`; +- `AuditLog`; +- нормализация VIN и госномера. + +СТО не получает персональные данные владельца по VIN/номеру. Поиск возвращает только минимальную маскированную карточку и пишет действие в аудит. Критичные изменения автомобиля проходят через запрос подтверждения владельцем. diff --git a/alembic/versions/202605120005_platform_service_visits.py b/alembic/versions/202605120005_platform_service_visits.py new file mode 100644 index 0000000..4f37fe7 --- /dev/null +++ b/alembic/versions/202605120005_platform_service_visits.py @@ -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") diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..ceba745 --- /dev/null +++ b/app/api/admin.py @@ -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) diff --git a/app/api/cars.py b/app/api/cars.py index a271079..24e3e26 100644 --- a/app/api/cars.py +++ b/app/api/cars.py @@ -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) diff --git a/app/api/change_requests.py b/app/api/change_requests.py new file mode 100644 index 0000000..b7a4ff6 --- /dev/null +++ b/app/api/change_requests.py @@ -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 diff --git a/app/api/deps.py b/app/api/deps.py index e0a547c..ff68f56 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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, + ) + ) diff --git a/app/api/my.py b/app/api/my.py new file mode 100644 index 0000000..3658541 --- /dev/null +++ b/app/api/my.py @@ -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 diff --git a/app/api/ocr.py b/app/api/ocr.py index 38f74e5..841fdf8 100644 --- a/app/api/ocr.py +++ b/app/api/ocr.py @@ -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", diff --git a/app/api/service_centers.py b/app/api/service_centers.py index 559ea12..d8385e1 100644 --- a/app/api/service_centers.py +++ b/app/api/service_centers.py @@ -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, diff --git a/app/api/service_visits.py b/app/api/service_visits.py new file mode 100644 index 0000000..b4ce084 --- /dev/null +++ b/app/api/service_visits.py @@ -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) diff --git a/app/api/users.py b/app/api/users.py index 5009286..6f52570 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -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, diff --git a/app/main.py b/app/main.py index 6462d5a..cd658b9 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,18 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from app.api import cars, catalog, entries, ocr, service_centers, users +from app.api import ( + admin, + cars, + catalog, + change_requests, + entries, + my, + ocr, + service_centers, + service_visits, + users, +) from app.core.config import settings app = FastAPI(title="Drivers Bot API", version="0.1.0") @@ -19,11 +30,15 @@ app.add_middleware( ) app.include_router(users.router, prefix="/api") +app.include_router(my.router, prefix="/api") app.include_router(catalog.router, prefix="/api") app.include_router(cars.router, prefix="/api") app.include_router(entries.router, prefix="/api") app.include_router(ocr.router, prefix="/api") app.include_router(service_centers.router, prefix="/api") +app.include_router(service_visits.router, prefix="/api") +app.include_router(change_requests.router, prefix="/api") +app.include_router(admin.router, prefix="/api") @app.get("/health") diff --git a/app/models/car.py b/app/models/car.py index 8facaa2..741bf6c 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -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) diff --git a/app/models/user.py b/app/models/user.py index 3130e27..5620c04 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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()) diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py index 9b3f751..c0d065e 100644 --- a/app/schemas/service_center.py +++ b/app/schemas/service_center.py @@ -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 diff --git a/app/schemas/user.py b/app/schemas/user.py index 63df6bc..8dae350 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -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 diff --git a/app/services/ocr_provider.py b/app/services/ocr_provider.py new file mode 100644 index 0000000..3304cad --- /dev/null +++ b/app/services/ocr_provider.py @@ -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() diff --git a/app/services/vehicle_identity.py b/app/services/vehicle_identity.py new file mode 100644 index 0000000..e7bd4b7 --- /dev/null +++ b/app/services/vehicle_identity.py @@ -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:]}" diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..f9dd52e --- /dev/null +++ b/tests/test_platform.py @@ -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" diff --git a/web/index.html b/web/index.html index 4aa7956..8773644 100644 --- a/web/index.html +++ b/web/index.html @@ -220,6 +220,9 @@ Параметры автомобиля Локаль и валюта Уведомления + Запросы на подтверждение + Подключенные автосервисы + Панель СТО Разобрать чек @@ -253,6 +256,54 @@ Включить уведомления + + Запросы на подтверждение + Здесь будут визиты СТО и изменения данных авто, которые ждут твоего решения. + + + + + Подключенные автосервисы + Автосервис видит машину только после твоего разрешения или в рамках визита. + + + + + Панель СТО + + + Название СТО + + + + Юридическое название + + + + Страна + + + + Город + + + + Адрес + + + + Телефон + + + + Регистрационный номер + + + Создать СТО + + + + Новое авто diff --git a/web/static/app.js b/web/static/app.js index 4b0f62d..9de46d8 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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 = `СТО пока не создано`; + return; + } + root.innerHTML = state.serviceCenters + .map( + (center) => ` + + ${center.display_name || center.name} + ${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"} + Статус: ${center.verification_status} + + `, + ) + .join(""); +} + +function renderPlaceholderList(selector, message) { + const root = document.querySelector(selector); + if (root) root.innerHTML = `${message}`; +} + 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", () => { diff --git a/web/static/styles.css b/web/static/styles.css index 388cf16..7cc2b09 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -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;