diff --git a/.env.example b/.env.example index bffbb7a..a1d69ed 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,12 @@ ALLOW_DEV_AUTH=false APP_HOST=0.0.0.0 APP_PORT=8000 VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +SECRET_KEY=change-this-long-random-secret +REDIS_URL=redis://redis:6379/0 OCR_PROVIDER=tesseract OCR_LANGUAGES=eng+rus+kor LLM_BASE_URL= LLM_MODEL= +ADMIN_TELEGRAM_IDS= +ADMIN_BOOTSTRAP_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aef0a66 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: ci + +on: + push: + branches: [main, develop, production] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: drivers + POSTGRES_USER: drivers + POSTGRES_PASSWORD: drivers + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U drivers -d drivers" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Tests + run: pytest -q + - name: Migration smoke + env: + DATABASE_URL: postgresql+asyncpg://drivers:drivers@127.0.0.1:5433/drivers + run: alembic upgrade head + - name: Docker build + run: docker build . diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..2efac98 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,111 @@ +# CarPass Deploy + +## First Install + +```bash +sudo mkdir -p /opt/carpass +sudo chown "$USER":"$USER" /opt/carpass +git clone /opt/carpass/app +cd /opt/carpass/app +cp .env.example .env +``` + +Edit `.env` and set real secrets: + +- `BOT_TOKEN` +- `BOT_USERNAME` +- `PUBLIC_WEBAPP_URL` +- `CORS_ORIGINS` +- `INTERNAL_API_TOKEN` +- `SECRET_KEY` +- `REDIS_URL` if Redis is external +- `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` when browser push is enabled +- `ADMIN_TELEGRAM_IDS` + +Production must use public HTTPS URLs and `ALLOW_DEV_AUTH=false`. + +## Start + +```bash +docker compose up -d --build +docker compose exec api alembic upgrade head +python -m scripts.bootstrap_admin +curl -fsS http://127.0.0.1:8000/ready +``` + +The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation. + +## Git-Based Update + +The server directory must remain a git clone. The main update path is: + +```bash +APP_DIR=/opt/carpass/app DEPLOY_BRANCH=main ./scripts/deploy.sh +``` + +The script runs: + +- `git fetch` +- `git pull --ff-only` +- optional DB backup with `BACKUP_BEFORE_DEPLOY=true` +- Docker build/up +- `alembic upgrade head` +- Python smoke compile +- `/ready` health check + +Do not use rsync as the primary deploy mechanism. + +## Rollback + +```bash +cd /opt/carpass/app +git log --oneline -20 +git checkout +docker compose up -d --build +curl -fsS http://127.0.0.1:8000/ready +``` + +Be careful with database migrations: code rollback does not automatically downgrade data. + +## Backups + +Create a compressed custom-format dump before risky deploys: + +```bash +BACKUP_DIR=/opt/carpass/backups ./scripts/backup_db.sh +``` + +Restore only during a maintenance window: + +```bash +./scripts/restore_db.sh /opt/carpass/backups/carpass-drivers-YYYYMMDDTHHMMSSZ.dump +``` + +For volume-level recovery, back up the Docker named volumes `pgdata` and `redisdata` according to the host backup policy. + +## Logs + +```bash +docker compose ps +docker compose logs -f api +docker compose logs -f bot +docker compose logs -f db +``` + +## Migration Smoke Check + +For a configured Postgres database: + +```bash +./scripts/check_migrations.sh +``` + +## Cleanup Jobs + +Run periodic cleanup from cron or systemd timer: + +```bash +docker compose exec -T api python scripts/cleanup_jobs.py +``` + +It expires stale employee invites, marks exhausted notifications as abandoned, removes old abandoned notifications and clears old draft work orders. diff --git a/README.md b/README.md index 0340a6b..0d73630 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,15 @@ CarPass — цифровой паспорт автомобиля в Telegram. О Если автомобиль уже привязан к СТО, владелец может открыть карточку авто и записаться сразу в календарь этого сервиса. Если привязки нет, пользователь выбирает СТО из каталога, смотрит свободные окна и создает заявку. -СТО получает уведомление о новой заявке, подтверждает время, отклоняет запись или предлагает другое окно. Когда запись подтверждена, она появляется в календаре СТО. После визита сервис может создать заказ-наряд из записи, провести работы, отправить результат владельцу и обновить историю автомобиля через существующий сценарий подтверждения визита. +СТО получает уведомление о новой заявке, подтверждает время, отклоняет запись или предлагает другое окно. Когда запись подтверждена, она появляется в календаре СТО. После этого сервис создает заказ-наряд, добавляет работы, товары, жидкости, запчасти, комментарии и при необходимости отправляет заказ-наряд владельцу на согласование. + +После закрытия заказ-наряда CarPass атомарно создает сервисную запись, расход автомобиля, историю одометра, рекомендации следующего ТО и уведомление владельцу. Завершенная работа появляется в истории автомобиля, а стоимость попадает в стоимость владения без двойного учета. + +## Заказ-наряды СТО + +Заказ-наряд хранит номер, СТО, автомобиль, владельца, сотрудника, пробег, жалобу клиента, диагностику, работы, материалы, рекомендации, комментарии, файлы, суммы работ и товаров, скидку, итог и статус. Поддержаны статусы `draft`, `diagnosis`, `waiting_owner_approval`, `approved_by_owner`, `rejected_by_owner`, `in_progress`, `completed`, `cancelled`, `archived`. + +Завершенные заказ-наряды нельзя редактировать обычным способом. Если после согласования изменилась сумма, заказ-наряд возвращается на согласование владельцу. ## Рекомендации ТО @@ -50,16 +58,27 @@ CarPass создает рекомендации обслуживания из д - СТО получает новую заявку на запись, отмену клиента и решение по предложенному времени. - Владелец получает подтверждение, отклонение или предложение нового времени. +- Владелец получает уведомления о создании заказ-наряда, ожидании согласования и завершении работы. - Рекомендации ТО фиксируются в истории уведомлений. +Уведомления имеют статусы `pending`, `processing`, `sent`, `failed`, `retrying`, `abandoned`, `read`, счетчик повторов и idempotency key, чтобы не плодить дубли. + ## Безопасность данных CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца. +Чувствительные действия ограничены rate limiting: OCR, VIN/номер, запросы доступа к автомобилю, записи в СТО, приглашения сотрудников и отзывы. В production лимиты работают через Redis, а локально могут падать обратно на in-memory режим. + ## Telegram Mini App Mini App открывается через кнопку внутри Telegram-бота. Так Telegram передает защищенную авторизацию, а гараж привязывается к аккаунту пользователя. Если страницу открыть напрямую в браузере, CarPass покажет понятное приглашение открыть приложение через Telegram. +## Deploy + +Production/pilot deploy описан в [DEPLOY.md](DEPLOY.md). Основной путь обновления сервера: git clone/pull, Docker Compose, Alembic migrations и `/ready` health check. Admin bootstrap выполняется через `ADMIN_TELEGRAM_IDS`, без hardcoded Telegram ID в миграциях. + +Production-контур включает Redis-backed rate limiting, security headers, `/health`, `/ready`, Prometheus-ready `/metrics`, cleanup jobs, backup/restore скрипты и CI-шаблон для lint/tests/migrations/docker build. + ## Команды бота - `/start` и `/menu` — правильный вход в Mini App. diff --git a/alembic/versions/202605150001_promote_admin_user.py b/alembic/versions/202605150001_promote_admin_user.py index 41903cc..5785e9b 100644 --- a/alembic/versions/202605150001_promote_admin_user.py +++ b/alembic/versions/202605150001_promote_admin_user.py @@ -1,4 +1,4 @@ -"""promote requested admin user +"""legacy admin bootstrap placeholder Revision ID: 202605150001 Revises: 202605140002 @@ -7,33 +7,15 @@ Create Date: 2026-05-15 05:00:00.000000 from collections.abc import Sequence -from alembic import op - revision: str = "202605150001" down_revision: str | None = "202605140002" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None -ADMIN_TELEGRAM_ID = 556399210 - def upgrade() -> None: - op.execute( - f""" - insert into users (telegram_id, username, platform_role) - values ({ADMIN_TELEGRAM_ID}, '{ADMIN_TELEGRAM_ID}', 'admin') - on conflict (telegram_id) do update - set platform_role = 'admin' - """ - ) + return None def downgrade() -> None: - op.execute( - f""" - update users - set platform_role = 'user' - where telegram_id = {ADMIN_TELEGRAM_ID} - and platform_role = 'admin' - """ - ) + return None diff --git a/alembic/versions/202605150003_production_work_orders.py b/alembic/versions/202605150003_production_work_orders.py new file mode 100644 index 0000000..1922360 --- /dev/null +++ b/alembic/versions/202605150003_production_work_orders.py @@ -0,0 +1,210 @@ +"""production work orders, employee invites, notifications + +Revision ID: 202605150003 +Revises: 202605150002 +Create Date: 2026-05-15 12:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "202605150003" +down_revision: str | None = "202605150002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("service_entries", sa.Column("service_visit_id", sa.Integer(), nullable=True)) + op.create_index("ix_service_entries_service_visit_id", "service_entries", ["service_visit_id"]) + op.create_foreign_key( + "fk_service_entries_service_visit_id_service_visits", + "service_entries", + "service_visits", + ["service_visit_id"], + ["id"], + ondelete="SET NULL", + ) + op.add_column("expense_entries", sa.Column("service_visit_id", sa.Integer(), nullable=True)) + op.create_index("ix_expense_entries_service_visit_id", "expense_entries", ["service_visit_id"]) + op.create_foreign_key( + "fk_expense_entries_service_visit_id_service_visits", + "expense_entries", + "service_visits", + ["service_visit_id"], + ["id"], + ondelete="SET NULL", + ) + + op.add_column("service_employees", sa.Column("invite_token", sa.String(length=96), nullable=True)) + op.add_column("service_employees", sa.Column("invite_expires_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("service_employees", sa.Column("invite_revoked_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("service_employees", sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_service_employees_invite_token", "service_employees", ["invite_token"], unique=True) + + op.add_column("service_visits", sa.Column("work_order_number", sa.String(length=40), nullable=True)) + op.add_column("service_visits", sa.Column("owner_id", sa.Integer(), nullable=True)) + op.add_column("service_visits", sa.Column("assigned_employee_id", sa.Integer(), nullable=True)) + op.add_column("service_visits", sa.Column("customer_complaint", sa.Text(), nullable=True)) + op.add_column("service_visits", sa.Column("diagnosis", sa.Text(), nullable=True)) + op.add_column("service_visits", sa.Column("service_comment", sa.Text(), nullable=True)) + op.add_column("service_visits", sa.Column("owner_comment", sa.Text(), nullable=True)) + op.add_column("service_visits", sa.Column("recommendations_text", sa.Text(), nullable=True)) + op.add_column("service_visits", sa.Column("attachment_urls", sa.JSON(), nullable=True)) + op.add_column("service_visits", sa.Column("labor_total", sa.Numeric(12, 2), server_default="0", nullable=False)) + op.add_column("service_visits", sa.Column("product_total", sa.Numeric(12, 2), server_default="0", nullable=False)) + op.add_column("service_visits", sa.Column("discount_total", sa.Numeric(12, 2), server_default="0", nullable=False)) + op.add_column("service_visits", sa.Column("final_total", sa.Numeric(12, 2), server_default="0", nullable=False)) + op.add_column("service_visits", sa.Column("price_approved_total", sa.Numeric(12, 2), nullable=True)) + op.add_column("service_visits", sa.Column("approval_required", sa.Boolean(), server_default=sa.text("false"), nullable=False)) + op.add_column("service_visits", sa.Column("opened_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("service_visits", sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("service_visits", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_service_visits_work_order_number", "service_visits", ["work_order_number"], unique=True) + op.create_index("ix_service_visits_owner_id", "service_visits", ["owner_id"]) + op.create_index("ix_service_visits_assigned_employee_id", "service_visits", ["assigned_employee_id"]) + op.create_foreign_key("fk_service_visits_owner_id_users", "service_visits", "users", ["owner_id"], ["id"], ondelete="SET NULL") + op.create_foreign_key( + "fk_service_visits_assigned_employee_id_service_employees", + "service_visits", + "service_employees", + ["assigned_employee_id"], + ["id"], + ondelete="SET NULL", + ) + + op.add_column("service_work_items", sa.Column("category", sa.String(length=80), nullable=True)) + op.add_column("service_work_items", sa.Column("quantity", sa.Numeric(10, 3), server_default="1", nullable=False)) + op.add_column("service_work_items", sa.Column("unit", sa.String(length=24), server_default="pcs", nullable=False)) + op.add_column("service_work_items", sa.Column("unit_price", sa.Numeric(12, 2), nullable=True)) + op.add_column("service_work_items", sa.Column("discount", sa.Numeric(12, 2), server_default="0", nullable=False)) + op.add_column("service_work_items", sa.Column("total", sa.Numeric(12, 2), nullable=True)) + op.add_column("service_work_items", sa.Column("warranty_days", sa.Integer(), nullable=True)) + op.add_column("service_work_items", sa.Column("warranty_odometer_km", sa.Integer(), nullable=True)) + + op.add_column("service_notifications", sa.Column("retry_count", sa.Integer(), server_default="0", nullable=False)) + op.add_column("service_notifications", sa.Column("last_error", sa.Text(), nullable=True)) + op.add_column("service_notifications", sa.Column("idempotency_key", sa.String(length=160), nullable=True)) + op.add_column("service_notifications", sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("service_notifications", sa.Column("read_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_service_notifications_idempotency_key", "service_notifications", ["idempotency_key"], unique=True) + op.execute("update service_notifications set status = 'pending' where status = 'unread'") + + op.create_table( + "service_product_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_visit_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=180), nullable=False), + sa.Column("category", sa.String(length=80), nullable=True), + sa.Column("product_type", sa.String(length=40), server_default="other", nullable=False), + sa.Column("brand", sa.String(length=80), nullable=True), + sa.Column("sku", sa.String(length=120), nullable=True), + sa.Column("quantity", sa.Numeric(10, 3), server_default="1", nullable=False), + sa.Column("unit", sa.String(length=24), server_default="pcs", nullable=False), + sa.Column("unit_price", sa.Numeric(12, 2), server_default="0", nullable=False), + sa.Column("discount", sa.Numeric(12, 2), server_default="0", nullable=False), + sa.Column("total", sa.Numeric(12, 2), server_default="0", nullable=False), + sa.Column("volume", sa.Numeric(8, 3), nullable=True), + sa.Column("viscosity", sa.String(length=40), nullable=True), + sa.Column("specification", sa.String(length=120), nullable=True), + sa.Column("used_volume", sa.Numeric(8, 3), 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("ix_service_product_items_category", "service_product_items", ["category"]) + op.create_index("ix_service_product_items_product_type", "service_product_items", ["product_type"]) + op.create_index("ix_service_product_items_service_visit_id", "service_product_items", ["service_visit_id"]) + + op.create_table( + "work_order_status_history", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_visit_id", sa.Integer(), nullable=False), + sa.Column("from_status", sa.String(length=40), nullable=True), + sa.Column("to_status", sa.String(length=40), nullable=False), + sa.Column("changed_by_user_id", sa.Integer(), nullable=True), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["changed_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["service_visit_id"], ["service_visits.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_work_order_status_history_changed_by_user_id", "work_order_status_history", ["changed_by_user_id"]) + op.create_index("ix_work_order_status_history_created_at", "work_order_status_history", ["created_at"]) + op.create_index("ix_work_order_status_history_service_visit_id", "work_order_status_history", ["service_visit_id"]) + op.create_index("ix_work_order_status_history_to_status", "work_order_status_history", ["to_status"]) + + +def downgrade() -> None: + op.drop_constraint("fk_expense_entries_service_visit_id_service_visits", "expense_entries", type_="foreignkey") + op.drop_index("ix_expense_entries_service_visit_id", table_name="expense_entries") + op.drop_column("expense_entries", "service_visit_id") + op.drop_constraint("fk_service_entries_service_visit_id_service_visits", "service_entries", type_="foreignkey") + op.drop_index("ix_service_entries_service_visit_id", table_name="service_entries") + op.drop_column("service_entries", "service_visit_id") + + op.drop_index("ix_work_order_status_history_to_status", table_name="work_order_status_history") + op.drop_index("ix_work_order_status_history_service_visit_id", table_name="work_order_status_history") + op.drop_index("ix_work_order_status_history_created_at", table_name="work_order_status_history") + op.drop_index("ix_work_order_status_history_changed_by_user_id", table_name="work_order_status_history") + op.drop_table("work_order_status_history") + + op.drop_index("ix_service_product_items_service_visit_id", table_name="service_product_items") + op.drop_index("ix_service_product_items_product_type", table_name="service_product_items") + op.drop_index("ix_service_product_items_category", table_name="service_product_items") + op.drop_table("service_product_items") + + op.drop_index("ix_service_notifications_idempotency_key", table_name="service_notifications") + op.drop_column("service_notifications", "read_at") + op.drop_column("service_notifications", "sent_at") + op.drop_column("service_notifications", "idempotency_key") + op.drop_column("service_notifications", "last_error") + op.drop_column("service_notifications", "retry_count") + + for column_name in ( + "warranty_odometer_km", + "warranty_days", + "total", + "discount", + "unit_price", + "unit", + "quantity", + "category", + ): + op.drop_column("service_work_items", column_name) + + op.drop_constraint("fk_service_visits_assigned_employee_id_service_employees", "service_visits", type_="foreignkey") + op.drop_constraint("fk_service_visits_owner_id_users", "service_visits", type_="foreignkey") + op.drop_index("ix_service_visits_assigned_employee_id", table_name="service_visits") + op.drop_index("ix_service_visits_owner_id", table_name="service_visits") + op.drop_index("ix_service_visits_work_order_number", table_name="service_visits") + for column_name in ( + "completed_at", + "approved_at", + "opened_at", + "approval_required", + "price_approved_total", + "final_total", + "discount_total", + "product_total", + "labor_total", + "attachment_urls", + "recommendations_text", + "owner_comment", + "service_comment", + "diagnosis", + "customer_complaint", + "assigned_employee_id", + "owner_id", + "work_order_number", + ): + op.drop_column("service_visits", column_name) + + op.drop_index("ix_service_employees_invite_token", table_name="service_employees") + op.drop_column("service_employees", "activated_at") + op.drop_column("service_employees", "invite_revoked_at") + op.drop_column("service_employees", "invite_expires_at") + op.drop_column("service_employees", "invite_token") diff --git a/alembic/versions/202605150004_production_guards.py b/alembic/versions/202605150004_production_guards.py new file mode 100644 index 0000000..2cf7930 --- /dev/null +++ b/alembic/versions/202605150004_production_guards.py @@ -0,0 +1,114 @@ +"""production idempotency, corrections and slot guards + +Revision ID: 202605150004 +Revises: 202605150003 +Create Date: 2026-05-15 16:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "202605150004" +down_revision: str | None = "202605150003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("service_visits", sa.Column("version", sa.Integer(), server_default="1", nullable=False)) + op.add_column("service_visits", sa.Column("completed_snapshot", sa.JSON(), nullable=True)) + + op.create_index( + "uq_service_entries_service_visit_id_not_null", + "service_entries", + ["service_visit_id"], + unique=True, + postgresql_where=sa.text("service_visit_id is not null"), + ) + op.create_index( + "uq_expense_entries_service_visit_id_not_null", + "expense_entries", + ["service_visit_id"], + unique=True, + postgresql_where=sa.text("service_visit_id is not null"), + ) + op.create_index( + "uq_active_service_appointment_slot", + "service_appointments", + ["service_center_id", "requested_start_at", "requested_end_at"], + unique=True, + postgresql_where=sa.text("status in ('requested','confirmed','confirmed_by_sto','proposed_new_time')"), + ) + + op.create_table( + "work_order_corrections", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_visit_id", sa.Integer(), nullable=False), + sa.Column("requested_by_user_id", sa.Integer(), nullable=True), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("proposed_changes", sa.JSON(), nullable=True), + sa.Column("status", sa.String(length=24), server_default="pending", nullable=False), + sa.Column("owner_approval_required", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("created_version", sa.Integer(), server_default="1", nullable=False), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["service_visit_id"], ["service_visits.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_work_order_corrections_created_at", "work_order_corrections", ["created_at"]) + op.create_index("ix_work_order_corrections_service_visit_id", "work_order_corrections", ["service_visit_id"]) + op.create_index("ix_work_order_corrections_status", "work_order_corrections", ["status"]) + + op.create_table( + "inventory_transactions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_center_id", sa.Integer(), nullable=False), + sa.Column("service_visit_id", sa.Integer(), nullable=True), + sa.Column("product_item_id", sa.Integer(), nullable=True), + sa.Column("transaction_type", sa.String(length=32), nullable=False), + sa.Column("sku", sa.String(length=120), nullable=True), + sa.Column("title", sa.String(length=180), nullable=True), + sa.Column("quantity", sa.Numeric(10, 3), server_default="0", nullable=False), + sa.Column("unit", sa.String(length=24), server_default="pcs", nullable=False), + sa.Column("actor_user_id", sa.Integer(), 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.ForeignKeyConstraint(["product_item_id"], ["service_product_items.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_visit_id"], ["service_visits.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_inventory_transactions_actor_user_id", "inventory_transactions", ["actor_user_id"]) + op.create_index("ix_inventory_transactions_created_at", "inventory_transactions", ["created_at"]) + op.create_index("ix_inventory_transactions_product_item_id", "inventory_transactions", ["product_item_id"]) + op.create_index("ix_inventory_transactions_service_center_id", "inventory_transactions", ["service_center_id"]) + op.create_index("ix_inventory_transactions_service_visit_id", "inventory_transactions", ["service_visit_id"]) + op.create_index("ix_inventory_transactions_sku", "inventory_transactions", ["sku"]) + op.create_index("ix_inventory_transactions_transaction_type", "inventory_transactions", ["transaction_type"]) + + +def downgrade() -> None: + op.drop_index("ix_inventory_transactions_transaction_type", table_name="inventory_transactions") + op.drop_index("ix_inventory_transactions_sku", table_name="inventory_transactions") + op.drop_index("ix_inventory_transactions_service_visit_id", table_name="inventory_transactions") + op.drop_index("ix_inventory_transactions_service_center_id", table_name="inventory_transactions") + op.drop_index("ix_inventory_transactions_product_item_id", table_name="inventory_transactions") + op.drop_index("ix_inventory_transactions_created_at", table_name="inventory_transactions") + op.drop_index("ix_inventory_transactions_actor_user_id", table_name="inventory_transactions") + op.drop_table("inventory_transactions") + + op.drop_index("ix_work_order_corrections_status", table_name="work_order_corrections") + op.drop_index("ix_work_order_corrections_service_visit_id", table_name="work_order_corrections") + op.drop_index("ix_work_order_corrections_created_at", table_name="work_order_corrections") + op.drop_table("work_order_corrections") + + op.drop_index("uq_active_service_appointment_slot", table_name="service_appointments") + op.drop_index("uq_expense_entries_service_visit_id_not_null", table_name="expense_entries") + op.drop_index("uq_service_entries_service_visit_id_not_null", table_name="service_entries") + op.drop_column("service_visits", "completed_snapshot") + op.drop_column("service_visits", "version") diff --git a/app/api/ocr.py b/app/api/ocr.py index feb565d..530012d 100644 --- a/app/api/ocr.py +++ b/app/api/ocr.py @@ -1,21 +1,29 @@ import re +from datetime import date from decimal import Decimal -from fastapi import APIRouter, Depends, File, UploadFile +from fastapi import APIRouter, Depends, File, Request, UploadFile from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_telegram_user +from app.db.session import get_session from app.models.user import User from app.services.ocr_provider import get_ocr_provider +from app.services.rate_limit import check_rate_limit +from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload router = APIRouter(prefix="/ocr", tags=["ocr"]) +MAX_OCR_FILE_BYTES = 8 * 1024 * 1024 class ReceiptSuggestion(BaseModel): + entry_date: date | None = None total_cost: Decimal | None = None liters: Decimal | None = None price_per_liter: Decimal | None = None station: str | None = None + category: str | None = None confidence: float message: str @@ -34,10 +42,20 @@ class OCRResultRead(BaseModel): @router.post("/parse-text-receipt", response_model=ReceiptSuggestion) async def parse_text_receipt( + request: Request, file: UploadFile = File(...), current_user: User = Depends(get_current_telegram_user), + session: AsyncSession = Depends(get_session), ) -> ReceiptSuggestion: + await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session) content = await file.read() + validate_upload( + content=content, + filename=file.filename, + content_type=file.content_type, + max_bytes=MAX_OCR_FILE_BYTES, + allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES, + ) content_type = (file.content_type or "").lower() if content_type.startswith("image/") or content_type == "application/pdf": result = await get_ocr_provider().recognize(content, file.filename) @@ -62,6 +80,7 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion: numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)] station = detect_station(compact) + entry_date = detect_date(compact) liters = find_liters(compact, numbers) price = find_price_per_liter(compact, numbers) total = find_total(compact, numbers, liters, price) @@ -80,10 +99,12 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion: confidence = max(0, min(float(confidence), 0.95)) return ReceiptSuggestion( + entry_date=entry_date, total_cost=total, liters=liters, price_per_liter=price, station=station, + category="fuel" if liters or price else None, confidence=round(confidence, 2) if numbers else 0, message=( "Разобрал текст чека и заполнил форму. Проверь значения перед сохранением." @@ -95,18 +116,25 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion: @router.post("/fuel-receipt", response_model=ReceiptSuggestion, deprecated=True) async def scan_fuel_receipt( + request: Request, file: UploadFile = File(...), current_user: User = Depends(get_current_telegram_user), + session: AsyncSession = Depends(get_session), ) -> ReceiptSuggestion: - return await parse_text_receipt(file, current_user) + return await parse_text_receipt(request, file, current_user, session) @router.post("/license-plate", response_model=OCRResultRead) async def recognize_license_plate( + request: Request, file: UploadFile = File(...), current_user: User = Depends(get_current_telegram_user), + session: AsyncSession = Depends(get_session), ) -> OCRResultRead: - result = await get_ocr_provider().recognize(await file.read(), file.filename) + await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session) + content = await file.read() + validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) + result = await get_ocr_provider().recognize(content, file.filename) return OCRResultRead( recognized_text=result.recognized_text, candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"], @@ -116,10 +144,15 @@ async def recognize_license_plate( @router.post("/vin", response_model=OCRResultRead) async def recognize_vin( + request: Request, file: UploadFile = File(...), current_user: User = Depends(get_current_telegram_user), + session: AsyncSession = Depends(get_session), ) -> OCRResultRead: - result = await get_ocr_provider().recognize(await file.read(), file.filename) + await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session) + content = await file.read() + validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) + result = await get_ocr_provider().recognize(content, file.filename) return OCRResultRead( recognized_text=result.recognized_text, candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"], @@ -129,10 +162,15 @@ async def recognize_vin( @router.post("/service-document", response_model=OCRResultRead) async def recognize_service_document( + request: Request, file: UploadFile = File(...), current_user: User = Depends(get_current_telegram_user), + session: AsyncSession = Depends(get_session), ) -> OCRResultRead: - result = await get_ocr_provider().recognize(await file.read(), file.filename) + await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session) + content = await file.read() + validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) + result = await get_ocr_provider().recognize(content, file.filename) return OCRResultRead( recognized_text=result.recognized_text, candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates], @@ -158,6 +196,24 @@ def detect_station(text: str) -> str | None: return None +def detect_date(text: str) -> date | None: + for pattern in ( + r"\b(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})\b", + r"\b(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\b", + ): + match = re.search(pattern, text) + if not match: + continue + first, second, third = [int(item) for item in match.groups()] + try: + if first > 1900: + return date(first, second, third) + return date(third, second, first) + except ValueError: + continue + return None + + def decimal_from_match(match: re.Match[str] | None) -> Decimal | None: if not match: return None @@ -183,9 +239,9 @@ def find_price_per_liter(text: str, numbers: list[Decimal]) -> Decimal | None: ] for pattern in patterns: value = decimal_from_match(re.search(pattern, text, re.IGNORECASE)) - if value and Decimal("10") <= value <= Decimal("500"): + if value and Decimal("0.1") <= value <= Decimal("500"): return value - candidates = [item for item in numbers if Decimal("10") <= item <= Decimal("500")] + candidates = [item for item in numbers if Decimal("0.1") <= item <= Decimal("500")] return candidates[-1] if candidates else None diff --git a/app/api/service_centers.py b/app/api/service_centers.py index f36f0ef..23a502b 100644 --- a/app/api/service_centers.py +++ b/app/api/service_centers.py @@ -1,6 +1,7 @@ -from datetime import UTC, datetime +import secrets +from datetime import UTC, datetime, timedelta -from fastapi import APIRouter, Depends, Header, HTTPException, status +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -48,6 +49,7 @@ from app.schemas.service_center import ( ) from app.services.notifications import notify_platform_moderators from app.services.odometer import validate_odometer_change +from app.services.rate_limit import check_rate_limit from app.services.vehicle_identity import mask_license_plate, mask_vin router = APIRouter(prefix="/service-centers", tags=["service-centers"]) @@ -162,12 +164,17 @@ async def my_service_centers( current_user: User = Depends(get_current_telegram_user), ) -> list[ServiceCenter]: result = await session.execute( - select(ServiceCenter) + select(ServiceCenter, ServiceEmployee.role, ServiceEmployee.status) .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()) + centers = [] + for center, role, employee_status in result.all(): + center.employee_role = role + center.employee_status = employee_status + centers.append(center) + return centers @router.get("/public", response_model=list[ServiceCenterPublicRead]) @@ -253,9 +260,11 @@ async def submit_verification( async def invite_employee( service_center_id: int, payload: ServiceEmployeeInvite, + request: Request, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceEmployee: + await check_rate_limit(scope="employee_invite", limit=10, window_seconds=3600, request=request, user=current_user, session=session) 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( @@ -272,18 +281,95 @@ async def invite_employee( role=payload.role, permissions=payload.permissions, status="invited", + invite_token=secrets.token_urlsafe(32), + invite_expires_at=datetime.now(UTC) + timedelta(hours=payload.expires_in_hours), ) session.add(employee) else: employee.role = payload.role employee.permissions = payload.permissions employee.status = "invited" + employee.invite_token = secrets.token_urlsafe(32) + employee.invite_expires_at = datetime.now(UTC) + timedelta(hours=payload.expires_in_hours) + employee.invite_revoked_at = None + employee.activated_at = None 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.post("/employees/invites/{invite_token}/accept", response_model=ServiceEmployeeRead) +async def accept_employee_invite( + invite_token: str, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceEmployee: + result = await session.execute( + select(ServiceEmployee).where(ServiceEmployee.invite_token == invite_token) + ) + employee = result.scalar_one_or_none() + if employee is None: + raise HTTPException(status_code=404, detail="Invite not found") + if employee.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Invite belongs to another Telegram account") + if employee.status != "invited": + raise HTTPException(status_code=409, detail="Invite is not active") + if employee.invite_revoked_at is not None: + raise HTTPException(status_code=409, detail="Invite was revoked") + if employee.invite_expires_at: + expires_at = employee.invite_expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + else: + expires_at = None + if expires_at and expires_at <= datetime.now(UTC): + employee.status = "expired" + await log_audit(session, actor=current_user, action="service_employee.invite_expired", target_type="service_employee", target_id=employee.id) + await session.commit() + raise HTTPException(status_code=409, detail="Invite expired") + employee.status = "active" + employee.activated_at = datetime.now(UTC) + employee.invite_token = None + await log_audit( + session, + actor=current_user, + action="service_employee.invite_accept", + target_type="service_employee", + target_id=employee.id, + ) + await session.commit() + await session.refresh(employee) + return employee + + +@router.post("/employees/{employee_id}/revoke-invite", response_model=ServiceEmployeeRead) +async def revoke_employee_invite( + employee_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceEmployee: + employee = await session.get(ServiceEmployee, employee_id) + if employee is None: + raise HTTPException(status_code=404, detail="Employee not found") + await ensure_service_employee(session, employee.service_center_id, current_user, {"owner", "manager"}) + if employee.status != "invited": + raise HTTPException(status_code=409, detail="Only invited employees can be revoked") + employee.status = "revoked" + employee.invite_revoked_at = datetime.now(UTC) + employee.invite_token = None + await log_audit( + session, + actor=current_user, + action="service_employee.invite_revoke", + target_type="service_employee", + target_id=employee.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, @@ -355,6 +441,7 @@ async def create_visit( visit = ServiceVisit( service_center_id=service_center_id, vehicle_id=payload.vehicle_id, + owner_id=vehicle.owner_id, created_by_employee_id=employee.id, visit_date=payload.visit_date, odometer=payload.odometer, @@ -374,9 +461,11 @@ async def create_visit( async def request_vehicle_access( service_center_id: int, payload: VehicleSearchRequest, + request: Request, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> VehicleSearchResult: + await check_rate_limit(scope="vehicle_access_request", limit=20, window_seconds=3600, request=request, user=current_user, session=session) await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"}) await ensure_service_center_approved(session, service_center_id) stmt = select(Car) @@ -610,9 +699,11 @@ async def service_center_reviews( async def create_service_center_review( service_center_id: int, payload: ServiceCenterReviewCreate, + request: Request, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceCenterReview: + await check_rate_limit(scope="service_review", limit=10, window_seconds=3600, request=request, user=current_user, session=session) await ensure_service_center_approved(session, service_center_id) result = await session.execute( select(ServiceCenterReview).where( diff --git a/app/api/sto_booking.py b/app/api/sto_booking.py index 63fd756..d611bc0 100644 --- a/app/api/sto_booking.py +++ b/app/api/sto_booking.py @@ -1,6 +1,6 @@ from datetime import UTC, date, datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -36,6 +36,7 @@ from app.schemas.sto_booking import ( ServiceCenterHolidayRead, STODashboardRead, ) +from app.services.rate_limit import check_rate_limit from app.services.sto_booking import ( calculate_available_slots, create_service_notification, @@ -46,6 +47,7 @@ from app.services.sto_booking import ( money_to_float, notify_service_staff, ) +from app.services.work_orders import add_status_history, assign_work_order_number APPROVED_SERVICE_STATUSES = {"verified", "approved"} @@ -173,9 +175,11 @@ async def get_available_slots( @router.post("/appointments", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED) async def create_appointment( payload: AppointmentCreate, + request: Request, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceAppointment: + await check_rate_limit(scope="appointment_create", limit=20, window_seconds=3600, request=request, user=current_user, session=session) await _approved_service_center(session, payload.service_center_id) vehicle = await _owned_vehicle(session, payload.vehicle_id, current_user) duration = estimate_duration(payload.service_type, payload.estimated_duration_minutes) @@ -253,15 +257,15 @@ async def cancel_appointment( current_user: User = Depends(get_current_telegram_user), ) -> ServiceAppointment: appointment = await _appointment_for_owner(session, appointment_id, current_user) - if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}: + if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}: raise HTTPException(status_code=409, detail="Appointment cannot be cancelled") - appointment.status = "cancelled_by_customer" + appointment.status = "cancelled_by_owner" appointment.cancelled_at = datetime.now(UTC) appointment.cancellation_reason = payload.reason await notify_service_staff( session, service_center_id=appointment.service_center_id, - notification_type="appointment.cancelled_by_customer", + notification_type="appointment.cancelled_by_owner", title="Клиент отменил запись", body=payload.reason, appointment_id=appointment.id, @@ -316,7 +320,7 @@ async def reject_proposed_time( appointment = await _appointment_for_owner(session, appointment_id, current_user) if appointment.status != "proposed_new_time": raise HTTPException(status_code=409, detail="Appointment has no proposed time") - appointment.status = "rejected" + appointment.status = "rejected_by_sto" appointment.service_center_comment = payload.comment await notify_service_staff( session, @@ -365,13 +369,13 @@ async def get_sto_dashboard( confirmed_appointments = int( (await session.execute(select(func.count(ServiceAppointment.id)).where( ServiceAppointment.service_center_id == service_center_id, - ServiceAppointment.status == "confirmed", + ServiceAppointment.status.in_(["confirmed", "confirmed_by_sto"]), ))).scalar_one() or 0 ) active_work_orders = int( (await session.execute(select(func.count(ServiceVisit.id)).where( ServiceVisit.service_center_id == service_center_id, - ServiceVisit.status.in_(["draft", "pending_owner_confirmation"]), + ServiceVisit.status.in_(["draft", "diagnosis", "waiting_owner_approval", "approved_by_owner", "in_progress", "pending_owner_confirmation"]), ))).scalar_one() or 0 ) completed_result = await session.execute( @@ -465,7 +469,7 @@ async def confirm_appointment( duration_minutes=appointment.estimated_duration_minutes, exclude_appointment_id=appointment.id, ) - appointment.status = "confirmed" + appointment.status = "confirmed_by_sto" appointment.confirmed_start_at = appointment.requested_start_at appointment.confirmed_end_at = appointment.requested_end_at appointment.service_center_comment = payload.comment @@ -492,9 +496,9 @@ async def reject_appointment( current_user: User = Depends(get_current_telegram_user), ) -> ServiceAppointment: appointment = await _appointment_for_sto(session, appointment_id, current_user) - if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}: + if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}: raise HTTPException(status_code=409, detail="Appointment cannot be rejected") - appointment.status = "rejected" + appointment.status = "rejected_by_sto" appointment.service_center_comment = payload.comment await create_service_notification( session, @@ -519,7 +523,7 @@ async def propose_appointment_time( current_user: User = Depends(get_current_telegram_user), ) -> ServiceAppointment: appointment = await _appointment_for_sto(session, appointment_id, current_user) - if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}: + if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}: raise HTTPException(status_code=409, detail="Appointment cannot be changed") duration = estimate_duration(appointment.service_type, payload.estimated_duration_minutes or appointment.estimated_duration_minutes) proposed_start = _utc(payload.proposed_start_at) @@ -559,7 +563,7 @@ async def create_work_order_from_appointment( ) -> ServiceVisit: appointment = await _appointment_for_sto(session, appointment_id, current_user) employee = await ensure_service_employee(session, appointment.service_center_id, current_user, {"owner", "manager", "receptionist"}) - if appointment.status != "confirmed": + if appointment.status not in {"confirmed", "confirmed_by_sto"}: raise HTTPException(status_code=409, detail="Only confirmed appointment can become work order") if appointment.linked_work_order_id: visit = await session.get(ServiceVisit, appointment.linked_work_order_id) @@ -569,15 +573,32 @@ async def create_work_order_from_appointment( visit = ServiceVisit( service_center_id=appointment.service_center_id, vehicle_id=appointment.vehicle_id, + owner_id=appointment.owner_id, created_by_employee_id=employee.id, + assigned_employee_id=employee.id, visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(), odometer=payload.odometer, status="draft", + customer_complaint=appointment.customer_comment, notes=payload.notes or appointment.customer_comment, + opened_at=datetime.now(UTC), ) session.add(visit) await session.flush() + await assign_work_order_number(session, visit) + await add_status_history(session, visit, to_status="diagnosis", actor=current_user, comment="Created from appointment") appointment.linked_work_order_id = visit.id + appointment.status = "converted_to_work_order" + await create_service_notification( + session, + recipient_user_id=appointment.owner_id, + service_center_id=appointment.service_center_id, + appointment_id=appointment.id, + notification_type="work_order.created", + title="СТО создало заказ-наряд", + body=visit.work_order_number, + idempotency_key=f"work_order:{visit.id}:created", + ) await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id}) await session.commit() await session.refresh(visit) diff --git a/app/api/users.py b/app/api/users.py index 6f52570..7749928 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -25,6 +25,7 @@ from app.schemas.user import ( UserUpsert, WebAppAuthRequest, ) +from app.services.rate_limit import check_rate_limit from app.services.telegram_auth import verify_login_widget, verify_webapp_init_data router = APIRouter(prefix="/users", tags=["users"]) @@ -56,8 +57,11 @@ async def auth_config() -> AuthConfig: @router.post("/webapp-auth", response_model=UserRead) async def webapp_auth( - payload: WebAppAuthRequest, session: AsyncSession = Depends(get_session) + payload: WebAppAuthRequest, + request: Request, + session: AsyncSession = Depends(get_session), ) -> User: + await check_rate_limit(scope="auth_webapp", limit=30, window_seconds=60, request=request, session=session) user_data = verify_webapp_init_data(payload.init_data, settings.bot_token) telegram_id = int(user_data["id"]) return await get_or_create_telegram_user( @@ -72,8 +76,11 @@ async def webapp_auth( @router.post("/telegram-login", response_model=UserRead) async def telegram_login( - payload: TelegramLoginRequest, session: AsyncSession = Depends(get_session) + payload: TelegramLoginRequest, + request: Request, + session: AsyncSession = Depends(get_session), ) -> User: + await check_rate_limit(scope="auth_login", limit=12, window_seconds=60, request=request, session=session) values = verify_login_widget(payload.model_dump(), settings.bot_token) telegram_id = int(values["id"]) return await get_or_create_telegram_user( diff --git a/app/api/work_orders.py b/app/api/work_orders.py new file mode 100644 index 0000000..afd9984 --- /dev/null +++ b/app/api/work_orders.py @@ -0,0 +1,337 @@ +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +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, + CarServiceLink, + ServiceAppointment, + ServiceProductItem, + ServiceVisit, + ServiceWorkItem, + WorkOrderCorrection, + WorkOrderStatusHistory, +) +from app.models.user import User +from app.schemas.service_center import ( + ServiceProductItemCreate, + ServiceProductItemRead, + ServiceVisitRead, + ServiceWorkItemCreate, + ServiceWorkItemRead, + WorkOrderCorrectionCreate, + WorkOrderCorrectionRead, + WorkOrderDecision, + WorkOrderStatusHistoryRead, + WorkOrderUpdate, +) +from app.services.sto_booking import create_service_notification +from app.services.work_orders import ( + add_labor_item, + add_product_item, + add_status_history, + assign_work_order_number, + close_work_order, + ensure_work_order_editable, + refresh_work_order_totals, +) + +router = APIRouter(prefix="/work-orders", tags=["work-orders"]) + + +async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVisit: + visit = await session.get(ServiceVisit, work_order_id) + if visit is None: + raise HTTPException(status_code=404, detail="Work order not found") + return visit + + +async def ensure_work_order_sto_access( + session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None +) -> None: + await ensure_service_employee(session, visit.service_center_id, user, allowed_roles) + await ensure_work_order_vehicle_scope(session, visit) + + +async def ensure_work_order_owner_access(session: AsyncSession, visit: ServiceVisit, user: User) -> Car: + 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 != user.id: + raise HTTPException(status_code=403, detail="Forbidden") + return vehicle + + +async def ensure_work_order_vehicle_scope(session: AsyncSession, visit: ServiceVisit) -> None: + link = ( + await session.execute( + select(CarServiceLink).where( + CarServiceLink.car_id == visit.vehicle_id, + CarServiceLink.service_center_id == visit.service_center_id, + CarServiceLink.status == "approved", + CarServiceLink.is_active.is_(True), + ) + ) + ).scalar_one_or_none() + if link is not None: + return + appointment = ( + await session.execute( + select(ServiceAppointment).where( + ServiceAppointment.linked_work_order_id == visit.id, + ServiceAppointment.service_center_id == visit.service_center_id, + ServiceAppointment.vehicle_id == visit.vehicle_id, + ServiceAppointment.status.in_(["converted_to_work_order", "completed"]), + ) + ) + ).scalar_one_or_none() + if appointment is None: + raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner") + + +@router.get("/{work_order_id}", response_model=ServiceVisitRead) +async def get_work_order_detail( + work_order_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceVisit: + visit = await get_work_order(session, work_order_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: + return visit + await ensure_work_order_sto_access(session, visit, current_user) + return visit + + +@router.patch("/{work_order_id}", response_model=ServiceVisitRead) +async def update_work_order( + work_order_id: int, + payload: WorkOrderUpdate, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceVisit: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist", "mechanic"}) + await ensure_work_order_editable(visit) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(visit, field, value) + await refresh_work_order_totals(session, visit) + await log_audit(session, actor=current_user, action="work_order.update", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(visit) + return visit + + +@router.post("/{work_order_id}/labor-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED) +async def create_labor_item( + work_order_id: int, + payload: ServiceWorkItemCreate, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceWorkItem: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"}) + item = await add_labor_item(session, visit, payload=payload.model_dump()) + await log_audit(session, actor=current_user, action="work_order.labor_item.create", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(item) + return item + + +@router.post("/{work_order_id}/product-items", response_model=ServiceProductItemRead, status_code=status.HTTP_201_CREATED) +async def create_product_item( + work_order_id: int, + payload: ServiceProductItemCreate, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceProductItem: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"}) + item = await add_product_item(session, visit, payload=payload.model_dump()) + await log_audit(session, actor=current_user, action="work_order.product_item.create", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(item) + return item + + +@router.post("/{work_order_id}/submit-approval", response_model=ServiceVisitRead) +async def submit_work_order_for_approval( + work_order_id: int, + payload: WorkOrderDecision, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceVisit: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist"}) + await ensure_work_order_editable(visit) + await assign_work_order_number(session, visit) + await refresh_work_order_totals(session, visit) + visit.approval_required = True + await add_status_history(session, visit, to_status="waiting_owner_approval", actor=current_user, comment=payload.comment) + vehicle = await session.get(Car, visit.vehicle_id) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + await create_service_notification( + session, + recipient_user_id=vehicle.owner_id, + service_center_id=visit.service_center_id, + notification_type="work_order.waiting_owner_approval", + title="Заказ-наряд ожидает согласования", + body=f"{visit.work_order_number}: {visit.final_total} {visit.currency}", + idempotency_key=f"work_order:{visit.id}:waiting_owner_approval:{visit.final_total}", + ) + await log_audit(session, actor=current_user, action="work_order.submit_approval", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(visit) + return visit + + +@router.post("/{work_order_id}/approve", response_model=ServiceVisitRead) +async def approve_work_order( + work_order_id: int, + payload: WorkOrderDecision, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceVisit: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_owner_access(session, visit, current_user) + if visit.status != "waiting_owner_approval": + raise HTTPException(status_code=409, detail="Work order is not waiting for owner approval") + await refresh_work_order_totals(session, visit) + visit.price_approved_total = visit.final_total + visit.approved_at = datetime.now(UTC) + visit.owner_resolved_at = visit.approved_at + visit.owner_comment = payload.comment + await add_status_history(session, visit, to_status="approved_by_owner", actor=current_user, comment=payload.comment) + await create_service_notification( + session, + recipient_user_id=visit.owner_id or current_user.id, + service_center_id=visit.service_center_id, + notification_type="work_order.approved_by_owner", + title="Заказ-наряд согласован", + body=visit.work_order_number, + send_telegram=False, + idempotency_key=f"work_order:{visit.id}:approved_by_owner", + ) + await log_audit(session, actor=current_user, action="work_order.approve", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(visit) + return visit + + +@router.post("/{work_order_id}/reject", response_model=ServiceVisitRead) +async def reject_work_order( + work_order_id: int, + payload: WorkOrderDecision, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceVisit: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_owner_access(session, visit, current_user) + if visit.status != "waiting_owner_approval": + raise HTTPException(status_code=409, detail="Work order is not waiting for owner approval") + visit.owner_comment = payload.comment + visit.owner_resolved_at = datetime.now(UTC) + await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment) + await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(visit) + return visit + + +@router.post("/{work_order_id}/start", response_model=ServiceVisitRead) +async def start_work_order( + work_order_id: int, + payload: WorkOrderDecision, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceVisit: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"}) + if visit.status not in {"draft", "diagnosis", "approved_by_owner"}: + raise HTTPException(status_code=409, detail="Work order cannot be started") + await add_status_history(session, visit, to_status="in_progress", actor=current_user, comment=payload.comment) + await log_audit(session, actor=current_user, action="work_order.start", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(visit) + return visit + + +@router.post("/{work_order_id}/complete", response_model=ServiceVisitRead) +async def complete_work_order( + work_order_id: int, + payload: WorkOrderDecision, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceVisit: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"}) + await close_work_order( + session, + visit, + actor=current_user, + confirm_lower_odometer=payload.confirm_lower_odometer, + ) + await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id) + await session.commit() + await session.refresh(visit) + return visit + + +@router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED) +async def create_work_order_correction( + work_order_id: int, + payload: WorkOrderCorrectionCreate, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> WorkOrderCorrection: + visit = await get_work_order(session, work_order_id) + await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"}) + if visit.status != "completed": + raise HTTPException(status_code=409, detail="Correction flow is only for completed work orders") + correction = WorkOrderCorrection( + service_visit_id=visit.id, + requested_by_user_id=current_user.id, + reason=payload.reason, + proposed_changes=payload.proposed_changes, + owner_approval_required=payload.owner_approval_required, + created_version=visit.version or 1, + ) + session.add(correction) + await log_audit( + session, + actor=current_user, + action="work_order.correction.create", + target_type="service_visit", + target_id=visit.id, + metadata={"reason": payload.reason}, + ) + await session.commit() + await session.refresh(correction) + return correction + + +@router.get("/{work_order_id}/status-history", response_model=list[WorkOrderStatusHistoryRead]) +async def work_order_status_history( + work_order_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> list[WorkOrderStatusHistory]: + visit = await get_work_order(session, work_order_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: + await ensure_work_order_sto_access(session, visit, current_user) + result = await session.execute( + select(WorkOrderStatusHistory) + .where(WorkOrderStatusHistory.service_visit_id == visit.id) + .order_by(WorkOrderStatusHistory.created_at.asc(), WorkOrderStatusHistory.id.asc()) + ) + return list(result.scalars()) diff --git a/app/core/config.py b/app/core/config.py index ba9a357..97f6073 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -16,11 +16,16 @@ class Settings(BaseSettings): cors_origins: str = "" internal_api_token: str = "" vapid_public_key: str = "" + vapid_private_key: str = "" + secret_key: str = "" + redis_url: str = "" allow_dev_auth: bool = False ocr_provider: str = "tesseract" ocr_languages: str = "eng+rus+kor" llm_base_url: str = "" llm_model: str = "" + admin_telegram_ids: str = "" + admin_bootstrap_token: str = "" model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -36,6 +41,16 @@ class Settings(BaseSettings): def is_production(self) -> bool: return self.app_env.lower() == "production" + @property + def admin_telegram_id_list(self) -> list[int]: + values: list[int] = [] + for item in self.admin_telegram_ids.split(","): + item = item.strip() + if not item: + continue + values.append(int(item)) + return values + def validate_webapp_url_for_telegram(self) -> None: url = self.effective_webapp_url if self.is_production and not url.startswith("https://"): @@ -44,6 +59,25 @@ class Settings(BaseSettings): if self.is_production and any(item in url for item in forbidden): raise RuntimeError("Telegram Mini App URL must not use localhost, internal IP, or http://") + def validate_production_settings(self) -> None: + if not self.is_production: + return + if self.allow_dev_auth: + raise RuntimeError("ALLOW_DEV_AUTH must be false in production") + if not self.bot_token or self.bot_token == "change-me": + raise RuntimeError("BOT_TOKEN is required in production") + if not self.internal_api_token or self.internal_api_token.startswith("change-"): + raise RuntimeError("INTERNAL_API_TOKEN must be a real secret in production") + if not self.secret_key or self.secret_key.startswith("change-"): + raise RuntimeError("SECRET_KEY must be configured in production") + if not self.redis_url: + raise RuntimeError("REDIS_URL is required in production for rate limiting and queues") + if bool(self.vapid_public_key) != bool(self.vapid_private_key): + raise RuntimeError("VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together") + if not self.cors_origin_list: + raise RuntimeError("CORS_ORIGINS is required in production") + self.validate_webapp_url_for_telegram() + @lru_cache def get_settings() -> Settings: diff --git a/app/main.py b/app/main.py index ddd1458..bacb239 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,12 @@ -from fastapi import FastAPI +from contextlib import asynccontextmanager +from time import monotonic +from uuid import uuid4 + +from fastapi import Depends, FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession from app.api import ( admin, @@ -16,10 +22,54 @@ from app.api import ( service_visits, sto_booking, users, + work_orders, ) from app.core.config import settings +from app.db.session import get_session +from app.services.rate_limit import get_redis_client -app = FastAPI(title="Drivers Bot API", version="0.1.0") + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings.validate_production_settings() + yield + + +app = FastAPI(title="Drivers Bot API", version="0.1.0", lifespan=lifespan) + +REQUEST_COUNT = 0 +REQUEST_ERRORS = 0 +REQUEST_DURATION_TOTAL = 0.0 + + +@app.middleware("http") +async def production_headers_and_metrics(request: Request, call_next): + global REQUEST_COUNT, REQUEST_DURATION_TOTAL, REQUEST_ERRORS + request_id = request.headers.get("X-Request-ID") or str(uuid4()) + start = monotonic() + try: + response = await call_next(request) + except Exception: + REQUEST_ERRORS += 1 + raise + duration = monotonic() - start + REQUEST_COUNT += 1 + REQUEST_DURATION_TOTAL += duration + response.headers["X-Request-ID"] = request_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["X-Frame-Options"] = "DENY" + if settings.is_production: + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Content-Security-Policy"] = ( + "default-src 'self' https://telegram.org https://*.telegram.org; " + "connect-src 'self' https://api.telegram.org; " + "img-src 'self' data: https:; " + "script-src 'self' 'unsafe-inline' https://telegram.org https://*.telegram.org; " + "style-src 'self' 'unsafe-inline'; " + "frame-ancestors 'none'" + ) + return response dev_origins = ["http://localhost:8000", "http://127.0.0.1:8000"] if not settings.is_production else [] cors_origins = settings.cors_origin_list or dev_origins @@ -43,6 +93,7 @@ app.include_router(parser.router, prefix="/api") app.include_router(service_centers.router, prefix="/api") app.include_router(sto_booking.router, prefix="/api") app.include_router(service_visits.router, prefix="/api") +app.include_router(work_orders.router, prefix="/api") app.include_router(change_requests.router, prefix="/api") app.include_router(admin.router, prefix="/api") @@ -52,4 +103,41 @@ async def health() -> dict[str, str]: return {"status": "ok"} +@app.get("/metrics") +async def metrics() -> Response: + avg = REQUEST_DURATION_TOTAL / REQUEST_COUNT if REQUEST_COUNT else 0 + body = "\n".join( + [ + "# TYPE carpass_requests_total counter", + f"carpass_requests_total {REQUEST_COUNT}", + "# TYPE carpass_request_errors_total counter", + f"carpass_request_errors_total {REQUEST_ERRORS}", + "# TYPE carpass_request_duration_seconds_avg gauge", + f"carpass_request_duration_seconds_avg {avg:.6f}", + "", + ] + ) + return Response(body, media_type="text/plain; version=0.0.4") + + +@app.get("/ready") +async def ready(session: AsyncSession = Depends(get_session)) -> dict[str, str]: + await session.execute(text("select 1")) + migration = "unknown" + try: + version = await session.execute(text("select version_num from alembic_version limit 1")) + migration = version.scalar_one_or_none() or "unknown" + except Exception: + migration = "not_checked" + redis_status = "disabled" + if settings.redis_url: + redis = await get_redis_client() + if redis is None: + redis_status = "client_missing" + else: + await redis.ping() + redis_status = "ok" + return {"status": "ready", "database": "ok", "redis": redis_status, "migration": migration} + + app.mount("/", StaticFiles(directory="web", html=True), name="web") diff --git a/app/models/car.py b/app/models/car.py index a2c5707..10d5bf0 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -264,6 +264,10 @@ class ServiceEmployee(Base): 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) + invite_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True) + invite_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + invite_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) service_center = relationship("ServiceCenter", back_populates="employees") @@ -311,15 +315,35 @@ class ServiceVisit(Base): __tablename__ = "service_visits" id: Mapped[int] = mapped_column(primary_key=True) + work_order_number: Mapped[str | None] = mapped_column(String(40), unique=True, index=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) + owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) created_by_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True) + assigned_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) + customer_complaint: Mapped[str | None] = mapped_column(Text) + diagnosis: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text) + service_comment: Mapped[str | None] = mapped_column(Text) + owner_comment: Mapped[str | None] = mapped_column(Text) + recommendations_text: Mapped[str | None] = mapped_column(Text) + attachment_urls: Mapped[list | None] = mapped_column(JSON) total_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + labor_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + product_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + discount_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + final_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + price_approved_total: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + approval_required: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + version: Mapped[int] = mapped_column(Integer, default=1, server_default="1") + completed_snapshot: Mapped[dict | None] = mapped_column(JSON) currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB") + opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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( @@ -328,6 +352,9 @@ class ServiceVisit(Base): service_center = relationship("ServiceCenter", back_populates="visits") work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan") + product_items = relationship("ServiceProductItem", back_populates="visit", cascade="all, delete-orphan") + status_history = relationship("WorkOrderStatusHistory", back_populates="visit", cascade="all, delete-orphan") + corrections = relationship("WorkOrderCorrection", back_populates="visit", cascade="all, delete-orphan") class MaintenanceRecommendation(Base): @@ -395,7 +422,12 @@ class ServiceNotification(Base): notification_type: Mapped[str] = mapped_column(String(80), index=True) title: Mapped[str] = mapped_column(String(180)) body: Mapped[str | None] = mapped_column(Text) - status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True) + status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True) + retry_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + last_error: Mapped[str | None] = mapped_column(Text) + idempotency_key: Mapped[str | None] = mapped_column(String(160), unique=True, index=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) @@ -406,7 +438,13 @@ class ServiceWorkItem(Base): 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)) + category: Mapped[str | None] = mapped_column(String(80)) description: Mapped[str | None] = mapped_column(Text) + quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=1, server_default="1") + unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs") + unit_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + discount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + total: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) 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)) @@ -414,11 +452,85 @@ class ServiceWorkItem(Base): next_due_odometer: Mapped[int | None] next_due_date: Mapped[date | None] = mapped_column(Date) price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + warranty_days: Mapped[int | None] = mapped_column(Integer) + warranty_odometer_km: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) visit = relationship("ServiceVisit", back_populates="work_items") +class ServiceProductItem(Base): + __tablename__ = "service_product_items" + + id: Mapped[int] = mapped_column(primary_key=True) + service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True) + title: Mapped[str] = mapped_column(String(180)) + category: Mapped[str | None] = mapped_column(String(80), index=True) + product_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True) + brand: Mapped[str | None] = mapped_column(String(80)) + sku: Mapped[str | None] = mapped_column(String(120)) + quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=1, server_default="1") + unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs") + unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + discount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0") + volume: Mapped[Decimal | None] = mapped_column(Numeric(8, 3)) + viscosity: Mapped[str | None] = mapped_column(String(40)) + specification: Mapped[str | None] = mapped_column(String(120)) + used_volume: Mapped[Decimal | None] = mapped_column(Numeric(8, 3)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + visit = relationship("ServiceVisit", back_populates="product_items") + + +class WorkOrderStatusHistory(Base): + __tablename__ = "work_order_status_history" + + id: Mapped[int] = mapped_column(primary_key=True) + service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True) + from_status: Mapped[str | None] = mapped_column(String(40)) + to_status: Mapped[str] = mapped_column(String(40), index=True) + changed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) + comment: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + visit = relationship("ServiceVisit", back_populates="status_history") + + +class WorkOrderCorrection(Base): + __tablename__ = "work_order_corrections" + + id: Mapped[int] = mapped_column(primary_key=True) + service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True) + requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) + reason: Mapped[str] = mapped_column(Text) + proposed_changes: Mapped[dict | None] = mapped_column(JSON) + status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True) + owner_approval_required: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + created_version: Mapped[int] = mapped_column(Integer, default=1, server_default="1") + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + visit = relationship("ServiceVisit", back_populates="corrections") + + +class InventoryTransaction(Base): + __tablename__ = "inventory_transactions" + + id: Mapped[int] = mapped_column(primary_key=True) + service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True) + service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True) + product_item_id: Mapped[int | None] = mapped_column(ForeignKey("service_product_items.id", ondelete="SET NULL"), index=True) + transaction_type: Mapped[str] = mapped_column(String(32), index=True) + sku: Mapped[str | None] = mapped_column(String(120), index=True) + title: Mapped[str | None] = mapped_column(String(180)) + quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=0, server_default="0") + unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs") + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + class ServiceCenterReview(Base): __tablename__ = "service_center_reviews" __table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),) diff --git a/app/models/expense.py b/app/models/expense.py index 732009f..d18c3dc 100644 --- a/app/models/expense.py +++ b/app/models/expense.py @@ -66,6 +66,7 @@ class ServiceEntry(Base): id: Mapped[int] = mapped_column(primary_key=True) car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True) + service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True) entry_date: Mapped[date] = mapped_column(Date, index=True) odometer: Mapped[int | None] service_type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True) @@ -86,6 +87,7 @@ class ExpenseEntry(Base): id: Mapped[int] = mapped_column(primary_key=True) car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True) + service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True) entry_date: Mapped[date] = mapped_column(Date, index=True) category: Mapped[ExpenseCategory] = mapped_column(Enum(ExpenseCategory), index=True) title: Mapped[str] = mapped_column(String(180)) diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py index ebdf2b5..3fe90d0 100644 --- a/app/schemas/service_center.py +++ b/app/schemas/service_center.py @@ -30,6 +30,8 @@ class ServiceCenterRead(ServiceCenterCreate): name: str verification_status: str owner_user_id: int | None = None + employee_role: str | None = None + employee_status: str | None = None created_at: datetime verified_at: datetime | None = None suspended_at: datetime | None = None @@ -78,6 +80,7 @@ class ServiceEmployeeInvite(BaseModel): telegram_id: int role: str = "receptionist" permissions: dict | None = None + expires_in_hours: int = Field(default=72, ge=0, le=720) class ServiceEmployeeRead(BaseModel): @@ -87,6 +90,10 @@ class ServiceEmployeeRead(BaseModel): role: str permissions: dict | None = None status: str + invite_token: str | None = None + invite_expires_at: datetime | None = None + invite_revoked_at: datetime | None = None + activated_at: datetime | None = None created_at: datetime model_config = ConfigDict(from_attributes=True) @@ -284,8 +291,27 @@ class ServiceVisitCreate(BaseModel): class ServiceVisitRead(ServiceVisitCreate): id: int service_center_id: int + work_order_number: str | None = None + owner_id: int | None = None created_by_employee_id: int | None = None + assigned_employee_id: int | None = None status: str + customer_complaint: str | None = None + diagnosis: str | None = None + service_comment: str | None = None + owner_comment: str | None = None + recommendations_text: str | None = None + attachment_urls: list[str] | None = None + labor_total: Decimal = Decimal("0") + product_total: Decimal = Decimal("0") + discount_total: Decimal = Decimal("0") + final_total: Decimal = Decimal("0") + approval_required: bool = False + version: int = 1 + completed_snapshot: dict | None = None + opened_at: datetime | None = None + approved_at: datetime | None = None + completed_at: datetime | None = None owner_resolved_at: datetime | None = None created_at: datetime @@ -295,7 +321,12 @@ class ServiceVisitRead(ServiceVisitCreate): class ServiceWorkItemCreate(BaseModel): work_type: str = "other" title: str + category: str | None = None description: str | None = None + quantity: Decimal = Decimal("1") + unit: str = "pcs" + unit_price: Decimal | None = None + discount: Decimal = Decimal("0") parts: list[dict] | None = None oil_brand: str | None = None oil_viscosity: str | None = None @@ -303,11 +334,108 @@ class ServiceWorkItemCreate(BaseModel): next_due_odometer: int | None = None next_due_date: date | None = None price: Decimal | None = None + warranty_days: int | None = None + warranty_odometer_km: int | None = None + + @model_validator(mode="after") + def validate_item(self) -> "ServiceWorkItemCreate": + if self.quantity <= 0: + raise ValueError("quantity must be positive") + if self.discount < 0: + raise ValueError("discount must be non-negative") + if self.unit_price is not None and self.unit_price < 0: + raise ValueError("unit_price must be non-negative") + if self.price is not None and self.price < 0: + raise ValueError("price must be non-negative") + return self class ServiceWorkItemRead(ServiceWorkItemCreate): id: int service_visit_id: int + total: Decimal | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ServiceProductItemCreate(BaseModel): + title: str + category: str | None = None + product_type: str = "other" + brand: str | None = None + sku: str | None = None + quantity: Decimal = Decimal("1") + unit: str = "pcs" + unit_price: Decimal = Decimal("0") + discount: Decimal = Decimal("0") + volume: Decimal | None = None + viscosity: str | None = None + specification: str | None = None + used_volume: Decimal | None = None + + @model_validator(mode="after") + def validate_product(self) -> "ServiceProductItemCreate": + if self.quantity <= 0: + raise ValueError("quantity must be positive") + if self.unit_price < 0 or self.discount < 0: + raise ValueError("price and discount must be non-negative") + return self + + +class ServiceProductItemRead(ServiceProductItemCreate): + id: int + service_visit_id: int + total: Decimal + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkOrderUpdate(BaseModel): + odometer: int | None = None + assigned_employee_id: int | None = None + customer_complaint: str | None = None + diagnosis: str | None = None + notes: str | None = None + service_comment: str | None = None + owner_comment: str | None = None + recommendations_text: str | None = None + attachment_urls: list[str] | None = None + discount_total: Decimal | None = None + approval_required: bool | None = None + + +class WorkOrderDecision(BaseModel): + comment: str | None = None + confirm_lower_odometer: bool = False + + +class WorkOrderStatusHistoryRead(BaseModel): + id: int + service_visit_id: int + from_status: str | None = None + to_status: str + changed_by_user_id: int | None = None + comment: str | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkOrderCorrectionCreate(BaseModel): + reason: str = Field(min_length=3, max_length=4000) + proposed_changes: dict | None = None + owner_approval_required: bool = True + + +class WorkOrderCorrectionRead(WorkOrderCorrectionCreate): + id: int + service_visit_id: int + requested_by_user_id: int | None = None + status: str + created_version: int + resolved_at: datetime | None = None created_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/app/schemas/sto_booking.py b/app/schemas/sto_booking.py index cc71d05..2b1950a 100644 --- a/app/schemas/sto_booking.py +++ b/app/schemas/sto_booking.py @@ -6,10 +6,14 @@ APPOINTMENT_STATUSES = { "draft", "requested", "confirmed", + "confirmed_by_sto", "proposed_new_time", "rejected", + "rejected_by_sto", + "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto", + "converted_to_work_order", "completed", "no_show", } diff --git a/app/services/calculations.py b/app/services/calculations.py index 179768a..81b215a 100644 --- a/app/services/calculations.py +++ b/app/services/calculations.py @@ -260,6 +260,7 @@ async def expense_period_totals( .where( ExpenseEntry.car_id == car_id, ExpenseEntry.entry_date <= date_to, + ExpenseEntry.service_visit_id.is_(None), ) .order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc()) ) diff --git a/app/services/notifications.py b/app/services/notifications.py index 1d1caec..f483fa3 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -1,27 +1,70 @@ +from datetime import UTC, datetime, timedelta + import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings +from app.models.car import ServiceNotification from app.models.user import User MODERATOR_ROLES = {"admin", "verifier", "moderator"} -async def notify_user(user: User, text: str) -> None: +async def notify_user(user: User, text: str) -> bool: if not settings.bot_token or settings.app_env == "test": - return + return False try: async with httpx.AsyncClient(timeout=5) as client: - await client.post( + response = await client.post( f"https://api.telegram.org/bot{settings.bot_token}/sendMessage", data={"chat_id": str(user.telegram_id), "text": text}, ) + return response.status_code < 400 except Exception: - return + return False async def notify_platform_moderators(session: AsyncSession, text: str) -> None: result = await session.execute(select(User).where(User.platform_role.in_(MODERATOR_ROLES))) for user in result.scalars(): await notify_user(user, text) + + +async def retry_failed_notifications(session: AsyncSession, *, limit: int = 50) -> int: + return await process_notification_queue(session, limit=limit) + + +async def process_notification_queue(session: AsyncSession, *, limit: int = 50) -> int: + now = datetime.now(UTC) + result = await session.execute( + select(ServiceNotification) + .where( + ServiceNotification.status.in_(["pending", "failed", "retrying"]), + ServiceNotification.retry_count < 5, + ) + .order_by(ServiceNotification.created_at.asc()) + .limit(limit) + ) + delivered = 0 + for notification in result.scalars(): + if notification.status == "retrying" and notification.created_at > now - timedelta(seconds=30): + continue + notification.status = "processing" + user = await session.get(User, notification.recipient_user_id) + if user is None: + notification.status = "abandoned" + notification.last_error = "recipient_not_found" + continue + ok = await notify_user(user, f"{notification.title}\n{notification.body}" if notification.body else notification.title) + notification.retry_count += 1 + if ok: + notification.status = "sent" + notification.sent_at = datetime.now(UTC) + notification.last_error = None + delivered += 1 + else: + notification.status = "abandoned" if notification.retry_count >= 5 else "retrying" + notification.last_error = "telegram_delivery_failed" + await session.commit() + return delivered diff --git a/app/services/ocr_provider.py b/app/services/ocr_provider.py index 930caad..6ea3796 100644 --- a/app/services/ocr_provider.py +++ b/app/services/ocr_provider.py @@ -50,17 +50,35 @@ class TesseractOCRProvider: def _recognize_sync(self, content: bytes) -> str: try: import pytesseract - from PIL import Image + from PIL import Image, ImageEnhance, ImageOps except ImportError: return "" try: image = Image.open(BytesIO(content)) except Exception: return "" + candidates = [image] try: - return pytesseract.image_to_string(image, lang=settings.ocr_languages) + grayscale = ImageOps.grayscale(image) + resized = grayscale.resize((grayscale.width * 2, grayscale.height * 2)) + contrast = ImageEnhance.Contrast(resized).enhance(1.8) + threshold = contrast.point(lambda pixel: 255 if pixel > 165 else 0) + candidates.extend([grayscale, contrast, threshold]) except Exception: - return pytesseract.image_to_string(image) + candidates = [image] + recognized: list[str] = [] + for candidate in candidates: + for config in ("--psm 6", "--psm 11"): + try: + text = pytesseract.image_to_string(candidate, lang=settings.ocr_languages, config=config) + except Exception: + try: + text = pytesseract.image_to_string(candidate, config=config) + except Exception: + text = "" + if text.strip(): + recognized.append(text) + return "\n".join(recognized) class CompositeOCRProvider: diff --git a/app/services/rate_limit.py b/app/services/rate_limit.py new file mode 100644 index 0000000..eb02fec --- /dev/null +++ b/app/services/rate_limit.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import time +from collections import defaultdict, deque +from collections.abc import Hashable + +from fastapi import HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.user import User + +BucketKey = tuple[str, Hashable] + +_buckets: dict[BucketKey, deque[float]] = defaultdict(deque) +_redis_client = None + + +def reset_rate_limit_state() -> None: + _buckets.clear() + + +async def check_rate_limit( + *, + scope: str, + limit: int, + window_seconds: int, + request: Request | None = None, + user: User | None = None, + session: AsyncSession | None = None, +) -> None: + identifiers: list[Hashable] = [] + if user is not None: + identifiers.append(f"user:{user.id}") + identifiers.append(f"telegram:{user.telegram_id}") + if request is not None and request.client is not None: + identifiers.append(f"ip:{request.client.host}") + if not identifiers: + identifiers.append("anonymous") + + if settings.redis_url: + allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds) + if not allowed: + await log_rate_limit_event(session, scope=scope, identifier="redis") + raise_rate_limit(scope, window_seconds) + return + + now = time.monotonic() + for identifier in identifiers: + key = (scope, identifier) + bucket = _buckets[key] + while bucket and now - bucket[0] > window_seconds: + bucket.popleft() + if len(bucket) >= limit: + await log_rate_limit_event(session, scope=scope, identifier=str(identifier)) + raise_rate_limit(scope, window_seconds) + for identifier in identifiers: + _buckets[(scope, identifier)].append(now) + + +def raise_rate_limit(scope: str, window_seconds: int) -> None: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "code": "rate_limit_exceeded", + "message": "Слишком много запросов. Попробуйте чуть позже.", + "scope": scope, + "retry_after_seconds": window_seconds, + }, + ) + + +async def get_redis_client(): + global _redis_client + if _redis_client is not None: + return _redis_client + try: + from redis.asyncio import Redis + except ImportError: + return None + _redis_client = Redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=True) + return _redis_client + + +async def check_redis_rate_limit( + scope: str, + identifiers: list[Hashable], + limit: int, + window_seconds: int, +) -> bool: + client = await get_redis_client() + if client is None: + return True + now_window = int(time.time() // window_seconds) + keys = [f"rl:{scope}:{identifier}:{now_window}" for identifier in identifiers] + pipe = client.pipeline() + for key in keys: + pipe.incr(key) + pipe.expire(key, window_seconds * 2) + results = await pipe.execute() + counts = [int(results[index]) for index in range(0, len(results), 2)] + return all(count <= limit for count in counts) + + +async def log_rate_limit_event( + session: AsyncSession | None, + *, + scope: str, + identifier: str, +) -> None: + if session is None: + return + from app.models.car import AuditLog + + session.add( + AuditLog( + actor_user_id=None, + actor_role="system", + action="rate_limit.exceeded", + target_type=scope, + target_id=identifier[:80], + metadata_json={"scope": scope, "identifier": identifier}, + ) + ) diff --git a/app/services/sto_booking.py b/app/services/sto_booking.py index 68e47c7..bdd9941 100644 --- a/app/services/sto_booking.py +++ b/app/services/sto_booking.py @@ -21,7 +21,7 @@ from app.models.expense import ServiceEntry from app.models.user import User from app.services.notifications import notify_user -ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "proposed_new_time"} +ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "confirmed_by_sto", "proposed_new_time"} DEFAULT_SERVICE_DURATIONS = { "oil_change": 60, "diagnostics": 60, @@ -190,7 +190,16 @@ async def create_service_notification( service_center_id: int | None = None, appointment_id: int | None = None, send_telegram: bool = True, + idempotency_key: str | None = None, ) -> ServiceNotification: + if idempotency_key: + existing = ( + await session.execute( + select(ServiceNotification).where(ServiceNotification.idempotency_key == idempotency_key) + ) + ).scalar_one_or_none() + if existing is not None: + return existing notification = ServiceNotification( recipient_user_id=recipient_user_id, service_center_id=service_center_id, @@ -198,12 +207,21 @@ async def create_service_notification( notification_type=notification_type, title=title, body=body, + idempotency_key=idempotency_key, ) session.add(notification) if send_telegram: user = await session.get(User, recipient_user_id) if user is not None: - await notify_user(user, f"{title}\n{body}" if body else title) + notification.status = "processing" + delivered = await notify_user(user, f"{title}\n{body}" if body else title) + if delivered: + notification.status = "sent" + notification.sent_at = datetime.now(UTC) + else: + notification.status = "retrying" + notification.retry_count = 1 + notification.last_error = "telegram_delivery_failed" return notification diff --git a/app/services/uploads.py b/app/services/uploads.py new file mode 100644 index 0000000..f53b3dd --- /dev/null +++ b/app/services/uploads.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import mimetypes +from pathlib import PurePath + +from fastapi import HTTPException + +SAFE_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"} +SAFE_TEXT_TYPES = {"text/plain", "application/pdf"} +BLOCKED_EXTENSIONS = {".exe", ".bat", ".cmd", ".sh", ".php", ".js", ".html", ".svg"} + + +def sanitize_filename(filename: str | None) -> str: + name = PurePath(filename or "upload.bin").name + return "".join(char if char.isalnum() or char in {".", "-", "_"} else "_" for char in name)[:160] + + +def validate_upload( + *, + content: bytes, + filename: str | None, + content_type: str | None, + max_bytes: int, + allowed_types: set[str], +) -> str: + safe_name = sanitize_filename(filename) + suffix = PurePath(safe_name).suffix.lower() + if len(content) > max_bytes: + raise HTTPException(status_code=413, detail="File is too large") + if suffix in BLOCKED_EXTENSIONS: + raise HTTPException(status_code=415, detail="Executable or unsafe file type is not allowed") + detected_type = (content_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream").lower() + if detected_type not in allowed_types: + raise HTTPException(status_code=415, detail="Unsupported file type") + if detected_type in SAFE_IMAGE_TYPES: + validate_image(content) + return safe_name + + +def validate_image(content: bytes) -> None: + try: + from PIL import Image + except ImportError: + return + try: + with Image.open(__import__("io").BytesIO(content)) as image: + width, height = image.size + if width * height > 24_000_000: + raise HTTPException(status_code=413, detail="Image dimensions are too large") + image.verify() + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=415, detail="Corrupted image file") from exc diff --git a/app/services/work_orders.py b/app/services/work_orders.py new file mode 100644 index 0000000..962bce7 --- /dev/null +++ b/app/services/work_orders.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.car import ( + Car, + InventoryTransaction, + MaintenanceRecommendation, + ServiceAppointment, + ServiceCenter, + ServiceProductItem, + ServiceVisit, + ServiceWorkItem, + WorkOrderStatusHistory, +) +from app.models.expense import ExpenseCategory, ExpenseEntry, ServiceEntry, ServiceType +from app.models.user import User +from app.services.odometer import apply_odometer_from_record, validate_odometer_change +from app.services.sto_booking import create_service_notification + +WORK_ORDER_STATUSES = { + "draft", + "diagnosis", + "waiting_owner_approval", + "approved_by_owner", + "rejected_by_owner", + "in_progress", + "completed", + "cancelled", + "archived", +} +LOCKED_WORK_ORDER_STATUSES = {"completed", "cancelled", "archived"} + + +def money(value: Decimal | int | float | None) -> Decimal: + return Decimal(str(value or 0)).quantize(Decimal("0.01")) + + +def line_total(quantity: Decimal, unit_price: Decimal | None, discount: Decimal) -> Decimal: + return max(Decimal("0"), Decimal(quantity) * money(unit_price) - money(discount)).quantize(Decimal("0.01")) + + +async def add_status_history( + session: AsyncSession, + visit: ServiceVisit, + *, + to_status: str, + actor: User | None, + comment: str | None = None, +) -> None: + if to_status not in WORK_ORDER_STATUSES and to_status not in {"pending_owner_confirmation", "confirmed", "disputed"}: + raise HTTPException(status_code=400, detail="Unsupported work order status") + from_status = visit.status + if from_status == to_status: + return + visit.status = to_status + session.add( + WorkOrderStatusHistory( + service_visit_id=visit.id, + from_status=from_status, + to_status=to_status, + changed_by_user_id=actor.id if actor else None, + comment=comment, + ) + ) + + +async def ensure_work_order_editable(visit: ServiceVisit) -> None: + if visit.status in LOCKED_WORK_ORDER_STATUSES: + raise HTTPException(status_code=409, detail="Completed or archived work order cannot be changed") + + +async def refresh_work_order_totals(session: AsyncSession, visit: ServiceVisit) -> None: + work_items = list( + ( + await session.execute( + select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id) + ) + ).scalars() + ) + product_items = list( + ( + await session.execute( + select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id) + ) + ).scalars() + ) + labor_total = sum((money(item.total if item.total is not None else item.price) for item in work_items), Decimal("0")) + product_total = sum((money(item.total) for item in product_items), Decimal("0")) + discount_total = money(visit.discount_total) + final_total = max(Decimal("0"), labor_total + product_total - discount_total).quantize(Decimal("0.01")) + visit.labor_total = labor_total.quantize(Decimal("0.01")) + visit.product_total = product_total.quantize(Decimal("0.01")) + visit.final_total = final_total + visit.total_cost = final_total + if visit.status == "approved_by_owner" and visit.price_approved_total is not None and final_total != visit.price_approved_total: + visit.status = "waiting_owner_approval" + visit.approved_at = None + + +async def assign_work_order_number(session: AsyncSession, visit: ServiceVisit) -> None: + if visit.work_order_number: + return + await session.flush() + visit.work_order_number = f"WO-{date.today():%Y%m%d}-{visit.id:06d}" + + +async def add_labor_item( + session: AsyncSession, + visit: ServiceVisit, + *, + payload: dict, +) -> ServiceWorkItem: + await ensure_work_order_editable(visit) + quantity = Decimal(str(payload.get("quantity") or 1)) + unit_price = payload.get("unit_price") + legacy_price = payload.get("price") + total = line_total(quantity, money(unit_price if unit_price is not None else legacy_price), Decimal(str(payload.get("discount") or 0))) + item = ServiceWorkItem(**payload, service_visit_id=visit.id, total=total) + if item.price is None: + item.price = total + session.add(item) + await session.flush() + await refresh_work_order_totals(session, visit) + return item + + +async def add_product_item( + session: AsyncSession, + visit: ServiceVisit, + *, + payload: dict, +) -> ServiceProductItem: + await ensure_work_order_editable(visit) + quantity = Decimal(str(payload.get("quantity") or 1)) + unit_price = Decimal(str(payload.get("unit_price") or 0)) + discount = Decimal(str(payload.get("discount") or 0)) + item = ServiceProductItem(**payload, service_visit_id=visit.id, total=line_total(quantity, unit_price, discount)) + session.add(item) + await session.flush() + await refresh_work_order_totals(session, visit) + return item + + +async def close_work_order( + session: AsyncSession, + visit: ServiceVisit, + *, + actor: User, + confirm_lower_odometer: bool = False, +) -> tuple[ServiceEntry, ExpenseEntry]: + if visit.status == "completed": + service = ( + await session.execute(select(ServiceEntry).where(ServiceEntry.service_visit_id == visit.id)) + ).scalar_one_or_none() + expense = ( + await session.execute(select(ExpenseEntry).where(ExpenseEntry.service_visit_id == visit.id)) + ).scalar_one_or_none() + if service is not None and expense is not None: + return service, expense + raise HTTPException(status_code=409, detail="Completed work order is missing immutable records") + if visit.status not in {"approved_by_owner", "in_progress", "diagnosis", "draft"}: + raise HTTPException(status_code=409, detail="Work order must be approved or in progress before completion") + if visit.approval_required and visit.status != "approved_by_owner": + raise HTTPException(status_code=409, detail="Owner approval is required before completion") + vehicle = await session.get(Car, visit.vehicle_id) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + owner = await session.get(User, visit.owner_id or vehicle.owner_id) + if owner is None: + raise HTTPException(status_code=404, detail="Vehicle owner not found") + validate_odometer_change( + vehicle, + visit.odometer, + source_record_type="work_order", + confirm_lower_odometer=confirm_lower_odometer, + ) + await refresh_work_order_totals(session, visit) + existing_service = ( + await session.execute(select(ServiceEntry).where(ServiceEntry.service_visit_id == visit.id)) + ).scalar_one_or_none() + existing_expense = ( + await session.execute(select(ExpenseEntry).where(ExpenseEntry.service_visit_id == visit.id)) + ).scalar_one_or_none() + if existing_service is not None or existing_expense is not None: + raise HTTPException(status_code=409, detail="Work order completion records already exist") + center = await session.get(ServiceCenter, visit.service_center_id) + vendor_name = center.display_name or center.name if center else None + service = ServiceEntry( + car_id=vehicle.id, + service_visit_id=visit.id, + entry_date=visit.visit_date, + odometer=visit.odometer, + service_type=ServiceType.maintenance, + title=f"Заказ-наряд {visit.work_order_number or visit.id}", + category="sto_work_order", + vendor=vendor_name, + total_cost=visit.final_total, + notes=visit.service_comment or visit.notes, + ) + expense = ExpenseEntry( + car_id=vehicle.id, + service_visit_id=visit.id, + entry_date=visit.visit_date, + category=ExpenseCategory.maintenance, + title=f"СТО: заказ-наряд {visit.work_order_number or visit.id}", + vendor=vendor_name, + total_cost=max(visit.final_total, Decimal("0.01")), + currency=visit.currency, + odometer=visit.odometer, + metadata_json={ + "service_visit_id": visit.id, + "work_order_number": visit.work_order_number, + "labor_total": str(visit.labor_total), + "product_total": str(visit.product_total), + }, + ) + session.add_all([service, expense]) + await session.flush() + await apply_odometer_from_record( + session, + vehicle, + new_odometer=visit.odometer, + source_record_type="work_order", + source_record_id=visit.id, + changed_by=actor.id, + confirm_lower_odometer=confirm_lower_odometer, + ) + visit.completed_at = datetime.now(UTC) + await add_status_history(session, visit, to_status="completed", actor=actor, comment="Work order completed") + appointment = ( + await session.execute( + select(ServiceAppointment).where(ServiceAppointment.linked_work_order_id == visit.id) + ) + ).scalar_one_or_none() + if appointment is not None: + appointment.status = "completed" + if appointment and appointment.source_recommendation_id: + recommendation = await session.get(MaintenanceRecommendation, appointment.source_recommendation_id) + if recommendation is not None: + recommendation.status = "completed" + work_items = list( + ( + await session.execute( + select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id) + ) + ).scalars() + ) + product_items = list( + ( + await session.execute( + select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id) + ) + ).scalars() + ) + for product in product_items: + session.add( + InventoryTransaction( + service_center_id=visit.service_center_id, + service_visit_id=visit.id, + product_item_id=product.id, + transaction_type="consume", + sku=product.sku, + title=product.title, + quantity=product.quantity, + unit=product.unit, + actor_user_id=actor.id, + metadata_json={"source": "work_order_completion"}, + ) + ) + for item in work_items: + if item.next_due_date or item.next_due_odometer: + session.add( + MaintenanceRecommendation( + vehicle_id=vehicle.id, + recommendation_type=item.work_type or "maintenance", + title=f"Следующее ТО: {item.title}", + due_odometer_km=item.next_due_odometer, + due_date=item.next_due_date, + priority="medium", + status="active", + source="work_order", + source_service_center_id=visit.service_center_id, + source_appointment_id=appointment.id if appointment else None, + ) + ) + visit.version = (visit.version or 1) + 1 + visit.completed_snapshot = { + "work_order_number": visit.work_order_number, + "vehicle_id": vehicle.id, + "service_center_id": visit.service_center_id, + "odometer": visit.odometer, + "labor_total": str(visit.labor_total), + "product_total": str(visit.product_total), + "discount_total": str(visit.discount_total), + "final_total": str(visit.final_total), + "currency": visit.currency, + "completed_at": visit.completed_at.isoformat() if visit.completed_at else None, + } + await create_service_notification( + session, + recipient_user_id=owner.id, + service_center_id=visit.service_center_id, + appointment_id=appointment.id if appointment else None, + notification_type="work_order.completed", + title="Работа по заказ-наряду завершена", + body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}. Можно оставить отзыв.", + idempotency_key=f"work_order:{visit.id}:completed", + ) + return service, expense diff --git a/bot/main.py b/bot/main.py index 06d93f2..6548afe 100644 --- a/bot/main.py +++ b/bot/main.py @@ -521,8 +521,9 @@ async def help_message(message: Message) -> None: "• /appointments — мои записи в СТО;\n" "• /sto_bookings — заявки и календарь для владельца СТО;\n" "• /register_sto — заявка на СТО.\n\n" - "Для ТО: в карточке авто Mini App показывает рекомендации, доступные СТО, свободные окна, запись и согласование времени.\n" - "Для СТО: настрой график, принимай заявки, создавай заказ-наряд из подтвержденной записи и отправляй клиенту результат работ.\n\n" + "Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n" + "СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n" + "Безопасность: СТО видит автомобиль только после подтверждения владельца, а спорные изменения VIN, номера и пробега идут через согласование.\n\n" "Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.", reply_markup=menu_inline_keyboard(), ) diff --git a/docker-compose.yml b/docker-compose.yml index 0696e1f..f0e4cc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: db: image: postgres:16-alpine + restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-drivers} POSTGRES_USER: ${POSTGRES_USER:-drivers} @@ -14,9 +15,28 @@ services: interval: 5s timeout: 3s retries: 10 + logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "5" + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + logging: *default-logging api: build: . + restart: unless-stopped command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" env_file: - path: .env @@ -36,14 +56,27 @@ services: OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor} LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_MODEL: ${LLM_MODEL:-} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + SECRET_KEY: ${SECRET_KEY:-} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} ports: - "127.0.0.1:8000:8000" + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""] + interval: 10s + timeout: 5s + retries: 12 depends_on: db: condition: service_healthy + redis: + condition: service_healthy + logging: *default-logging bot: build: . + restart: unless-stopped command: python -m bot.main env_file: - path: .env @@ -60,8 +93,13 @@ services: OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor} LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_MODEL: ${LLM_MODEL:-} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + SECRET_KEY: ${SECRET_KEY:-} depends_on: - - api + api: + condition: service_healthy + logging: *default-logging volumes: pgdata: + redisdata: diff --git a/pyproject.toml b/pyproject.toml index 886ee91..8b75e9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,9 +11,11 @@ dependencies = [ "httpx>=0.27,<1.0", "matplotlib>=3.8,<4.0", "pandas>=2.2,<3.0", + "pillow>=10.0,<12.0", "pydantic-settings>=2.2,<3.0", "pytesseract>=0.3.13,<1.0", "python-multipart>=0.0.9,<1.0", + "redis>=5.0,<6.0", "sqlalchemy[asyncio]>=2.0,<3.0", "uvicorn[standard]>=0.29,<1.0", ] diff --git a/scripts/backup_db.sh b/scripts/backup_db.sh new file mode 100755 index 0000000..069c6fb --- /dev/null +++ b/scripts/backup_db.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_DIR="${BACKUP_DIR:-./backups}" +COMPOSE="${COMPOSE:-docker compose}" +DB_SERVICE="${DB_SERVICE:-db}" +POSTGRES_DB="${POSTGRES_DB:-drivers}" +POSTGRES_USER="${POSTGRES_USER:-drivers}" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +OUT="${BACKUP_DIR}/carpass-${POSTGRES_DB}-${STAMP}.dump" + +mkdir -p "$BACKUP_DIR" + +echo "Creating database backup: $OUT" +$COMPOSE exec -T "$DB_SERVICE" pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "$OUT" +echo "Backup complete: $OUT" diff --git a/scripts/bootstrap_admin.py b/scripts/bootstrap_admin.py new file mode 100644 index 0000000..161af7d --- /dev/null +++ b/scripts/bootstrap_admin.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import asyncio + +from sqlalchemy import select + +from app.core.config import settings +from app.db.session import async_session_factory +from app.models.user import User + + +async def main() -> None: + admin_ids = settings.admin_telegram_id_list + if not admin_ids: + raise SystemExit("ADMIN_TELEGRAM_IDS is empty") + async with async_session_factory() as session: + for telegram_id in admin_ids: + result = await session.execute(select(User).where(User.telegram_id == telegram_id)) + user = result.scalar_one_or_none() + if user is None: + user = User(telegram_id=telegram_id, username=str(telegram_id), platform_role="admin") + session.add(user) + else: + user.platform_role = "admin" + await session.commit() + print(f"Bootstrapped admins: {', '.join(str(item) for item in admin_ids)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/check_migrations.sh b/scripts/check_migrations.sh new file mode 100755 index 0000000..dfdf4bc --- /dev/null +++ b/scripts/check_migrations.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Checking Alembic migration chain..." +python -m alembic heads +python -m alembic current || true +python -m alembic upgrade head +python -m alembic current +echo "Alembic migrations applied successfully." diff --git a/scripts/cleanup_jobs.py b/scripts/cleanup_jobs.py new file mode 100644 index 0000000..ef95c52 --- /dev/null +++ b/scripts/cleanup_jobs.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, select + +from app.db.session import async_session_factory +from app.models.car import ServiceEmployee, ServiceNotification, ServiceVisit + + +async def main() -> None: + now = datetime.now(UTC) + async with async_session_factory() as session: + expired = ( + await session.execute( + select(ServiceEmployee).where( + ServiceEmployee.status == "invited", + ServiceEmployee.invite_expires_at.is_not(None), + ServiceEmployee.invite_expires_at <= now, + ) + ) + ).scalars() + expired_count = 0 + for employee in expired: + employee.status = "expired" + employee.invite_token = None + expired_count += 1 + + abandoned_count = 0 + abandoned = ( + await session.execute( + select(ServiceNotification).where( + ServiceNotification.status.in_(["failed", "retrying"]), + ServiceNotification.retry_count >= 5, + ServiceNotification.created_at < now - timedelta(days=1), + ) + ) + ).scalars() + for notification in abandoned: + notification.status = "abandoned" + abandoned_count += 1 + + old_notifications = await session.execute( + delete(ServiceNotification).where( + ServiceNotification.status == "abandoned", + ServiceNotification.created_at < now - timedelta(days=30), + ) + ) + orphan_drafts = await session.execute( + delete(ServiceVisit).where( + ServiceVisit.status == "draft", + ServiceVisit.created_at < now - timedelta(days=90), + ) + ) + await session.commit() + print( + "Cleanup done: " + f"expired_invites={expired_count}, " + f"abandoned_notifications={abandoned_count}, " + f"deleted_old_notifications={old_notifications.rowcount or 0}, " + f"orphan_drafts={orphan_drafts.rowcount or 0}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..abf136b --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/carpass/app}" +BRANCH="${DEPLOY_BRANCH:-main}" +COMPOSE="${COMPOSE:-docker compose}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8000/ready}" +BACKUP_BEFORE_DEPLOY="${BACKUP_BEFORE_DEPLOY:-false}" + +if [[ ! -d "$APP_DIR/.git" ]]; then + echo "Deploy directory is not a git repository: $APP_DIR" >&2 + exit 1 +fi + +cd "$APP_DIR" + +if [[ ! -f ".env" ]]; then + echo ".env is missing in $APP_DIR" >&2 + exit 1 +fi + +echo "Fetching $BRANCH..." +git fetch origin "$BRANCH" +git checkout "$BRANCH" +git pull --ff-only origin "$BRANCH" + +if [[ "$BACKUP_BEFORE_DEPLOY" == "true" ]]; then + echo "Creating pre-deploy database backup..." + ./scripts/backup_db.sh +fi + +echo "Building and starting containers..." +$COMPOSE build +$COMPOSE up -d + +echo "Applying migrations..." +$COMPOSE exec -T api alembic upgrade head + +echo "Running smoke checks..." +$COMPOSE exec -T api python -m compileall -q app bot + +echo "Running health check: $HEALTH_URL" +for attempt in {1..30}; do + if curl -fsS "$HEALTH_URL" >/tmp/carpass-ready.json; then + cat /tmp/carpass-ready.json + echo + $COMPOSE ps + exit 0 + fi + sleep 2 +done + +echo "API readiness check failed" >&2 +$COMPOSE logs --tail=120 api >&2 +exit 1 diff --git a/scripts/restore_db.sh b/scripts/restore_db.sh new file mode 100755 index 0000000..879e966 --- /dev/null +++ b/scripts/restore_db.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 path/to/backup.dump" >&2 + exit 2 +fi + +BACKUP_FILE="$1" +COMPOSE="${COMPOSE:-docker compose}" +DB_SERVICE="${DB_SERVICE:-db}" +POSTGRES_DB="${POSTGRES_DB:-drivers}" +POSTGRES_USER="${POSTGRES_USER:-drivers}" + +if [[ ! -f "$BACKUP_FILE" ]]; then + echo "Backup file not found: $BACKUP_FILE" >&2 + exit 1 +fi + +echo "Restoring $BACKUP_FILE into $POSTGRES_DB. This replaces database contents." +cat "$BACKUP_FILE" | $COMPOSE exec -T "$DB_SERVICE" pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists +echo "Restore complete" diff --git a/tests/conftest.py b/tests/conftest.py index 047789d..c6a7235 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ from app.db.base import Base from app.db.session import get_session from app.main import app from app.models import car, expense, gamification, push, user # noqa: F401 +from app.services.rate_limit import reset_rate_limit_state TEST_BOT_TOKEN = "123456:test-token" TEST_INTERNAL_TOKEN = "internal-test-token" @@ -34,6 +35,7 @@ def make_init_data(telegram_id: int, first_name: str = "Test") -> str: @pytest.fixture(autouse=True) def configure_settings() -> None: + reset_rate_limit_state() settings.bot_token = TEST_BOT_TOKEN settings.internal_api_token = TEST_INTERNAL_TOKEN settings.app_env = "test" diff --git a/tests/test_production_flows.py b/tests/test_production_flows.py new file mode 100644 index 0000000..1221fa7 --- /dev/null +++ b/tests/test_production_flows.py @@ -0,0 +1,275 @@ +from datetime import UTC, datetime, timedelta +from io import BytesIO + +import pytest + + +async def create_verified_center(client, owner_headers, admin_headers, internal_headers, name: str) -> dict: + center = ( + await client.post( + "/api/service-centers", + headers=owner_headers, + json={"display_name": name, "country": "KR", "city": "Seoul"}, + ) + ).json() + await client.post( + "/api/users", + headers=internal_headers, + json={"telegram_id": 9001, "platform_role": "admin"}, + ) + verified = await client.post(f"/api/admin/service-centers/{center['id']}/verify", headers=admin_headers) + assert verified.status_code == 200 + return verified.json() + + +@pytest.mark.asyncio +async def test_employee_invite_activation_revoked_and_expired( + client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers +) -> None: + center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers, "Invite Flow Service") + + invite = await client.post( + f"/api/service-centers/{center['id']}/employees/invite", + headers=auth_headers, + json={"telegram_id": 2002, "role": "manager"}, + ) + assert invite.status_code == 201 or invite.status_code == 200 + employee = invite.json() + token = employee["invite_token"] + + forbidden = await client.get(f"/api/sto/dashboard?service_center_id={center['id']}", headers=other_auth_headers) + assert forbidden.status_code == 403 + + accepted = await client.post( + f"/api/service-centers/employees/invites/{token}/accept", + headers=other_auth_headers, + ) + assert accepted.status_code == 200 + assert accepted.json()["status"] == "active" + + dashboard = await client.get(f"/api/sto/dashboard?service_center_id={center['id']}", headers=other_auth_headers) + assert dashboard.status_code == 200 + + revoked_invite = ( + await client.post( + f"/api/service-centers/{center['id']}/employees/invite", + headers=auth_headers, + json={"telegram_id": 3003, "role": "receptionist"}, + ) + ).json() + revoked = await client.post( + f"/api/service-centers/employees/{revoked_invite['id']}/revoke-invite", + headers=auth_headers, + ) + assert revoked.status_code == 200 + assert revoked.json()["status"] == "revoked" + + expired_invite = ( + await client.post( + f"/api/service-centers/{center['id']}/employees/invite", + headers=auth_headers, + json={"telegram_id": 4004, "role": "mechanic", "expires_in_hours": 0}, + ) + ).json() + expired_headers = {"X-Telegram-Init-Data": __import__("conftest").make_init_data(4004)} + expired = await client.post( + f"/api/service-centers/employees/invites/{expired_invite['invite_token']}/accept", + headers=expired_headers, + ) + assert expired.status_code == 409 + + +@pytest.mark.asyncio +async def test_work_order_completion_creates_vehicle_records_and_updates_costs( + client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers +) -> None: + center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers, "Work Order Service") + vehicle = ( + await client.post( + "/api/my/vehicles", + headers=other_auth_headers, + json={"name": "WO car", "current_odometer": 10000}, + ) + ).json() + await client.post( + f"/api/service-centers/{center['id']}/vehicle-links/owner-attach", + headers=other_auth_headers, + json={"car_id": vehicle["id"], "access_level": "full"}, + ) + start_at = datetime.now(UTC) + timedelta(days=3) + appointment = ( + await client.post( + "/api/appointments", + headers=other_auth_headers, + json={ + "service_center_id": center["id"], + "vehicle_id": vehicle["id"], + "service_type": "oil_change", + "service_name": "Oil change", + "requested_start_at": start_at.replace(hour=10, minute=0, second=0, microsecond=0).isoformat(), + "estimated_duration_minutes": 60, + "customer_comment": "Oil and filter", + }, + ) + ).json() + confirmed = await client.post( + f"/api/sto/appointments/{appointment['id']}/confirm", + headers=auth_headers, + json={"comment": "Confirmed"}, + ) + assert confirmed.status_code == 200 + + work_order = ( + await client.post( + f"/api/sto/appointments/{appointment['id']}/create-work-order", + headers=auth_headers, + json={"odometer": 10150}, + ) + ).json() + assert work_order["status"] == "diagnosis" + + labor = await client.post( + f"/api/work-orders/{work_order['id']}/labor-items", + headers=auth_headers, + json={"work_type": "oil_change", "title": "Oil labor", "quantity": 1, "unit_price": 70}, + ) + product = await client.post( + f"/api/work-orders/{work_order['id']}/product-items", + headers=auth_headers, + json={ + "title": "Engine oil", + "product_type": "engine_oil", + "quantity": 4, + "unit": "l", + "unit_price": 15, + "viscosity": "5W-30", + "used_volume": 4, + }, + ) + assert labor.status_code == 201 + assert product.status_code == 201 + + submitted = await client.post( + f"/api/work-orders/{work_order['id']}/submit-approval", + headers=auth_headers, + json={"comment": "Please approve"}, + ) + assert submitted.status_code == 200 + assert submitted.json()["final_total"] == "130.00" + + approved = await client.post( + f"/api/work-orders/{work_order['id']}/approve", + headers=other_auth_headers, + json={"comment": "Approved"}, + ) + assert approved.status_code == 200 + assert approved.json()["status"] == "approved_by_owner" + + completed = await client.post( + f"/api/work-orders/{work_order['id']}/complete", + headers=auth_headers, + json={}, + ) + assert completed.status_code == 200 + assert completed.json()["status"] == "completed" + + duplicate_completion = await client.post( + f"/api/work-orders/{work_order['id']}/complete", + headers=auth_headers, + json={}, + ) + assert duplicate_completion.status_code == 200 + assert duplicate_completion.json()["status"] == "completed" + + correction = await client.post( + f"/api/work-orders/{work_order['id']}/corrections", + headers=auth_headers, + json={ + "reason": "Typo in service comment", + "proposed_changes": {"service_comment": "Oil and filter replaced"}, + "owner_approval_required": False, + }, + ) + assert correction.status_code == 201 + assert correction.json()["created_version"] == completed.json()["version"] + + service_history = await client.get( + f"/api/my/vehicles/{vehicle['id']}/service-history", + headers=other_auth_headers, + ) + expenses = await client.get(f"/api/cars/{vehicle['id']}/expenses", headers=other_auth_headers) + refreshed = await client.get(f"/api/cars/{vehicle['id']}", headers=other_auth_headers) + stats = await client.get( + f"/api/cars/{vehicle['id']}/stats?date_from=2026-01-01&date_to=2099-12-31", + headers=other_auth_headers, + ) + + assert service_history.status_code == 200 + assert any(item["id"] == work_order["id"] for item in service_history.json()["service_visits"]) + assert sum(1 for item in service_history.json()["service_visits"] if item["id"] == work_order["id"]) == 1 + assert len(expenses.json()) == 1 + assert expenses.json()[0]["total_cost"] == "130.00" + assert refreshed.json()["current_odometer"] == 10150 + assert stats.json()["total_cost"] == "130.00" + + cannot_edit = await client.patch( + f"/api/work-orders/{work_order['id']}", + headers=auth_headers, + json={"diagnosis": "Changed"}, + ) + assert cannot_edit.status_code == 409 + + +@pytest.mark.asyncio +async def test_rate_limit_blocks_ocr_after_threshold(client, auth_headers) -> None: + for _ in range(8): + 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 + limited = await client.post( + "/api/ocr/vin", + headers=auth_headers, + files={"file": ("vin.txt", BytesIO(b"VIN KMHCT41BAHU123456"), "text/plain")}, + ) + assert limited.status_code == 429 + + +@pytest.mark.asyncio +async def test_ocr_receipt_parser_extracts_date_and_fuel_fields(client, auth_headers) -> None: + response = await client.post( + "/api/ocr/parse-text-receipt", + headers=auth_headers, + files={ + "file": ( + "receipt.txt", + BytesIO(b"Shell 2026-05-01 total 120.00 40 l price 3.00"), + "text/plain", + ) + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["entry_date"] == "2026-05-01" + assert payload["liters"] == "40" + assert payload["price_per_liter"] == "3.00" + assert payload["category"] == "fuel" + + +@pytest.mark.asyncio +async def test_upload_security_headers_and_metrics(client, auth_headers) -> None: + blocked = await client.post( + "/api/ocr/vin", + headers=auth_headers, + files={"file": ("payload.exe", BytesIO(b"MZ fake binary"), "application/octet-stream")}, + ) + assert blocked.status_code == 415 + assert blocked.headers["x-content-type-options"] == "nosniff" + assert blocked.headers["referrer-policy"] == "strict-origin-when-cross-origin" + assert "x-request-id" in blocked.headers + + metrics = await client.get("/metrics") + assert metrics.status_code == 200 + assert "carpass_requests_total" in metrics.text diff --git a/web/index.html b/web/index.html index 03e4526..13599b6 100644 --- a/web/index.html +++ b/web/index.html @@ -244,6 +244,14 @@

Меню

+ @@ -259,7 +267,8 @@ - + + @@ -534,6 +543,18 @@
+ +