Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

View File

@@ -15,7 +15,12 @@ ALLOW_DEV_AUTH=false
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
SECRET_KEY=change-this-long-random-secret
REDIS_URL=redis://redis:6379/0
OCR_PROVIDER=tesseract OCR_PROVIDER=tesseract
OCR_LANGUAGES=eng+rus+kor OCR_LANGUAGES=eng+rus+kor
LLM_BASE_URL= LLM_BASE_URL=
LLM_MODEL= LLM_MODEL=
ADMIN_TELEGRAM_IDS=
ADMIN_BOOTSTRAP_TOKEN=

43
.github/workflows/ci.yml vendored Normal file
View File

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

111
DEPLOY.md Normal file
View File

@@ -0,0 +1,111 @@
# CarPass Deploy
## First Install
```bash
sudo mkdir -p /opt/carpass
sudo chown "$USER":"$USER" /opt/carpass
git clone <repo-url> /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 <previous_commit>
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.

View File

@@ -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, номер, пробег и технические параметры, проходят подтверждение владельца. CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
Чувствительные действия ограничены rate limiting: OCR, VIN/номер, запросы доступа к автомобилю, записи в СТО, приглашения сотрудников и отзывы. В production лимиты работают через Redis, а локально могут падать обратно на in-memory режим.
## Telegram Mini App ## Telegram Mini App
Mini App открывается через кнопку внутри Telegram-бота. Так Telegram передает защищенную авторизацию, а гараж привязывается к аккаунту пользователя. Если страницу открыть напрямую в браузере, CarPass покажет понятное приглашение открыть приложение через Telegram. 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. - `/start` и `/menu` — правильный вход в Mini App.

View File

@@ -1,4 +1,4 @@
"""promote requested admin user """legacy admin bootstrap placeholder
Revision ID: 202605150001 Revision ID: 202605150001
Revises: 202605140002 Revises: 202605140002
@@ -7,33 +7,15 @@ Create Date: 2026-05-15 05:00:00.000000
from collections.abc import Sequence from collections.abc import Sequence
from alembic import op
revision: str = "202605150001" revision: str = "202605150001"
down_revision: str | None = "202605140002" down_revision: str | None = "202605140002"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
ADMIN_TELEGRAM_ID = 556399210
def upgrade() -> None: def upgrade() -> None:
op.execute( return None
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'
"""
)
def downgrade() -> None: def downgrade() -> None:
op.execute( return None
f"""
update users
set platform_role = 'user'
where telegram_id = {ADMIN_TELEGRAM_ID}
and platform_role = 'admin'
"""
)

View File

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

View File

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

View File

@@ -1,21 +1,29 @@
import re import re
from datetime import date
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, File, UploadFile from fastapi import APIRouter, Depends, File, Request, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.user import User from app.models.user import User
from app.services.ocr_provider import get_ocr_provider 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"]) router = APIRouter(prefix="/ocr", tags=["ocr"])
MAX_OCR_FILE_BYTES = 8 * 1024 * 1024
class ReceiptSuggestion(BaseModel): class ReceiptSuggestion(BaseModel):
entry_date: date | None = None
total_cost: Decimal | None = None total_cost: Decimal | None = None
liters: Decimal | None = None liters: Decimal | None = None
price_per_liter: Decimal | None = None price_per_liter: Decimal | None = None
station: str | None = None station: str | None = None
category: str | None = None
confidence: float confidence: float
message: str message: str
@@ -34,10 +42,20 @@ class OCRResultRead(BaseModel):
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion) @router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
async def parse_text_receipt( async def parse_text_receipt(
request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> ReceiptSuggestion: ) -> ReceiptSuggestion:
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read() 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() content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf": if content_type.startswith("image/") or content_type == "application/pdf":
result = await get_ocr_provider().recognize(content, file.filename) 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)] numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)]
station = detect_station(compact) station = detect_station(compact)
entry_date = detect_date(compact)
liters = find_liters(compact, numbers) liters = find_liters(compact, numbers)
price = find_price_per_liter(compact, numbers) price = find_price_per_liter(compact, numbers)
total = find_total(compact, numbers, liters, price) 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)) confidence = max(0, min(float(confidence), 0.95))
return ReceiptSuggestion( return ReceiptSuggestion(
entry_date=entry_date,
total_cost=total, total_cost=total,
liters=liters, liters=liters,
price_per_liter=price, price_per_liter=price,
station=station, station=station,
category="fuel" if liters or price else None,
confidence=round(confidence, 2) if numbers else 0, confidence=round(confidence, 2) if numbers else 0,
message=( message=(
"Разобрал текст чека и заполнил форму. Проверь значения перед сохранением." "Разобрал текст чека и заполнил форму. Проверь значения перед сохранением."
@@ -95,18 +116,25 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion:
@router.post("/fuel-receipt", response_model=ReceiptSuggestion, deprecated=True) @router.post("/fuel-receipt", response_model=ReceiptSuggestion, deprecated=True)
async def scan_fuel_receipt( async def scan_fuel_receipt(
request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> ReceiptSuggestion: ) -> 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) @router.post("/license-plate", response_model=OCRResultRead)
async def recognize_license_plate( async def recognize_license_plate(
request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> OCRResultRead: ) -> 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( return OCRResultRead(
recognized_text=result.recognized_text, recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"], 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) @router.post("/vin", response_model=OCRResultRead)
async def recognize_vin( async def recognize_vin(
request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> OCRResultRead: ) -> 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( return OCRResultRead(
recognized_text=result.recognized_text, recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"], 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) @router.post("/service-document", response_model=OCRResultRead)
async def recognize_service_document( async def recognize_service_document(
request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> OCRResultRead: ) -> 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( return OCRResultRead(
recognized_text=result.recognized_text, recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates], candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
@@ -158,6 +196,24 @@ def detect_station(text: str) -> str | None:
return 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: def decimal_from_match(match: re.Match[str] | None) -> Decimal | None:
if not match: if not match:
return None return None
@@ -183,9 +239,9 @@ def find_price_per_liter(text: str, numbers: list[Decimal]) -> Decimal | None:
] ]
for pattern in patterns: for pattern in patterns:
value = decimal_from_match(re.search(pattern, text, re.IGNORECASE)) 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 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 return candidates[-1] if candidates else None

View File

@@ -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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession 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.notifications import notify_platform_moderators
from app.services.odometer import validate_odometer_change 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 from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"]) router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@@ -162,12 +164,17 @@ async def my_service_centers(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]: ) -> list[ServiceCenter]:
result = await session.execute( result = await session.execute(
select(ServiceCenter) select(ServiceCenter, ServiceEmployee.role, ServiceEmployee.status)
.join(ServiceEmployee, ServiceEmployee.service_center_id == ServiceCenter.id) .join(ServiceEmployee, ServiceEmployee.service_center_id == ServiceCenter.id)
.where(ServiceEmployee.user_id == current_user.id, ServiceEmployee.status == "active") .where(ServiceEmployee.user_id == current_user.id, ServiceEmployee.status == "active")
.order_by(ServiceCenter.created_at.desc()) .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]) @router.get("/public", response_model=list[ServiceCenterPublicRead])
@@ -253,9 +260,11 @@ async def submit_verification(
async def invite_employee( async def invite_employee(
service_center_id: int, service_center_id: int,
payload: ServiceEmployeeInvite, payload: ServiceEmployeeInvite,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee: ) -> 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"}) 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) user = await get_or_create_telegram_user(session, telegram_id=payload.telegram_id)
result = await session.execute( result = await session.execute(
@@ -272,18 +281,95 @@ async def invite_employee(
role=payload.role, role=payload.role,
permissions=payload.permissions, permissions=payload.permissions,
status="invited", status="invited",
invite_token=secrets.token_urlsafe(32),
invite_expires_at=datetime.now(UTC) + timedelta(hours=payload.expires_in_hours),
) )
session.add(employee) session.add(employee)
else: else:
employee.role = payload.role employee.role = payload.role
employee.permissions = payload.permissions employee.permissions = payload.permissions
employee.status = "invited" 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 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.commit()
await session.refresh(employee) await session.refresh(employee)
return 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]) @router.get("/{service_center_id}/visits", response_model=list[ServiceVisitRead])
async def service_center_visits( async def service_center_visits(
service_center_id: int, service_center_id: int,
@@ -355,6 +441,7 @@ async def create_visit(
visit = ServiceVisit( visit = ServiceVisit(
service_center_id=service_center_id, service_center_id=service_center_id,
vehicle_id=payload.vehicle_id, vehicle_id=payload.vehicle_id,
owner_id=vehicle.owner_id,
created_by_employee_id=employee.id, created_by_employee_id=employee.id,
visit_date=payload.visit_date, visit_date=payload.visit_date,
odometer=payload.odometer, odometer=payload.odometer,
@@ -374,9 +461,11 @@ async def create_visit(
async def request_vehicle_access( async def request_vehicle_access(
service_center_id: int, service_center_id: int,
payload: VehicleSearchRequest, payload: VehicleSearchRequest,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> VehicleSearchResult: ) -> 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_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id) await ensure_service_center_approved(session, service_center_id)
stmt = select(Car) stmt = select(Car)
@@ -610,9 +699,11 @@ async def service_center_reviews(
async def create_service_center_review( async def create_service_center_review(
service_center_id: int, service_center_id: int,
payload: ServiceCenterReviewCreate, payload: ServiceCenterReviewCreate,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview: ) -> 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) await ensure_service_center_approved(session, service_center_id)
result = await session.execute( result = await session.execute(
select(ServiceCenterReview).where( select(ServiceCenterReview).where(

View File

@@ -1,6 +1,6 @@
from datetime import UTC, date, datetime, timedelta 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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
ServiceCenterHolidayRead, ServiceCenterHolidayRead,
STODashboardRead, STODashboardRead,
) )
from app.services.rate_limit import check_rate_limit
from app.services.sto_booking import ( from app.services.sto_booking import (
calculate_available_slots, calculate_available_slots,
create_service_notification, create_service_notification,
@@ -46,6 +47,7 @@ from app.services.sto_booking import (
money_to_float, money_to_float,
notify_service_staff, notify_service_staff,
) )
from app.services.work_orders import add_status_history, assign_work_order_number
APPROVED_SERVICE_STATUSES = {"verified", "approved"} 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) @router.post("/appointments", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED)
async def create_appointment( async def create_appointment(
payload: AppointmentCreate, payload: AppointmentCreate,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment: ) -> 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) await _approved_service_center(session, payload.service_center_id)
vehicle = await _owned_vehicle(session, payload.vehicle_id, current_user) vehicle = await _owned_vehicle(session, payload.vehicle_id, current_user)
duration = estimate_duration(payload.service_type, payload.estimated_duration_minutes) 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), current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment: ) -> ServiceAppointment:
appointment = await _appointment_for_owner(session, appointment_id, current_user) 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") 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.cancelled_at = datetime.now(UTC)
appointment.cancellation_reason = payload.reason appointment.cancellation_reason = payload.reason
await notify_service_staff( await notify_service_staff(
session, session,
service_center_id=appointment.service_center_id, service_center_id=appointment.service_center_id,
notification_type="appointment.cancelled_by_customer", notification_type="appointment.cancelled_by_owner",
title="Клиент отменил запись", title="Клиент отменил запись",
body=payload.reason, body=payload.reason,
appointment_id=appointment.id, appointment_id=appointment.id,
@@ -316,7 +320,7 @@ async def reject_proposed_time(
appointment = await _appointment_for_owner(session, appointment_id, current_user) appointment = await _appointment_for_owner(session, appointment_id, current_user)
if appointment.status != "proposed_new_time": if appointment.status != "proposed_new_time":
raise HTTPException(status_code=409, detail="Appointment has no proposed 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 appointment.service_center_comment = payload.comment
await notify_service_staff( await notify_service_staff(
session, session,
@@ -365,13 +369,13 @@ async def get_sto_dashboard(
confirmed_appointments = int( confirmed_appointments = int(
(await session.execute(select(func.count(ServiceAppointment.id)).where( (await session.execute(select(func.count(ServiceAppointment.id)).where(
ServiceAppointment.service_center_id == service_center_id, ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.status == "confirmed", ServiceAppointment.status.in_(["confirmed", "confirmed_by_sto"]),
))).scalar_one() or 0 ))).scalar_one() or 0
) )
active_work_orders = int( active_work_orders = int(
(await session.execute(select(func.count(ServiceVisit.id)).where( (await session.execute(select(func.count(ServiceVisit.id)).where(
ServiceVisit.service_center_id == service_center_id, 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 ))).scalar_one() or 0
) )
completed_result = await session.execute( completed_result = await session.execute(
@@ -465,7 +469,7 @@ async def confirm_appointment(
duration_minutes=appointment.estimated_duration_minutes, duration_minutes=appointment.estimated_duration_minutes,
exclude_appointment_id=appointment.id, exclude_appointment_id=appointment.id,
) )
appointment.status = "confirmed" appointment.status = "confirmed_by_sto"
appointment.confirmed_start_at = appointment.requested_start_at appointment.confirmed_start_at = appointment.requested_start_at
appointment.confirmed_end_at = appointment.requested_end_at appointment.confirmed_end_at = appointment.requested_end_at
appointment.service_center_comment = payload.comment appointment.service_center_comment = payload.comment
@@ -492,9 +496,9 @@ async def reject_appointment(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment: ) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user) 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") raise HTTPException(status_code=409, detail="Appointment cannot be rejected")
appointment.status = "rejected" appointment.status = "rejected_by_sto"
appointment.service_center_comment = payload.comment appointment.service_center_comment = payload.comment
await create_service_notification( await create_service_notification(
session, session,
@@ -519,7 +523,7 @@ async def propose_appointment_time(
current_user: User = Depends(get_current_telegram_user), current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment: ) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user) 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") 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) duration = estimate_duration(appointment.service_type, payload.estimated_duration_minutes or appointment.estimated_duration_minutes)
proposed_start = _utc(payload.proposed_start_at) proposed_start = _utc(payload.proposed_start_at)
@@ -559,7 +563,7 @@ async def create_work_order_from_appointment(
) -> ServiceVisit: ) -> ServiceVisit:
appointment = await _appointment_for_sto(session, appointment_id, current_user) 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"}) 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") raise HTTPException(status_code=409, detail="Only confirmed appointment can become work order")
if appointment.linked_work_order_id: if appointment.linked_work_order_id:
visit = await session.get(ServiceVisit, 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( visit = ServiceVisit(
service_center_id=appointment.service_center_id, service_center_id=appointment.service_center_id,
vehicle_id=appointment.vehicle_id, vehicle_id=appointment.vehicle_id,
owner_id=appointment.owner_id,
created_by_employee_id=employee.id, created_by_employee_id=employee.id,
assigned_employee_id=employee.id,
visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(), visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(),
odometer=payload.odometer, odometer=payload.odometer,
status="draft", status="draft",
customer_complaint=appointment.customer_comment,
notes=payload.notes or appointment.customer_comment, notes=payload.notes or appointment.customer_comment,
opened_at=datetime.now(UTC),
) )
session.add(visit) session.add(visit)
await session.flush() 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.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 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.commit()
await session.refresh(visit) await session.refresh(visit)

View File

@@ -25,6 +25,7 @@ from app.schemas.user import (
UserUpsert, UserUpsert,
WebAppAuthRequest, WebAppAuthRequest,
) )
from app.services.rate_limit import check_rate_limit
from app.services.telegram_auth import verify_login_widget, verify_webapp_init_data from app.services.telegram_auth import verify_login_widget, verify_webapp_init_data
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -56,8 +57,11 @@ async def auth_config() -> AuthConfig:
@router.post("/webapp-auth", response_model=UserRead) @router.post("/webapp-auth", response_model=UserRead)
async def webapp_auth( async def webapp_auth(
payload: WebAppAuthRequest, session: AsyncSession = Depends(get_session) payload: WebAppAuthRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> User: ) -> 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) user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
telegram_id = int(user_data["id"]) telegram_id = int(user_data["id"])
return await get_or_create_telegram_user( return await get_or_create_telegram_user(
@@ -72,8 +76,11 @@ async def webapp_auth(
@router.post("/telegram-login", response_model=UserRead) @router.post("/telegram-login", response_model=UserRead)
async def telegram_login( async def telegram_login(
payload: TelegramLoginRequest, session: AsyncSession = Depends(get_session) payload: TelegramLoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> User: ) -> 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) values = verify_login_widget(payload.model_dump(), settings.bot_token)
telegram_id = int(values["id"]) telegram_id = int(values["id"])
return await get_or_create_telegram_user( return await get_or_create_telegram_user(

337
app/api/work_orders.py Normal file
View File

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

View File

@@ -16,11 +16,16 @@ class Settings(BaseSettings):
cors_origins: str = "" cors_origins: str = ""
internal_api_token: str = "" internal_api_token: str = ""
vapid_public_key: str = "" vapid_public_key: str = ""
vapid_private_key: str = ""
secret_key: str = ""
redis_url: str = ""
allow_dev_auth: bool = False allow_dev_auth: bool = False
ocr_provider: str = "tesseract" ocr_provider: str = "tesseract"
ocr_languages: str = "eng+rus+kor" ocr_languages: str = "eng+rus+kor"
llm_base_url: str = "" llm_base_url: str = ""
llm_model: 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") 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: def is_production(self) -> bool:
return self.app_env.lower() == "production" 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: def validate_webapp_url_for_telegram(self) -> None:
url = self.effective_webapp_url url = self.effective_webapp_url
if self.is_production and not url.startswith("https://"): 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): 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://") 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 @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -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.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import ( from app.api import (
admin, admin,
@@ -16,10 +22,54 @@ from app.api import (
service_visits, service_visits,
sto_booking, sto_booking,
users, users,
work_orders,
) )
from app.core.config import settings 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 [] 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 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(service_centers.router, prefix="/api")
app.include_router(sto_booking.router, prefix="/api") app.include_router(sto_booking.router, prefix="/api")
app.include_router(service_visits.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(change_requests.router, prefix="/api")
app.include_router(admin.router, prefix="/api") app.include_router(admin.router, prefix="/api")
@@ -52,4 +103,41 @@ async def health() -> dict[str, str]:
return {"status": "ok"} 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") app.mount("/", StaticFiles(directory="web", html=True), name="web")

View File

@@ -264,6 +264,10 @@ class ServiceEmployee(Base):
role: Mapped[str] = mapped_column(String(32), default="receptionist", server_default="receptionist", index=True) role: Mapped[str] = mapped_column(String(32), default="receptionist", server_default="receptionist", index=True)
permissions: Mapped[dict | None] = mapped_column(JSON) permissions: Mapped[dict | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True) 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
service_center = relationship("ServiceCenter", back_populates="employees") service_center = relationship("ServiceCenter", back_populates="employees")
@@ -311,15 +315,35 @@ class ServiceVisit(Base):
__tablename__ = "service_visits" __tablename__ = "service_visits"
id: Mapped[int] = mapped_column(primary_key=True) 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) 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) 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) 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) visit_date: Mapped[date] = mapped_column(Date, index=True)
odometer: Mapped[int | None] odometer: Mapped[int | None]
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True) 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) 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)) 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") 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)) owner_resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
@@ -328,6 +352,9 @@ class ServiceVisit(Base):
service_center = relationship("ServiceCenter", back_populates="visits") service_center = relationship("ServiceCenter", back_populates="visits")
work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan") 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): class MaintenanceRecommendation(Base):
@@ -395,7 +422,12 @@ class ServiceNotification(Base):
notification_type: Mapped[str] = mapped_column(String(80), index=True) notification_type: Mapped[str] = mapped_column(String(80), index=True)
title: Mapped[str] = mapped_column(String(180)) title: Mapped[str] = mapped_column(String(180))
body: Mapped[str | None] = mapped_column(Text) 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) 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) 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) work_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True)
title: Mapped[str] = mapped_column(String(180)) title: Mapped[str] = mapped_column(String(180))
category: Mapped[str | None] = mapped_column(String(80))
description: Mapped[str | None] = mapped_column(Text) 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) parts: Mapped[list | None] = mapped_column(JSON)
oil_brand: Mapped[str | None] = mapped_column(String(80)) oil_brand: Mapped[str | None] = mapped_column(String(80))
oil_viscosity: Mapped[str | None] = mapped_column(String(40)) oil_viscosity: Mapped[str | None] = mapped_column(String(40))
@@ -414,11 +452,85 @@ class ServiceWorkItem(Base):
next_due_odometer: Mapped[int | None] next_due_odometer: Mapped[int | None]
next_due_date: Mapped[date | None] = mapped_column(Date) next_due_date: Mapped[date | None] = mapped_column(Date)
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
visit = relationship("ServiceVisit", back_populates="work_items") 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): class ServiceCenterReview(Base):
__tablename__ = "service_center_reviews" __tablename__ = "service_center_reviews"
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),) __table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),)

View File

@@ -66,6 +66,7 @@ class ServiceEntry(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=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) entry_date: Mapped[date] = mapped_column(Date, index=True)
odometer: Mapped[int | None] odometer: Mapped[int | None]
service_type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True) 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) id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=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) entry_date: Mapped[date] = mapped_column(Date, index=True)
category: Mapped[ExpenseCategory] = mapped_column(Enum(ExpenseCategory), index=True) category: Mapped[ExpenseCategory] = mapped_column(Enum(ExpenseCategory), index=True)
title: Mapped[str] = mapped_column(String(180)) title: Mapped[str] = mapped_column(String(180))

View File

@@ -30,6 +30,8 @@ class ServiceCenterRead(ServiceCenterCreate):
name: str name: str
verification_status: str verification_status: str
owner_user_id: int | None = None owner_user_id: int | None = None
employee_role: str | None = None
employee_status: str | None = None
created_at: datetime created_at: datetime
verified_at: datetime | None = None verified_at: datetime | None = None
suspended_at: datetime | None = None suspended_at: datetime | None = None
@@ -78,6 +80,7 @@ class ServiceEmployeeInvite(BaseModel):
telegram_id: int telegram_id: int
role: str = "receptionist" role: str = "receptionist"
permissions: dict | None = None permissions: dict | None = None
expires_in_hours: int = Field(default=72, ge=0, le=720)
class ServiceEmployeeRead(BaseModel): class ServiceEmployeeRead(BaseModel):
@@ -87,6 +90,10 @@ class ServiceEmployeeRead(BaseModel):
role: str role: str
permissions: dict | None = None permissions: dict | None = None
status: str 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 created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -284,8 +291,27 @@ class ServiceVisitCreate(BaseModel):
class ServiceVisitRead(ServiceVisitCreate): class ServiceVisitRead(ServiceVisitCreate):
id: int id: int
service_center_id: int service_center_id: int
work_order_number: str | None = None
owner_id: int | None = None
created_by_employee_id: int | None = None created_by_employee_id: int | None = None
assigned_employee_id: int | None = None
status: str 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 owner_resolved_at: datetime | None = None
created_at: datetime created_at: datetime
@@ -295,7 +321,12 @@ class ServiceVisitRead(ServiceVisitCreate):
class ServiceWorkItemCreate(BaseModel): class ServiceWorkItemCreate(BaseModel):
work_type: str = "other" work_type: str = "other"
title: str title: str
category: str | None = None
description: 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 parts: list[dict] | None = None
oil_brand: str | None = None oil_brand: str | None = None
oil_viscosity: str | None = None oil_viscosity: str | None = None
@@ -303,11 +334,108 @@ class ServiceWorkItemCreate(BaseModel):
next_due_odometer: int | None = None next_due_odometer: int | None = None
next_due_date: date | None = None next_due_date: date | None = None
price: Decimal | 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): class ServiceWorkItemRead(ServiceWorkItemCreate):
id: int id: int
service_visit_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 created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -6,10 +6,14 @@ APPOINTMENT_STATUSES = {
"draft", "draft",
"requested", "requested",
"confirmed", "confirmed",
"confirmed_by_sto",
"proposed_new_time", "proposed_new_time",
"rejected", "rejected",
"rejected_by_sto",
"cancelled_by_owner",
"cancelled_by_customer", "cancelled_by_customer",
"cancelled_by_sto", "cancelled_by_sto",
"converted_to_work_order",
"completed", "completed",
"no_show", "no_show",
} }

View File

@@ -260,6 +260,7 @@ async def expense_period_totals(
.where( .where(
ExpenseEntry.car_id == car_id, ExpenseEntry.car_id == car_id,
ExpenseEntry.entry_date <= date_to, ExpenseEntry.entry_date <= date_to,
ExpenseEntry.service_visit_id.is_(None),
) )
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc()) .order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
) )

View File

@@ -1,27 +1,70 @@
from datetime import UTC, datetime, timedelta
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.models.car import ServiceNotification
from app.models.user import User from app.models.user import User
MODERATOR_ROLES = {"admin", "verifier", "moderator"} 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": if not settings.bot_token or settings.app_env == "test":
return return False
try: try:
async with httpx.AsyncClient(timeout=5) as client: 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", f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
data={"chat_id": str(user.telegram_id), "text": text}, data={"chat_id": str(user.telegram_id), "text": text},
) )
return response.status_code < 400
except Exception: except Exception:
return return False
async def notify_platform_moderators(session: AsyncSession, text: str) -> None: async def notify_platform_moderators(session: AsyncSession, text: str) -> None:
result = await session.execute(select(User).where(User.platform_role.in_(MODERATOR_ROLES))) result = await session.execute(select(User).where(User.platform_role.in_(MODERATOR_ROLES)))
for user in result.scalars(): for user in result.scalars():
await notify_user(user, text) 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

View File

@@ -50,17 +50,35 @@ class TesseractOCRProvider:
def _recognize_sync(self, content: bytes) -> str: def _recognize_sync(self, content: bytes) -> str:
try: try:
import pytesseract import pytesseract
from PIL import Image from PIL import Image, ImageEnhance, ImageOps
except ImportError: except ImportError:
return "" return ""
try: try:
image = Image.open(BytesIO(content)) image = Image.open(BytesIO(content))
except Exception: except Exception:
return "" return ""
candidates = [image]
try: 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: 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: class CompositeOCRProvider:

124
app/services/rate_limit.py Normal file
View File

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

View File

@@ -21,7 +21,7 @@ from app.models.expense import ServiceEntry
from app.models.user import User from app.models.user import User
from app.services.notifications import notify_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 = { DEFAULT_SERVICE_DURATIONS = {
"oil_change": 60, "oil_change": 60,
"diagnostics": 60, "diagnostics": 60,
@@ -190,7 +190,16 @@ async def create_service_notification(
service_center_id: int | None = None, service_center_id: int | None = None,
appointment_id: int | None = None, appointment_id: int | None = None,
send_telegram: bool = True, send_telegram: bool = True,
idempotency_key: str | None = None,
) -> ServiceNotification: ) -> 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( notification = ServiceNotification(
recipient_user_id=recipient_user_id, recipient_user_id=recipient_user_id,
service_center_id=service_center_id, service_center_id=service_center_id,
@@ -198,12 +207,21 @@ async def create_service_notification(
notification_type=notification_type, notification_type=notification_type,
title=title, title=title,
body=body, body=body,
idempotency_key=idempotency_key,
) )
session.add(notification) session.add(notification)
if send_telegram: if send_telegram:
user = await session.get(User, recipient_user_id) user = await session.get(User, recipient_user_id)
if user is not None: 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 return notification

54
app/services/uploads.py Normal file
View File

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

315
app/services/work_orders.py Normal file
View File

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

View File

@@ -521,8 +521,9 @@ async def help_message(message: Message) -> None:
"• /appointments — мои записи в СТО;\n" "• /appointments — мои записи в СТО;\n"
"• /sto_bookings — заявки и календарь для владельца СТО;\n" "• /sto_bookings — заявки и календарь для владельца СТО;\n"
"• /register_sto — заявка на СТО.\n\n" "• /register_sto — заявка на СТО.\n\n"
"Для ТО: в карточке авто Mini App показывает рекомендации, доступные СТО, свободные окна, запись и согласование времени.\n" "Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
"Для СТО: настрой график, принимай заявки, создавай заказ-наряд из подтвержденной записи и отправляй клиенту результат работ.\n\n" "СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n"
"Безопасность: СТО видит автомобиль только после подтверждения владельца, а спорные изменения VIN, номера и пробега идут через согласование.\n\n"
"Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.", "Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.",
reply_markup=menu_inline_keyboard(), reply_markup=menu_inline_keyboard(),
) )

View File

@@ -1,6 +1,7 @@
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-drivers} POSTGRES_DB: ${POSTGRES_DB:-drivers}
POSTGRES_USER: ${POSTGRES_USER:-drivers} POSTGRES_USER: ${POSTGRES_USER:-drivers}
@@ -14,9 +15,28 @@ services:
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 10 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: api:
build: . build: .
restart: unless-stopped
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
env_file: env_file:
- path: .env - path: .env
@@ -36,14 +56,27 @@ services:
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor} OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-} 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: ports:
- "127.0.0.1:8000:8000" - "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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
logging: *default-logging
bot: bot:
build: . build: .
restart: unless-stopped
command: python -m bot.main command: python -m bot.main
env_file: env_file:
- path: .env - path: .env
@@ -60,8 +93,13 @@ services:
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor} OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-} LLM_MODEL: ${LLM_MODEL:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
SECRET_KEY: ${SECRET_KEY:-}
depends_on: depends_on:
- api api:
condition: service_healthy
logging: *default-logging
volumes: volumes:
pgdata: pgdata:
redisdata:

View File

@@ -11,9 +11,11 @@ dependencies = [
"httpx>=0.27,<1.0", "httpx>=0.27,<1.0",
"matplotlib>=3.8,<4.0", "matplotlib>=3.8,<4.0",
"pandas>=2.2,<3.0", "pandas>=2.2,<3.0",
"pillow>=10.0,<12.0",
"pydantic-settings>=2.2,<3.0", "pydantic-settings>=2.2,<3.0",
"pytesseract>=0.3.13,<1.0", "pytesseract>=0.3.13,<1.0",
"python-multipart>=0.0.9,<1.0", "python-multipart>=0.0.9,<1.0",
"redis>=5.0,<6.0",
"sqlalchemy[asyncio]>=2.0,<3.0", "sqlalchemy[asyncio]>=2.0,<3.0",
"uvicorn[standard]>=0.29,<1.0", "uvicorn[standard]>=0.29,<1.0",
] ]

16
scripts/backup_db.sh Executable file
View File

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

View File

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

9
scripts/check_migrations.sh Executable file
View File

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

67
scripts/cleanup_jobs.py Normal file
View File

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

55
scripts/deploy.sh Executable file
View File

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

22
scripts/restore_db.sh Executable file
View File

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

View File

@@ -15,6 +15,7 @@ from app.db.base import Base
from app.db.session import get_session from app.db.session import get_session
from app.main import app from app.main import app
from app.models import car, expense, gamification, push, user # noqa: F401 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_BOT_TOKEN = "123456:test-token"
TEST_INTERNAL_TOKEN = "internal-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) @pytest.fixture(autouse=True)
def configure_settings() -> None: def configure_settings() -> None:
reset_rate_limit_state()
settings.bot_token = TEST_BOT_TOKEN settings.bot_token = TEST_BOT_TOKEN
settings.internal_api_token = TEST_INTERNAL_TOKEN settings.internal_api_token = TEST_INTERNAL_TOKEN
settings.app_env = "test" settings.app_env = "test"

View File

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

View File

@@ -244,6 +244,14 @@
<h2>Меню</h2> <h2>Меню</h2>
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button> <button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
</div> </div>
<section class="service-profile-card hidden" id="serviceProfileCard">
<div>
<p class="eyebrow">Профиль СТО</p>
<strong id="serviceProfileTitle">Рабочее место</strong>
<small id="serviceProfileMeta">Доступно после регистрации СТО</small>
</div>
<button class="wide-btn" type="button" data-menu-section="mechanicWorkplaceSection">Открыть рабочее место</button>
</section>
<button class="menu-row" data-menu-section="carsSection">Автомобили</button> <button class="menu-row" data-menu-section="carsSection">Автомобили</button>
<button class="menu-row" data-menu-section="carFormSection">Добавить авто</button> <button class="menu-row" data-menu-section="carFormSection">Добавить авто</button>
<button class="menu-row" data-menu-section="carProfileSection">Параметры авто</button> <button class="menu-row" data-menu-section="carProfileSection">Параметры авто</button>
@@ -259,7 +267,8 @@
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button> <button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
<button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button> <button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button>
<button class="menu-row" data-menu-section="connectedServicesSection">Подключённые СТО</button> <button class="menu-row" data-menu-section="connectedServicesSection">Подключённые СТО</button>
<button class="menu-row" data-menu-section="stoCalendarSection">Календарь СТО</button> <button class="menu-row sto-only hidden" data-menu-section="mechanicWorkplaceSection">Рабочее место механика</button>
<button class="menu-row sto-only hidden" data-menu-section="stoCalendarSection">Календарь СТО</button>
<button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button> <button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button>
<button class="menu-row" data-menu-section="settingsSection">Настройки</button> <button class="menu-row" data-menu-section="settingsSection">Настройки</button>
@@ -534,6 +543,18 @@
<div id="stoCalendarList" class="stack-list"></div> <div id="stoCalendarList" class="stack-list"></div>
</section> </section>
<section class="drawer-section hidden" id="mechanicWorkplaceSection">
<div class="section-head">
<div>
<p class="eyebrow">СТО</p>
<h2>Рабочее место механика</h2>
</div>
<select id="mechanicCenterSelect" aria-label="СТО"></select>
</div>
<div id="mechanicDashboardSummary" class="stats mini-stats"></div>
<div id="mechanicWorkplaceList" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="adminSection"> <section class="drawer-section hidden" id="adminSection">
<h2>Модерация СТО</h2> <h2>Модерация СТО</h2>
<div class="tip-card">Заявки видны только администраторам и модераторам.</div> <div class="tip-card">Заявки видны только администраторам и модераторам.</div>

View File

@@ -318,6 +318,9 @@ const state = {
allStats: null, allStats: null,
analytics: null, analytics: null,
serviceCenters: [], serviceCenters: [],
activeServiceCenterId: null,
mechanicAppointments: [],
mechanicWorkOrders: [],
publicServiceCenters: [], publicServiceCenters: [],
appointments: [], appointments: [],
maintenanceRecommendations: [], maintenanceRecommendations: [],
@@ -640,6 +643,10 @@ function selectedCar() {
return state.cars.find((car) => car.id === state.selectedCarId) || null; return state.cars.find((car) => car.id === state.selectedCarId) || null;
} }
function activeServiceCenter() {
return state.serviceCenters.find((center) => center.id === state.activeServiceCenterId) || state.serviceCenters[0] || null;
}
function numberOrNull(value) { function numberOrNull(value) {
return value === "" || value == null ? null : Number(value); return value === "" || value == null ? null : Number(value);
} }
@@ -1024,17 +1031,44 @@ function openCarProfile() {
openDrawerSection("carProfileSection"); openDrawerSection("carProfileSection");
} }
async function loadServiceCenters() { async function loadMyServiceCenters({ withTrust = false } = {}) {
const centers = await api("/service-centers/my"); const centers = await api("/service-centers/my");
state.serviceCenters = await Promise.all( state.serviceCenters = withTrust
centers.map(async (center) => { ? await Promise.all(
try { centers.map(async (center) => {
return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) }; try {
} catch (_) { return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) };
return center; } catch (_) {
} return center;
}), }
); }),
)
: centers;
if (!state.activeServiceCenterId && state.serviceCenters.length) {
state.activeServiceCenterId = state.serviceCenters[0].id;
}
if (state.activeServiceCenterId && !state.serviceCenters.some((center) => center.id === state.activeServiceCenterId)) {
state.activeServiceCenterId = state.serviceCenters[0]?.id || null;
}
renderServiceProfileCard();
return state.serviceCenters;
}
function renderServiceProfileCard() {
const card = document.querySelector("#serviceProfileCard");
if (!card) return;
const hasCenters = state.serviceCenters.length > 0;
card.classList.toggle("hidden", !hasCenters);
document.querySelectorAll(".sto-only").forEach((node) => node.classList.toggle("hidden", !hasCenters));
if (!hasCenters) return;
const center = state.serviceCenters.find((item) => item.id === state.activeServiceCenterId) || state.serviceCenters[0];
const role = serviceRoleLabel(center.employee_role || "owner");
document.querySelector("#serviceProfileTitle").textContent = center.display_name || center.name || "Рабочее место";
document.querySelector("#serviceProfileMeta").textContent = `${role} · ${serviceStatusLabel(center.verification_status)}`;
}
async function loadServiceCenters() {
await loadMyServiceCenters({ withTrust: true });
renderServiceCenters(); renderServiceCenters();
} }
@@ -1418,6 +1452,226 @@ async function loadStoCalendar() {
} }
} }
async function loadMechanicWorkplace() {
const centerSelect = document.querySelector("#mechanicCenterSelect");
const summary = document.querySelector("#mechanicDashboardSummary");
const list = document.querySelector("#mechanicWorkplaceList");
if (!centerSelect || !summary || !list) return;
try {
if (!state.serviceCenters.length) await loadMyServiceCenters();
if (!state.serviceCenters.length) {
centerSelect.innerHTML = "";
summary.innerHTML = "";
list.innerHTML = `<div class="empty">Сначала зарегистрируйте СТО или примите приглашение сотрудника.</div>`;
return;
}
centerSelect.innerHTML = state.serviceCenters
.map((center) => `<option value="${center.id}">${escapeHtml(center.display_name || center.name)}</option>`)
.join("");
centerSelect.value = String(state.activeServiceCenterId || state.serviceCenters[0].id);
const serviceCenterId = Number(centerSelect.value);
const center = state.serviceCenters.find((item) => item.id === serviceCenterId) || state.serviceCenters[0];
state.activeServiceCenterId = serviceCenterId;
renderServiceProfileCard();
const [dashboard, appointments, visits] = await Promise.all([
api(`/sto/dashboard?service_center_id=${serviceCenterId}`).catch(() => null),
api(`/sto/appointments?service_center_id=${serviceCenterId}`).catch(() => []),
api(`/service-centers/${serviceCenterId}/visits`).catch(() => []),
]);
state.mechanicAppointments = appointments.filter((item) =>
["requested", "confirmed", "confirmed_by_sto", "proposed_new_time"].includes(item.status),
);
state.mechanicWorkOrders = visits.filter((item) =>
!["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status),
);
summary.innerHTML = dashboard
? `
<div class="stat-card"><span>Заявки</span><strong>${dashboard.pending_appointments}</strong></div>
<div class="stat-card"><span>Подтверждено</span><strong>${dashboard.confirmed_appointments}</strong></div>
<div class="stat-card"><span>Заказ-наряды</span><strong>${dashboard.active_work_orders}</strong></div>
<div class="stat-card"><span>Авто</span><strong>${dashboard.connected_vehicles}</strong></div>
`
: `<div class="empty">Сводка недоступна</div>`;
const centerNotice = center.verification_status && !["approved", "verified"].includes(center.verification_status)
? `<div class="tip-card">СТО сейчас в статусе «${serviceStatusLabel(center.verification_status)}». Часть действий может быть недоступна до проверки.</div>`
: "";
const appointmentMarkup = state.mechanicAppointments.map(renderMechanicAppointment).join("");
const workOrderMarkup = state.mechanicWorkOrders.map(renderMechanicWorkOrder).join("");
list.innerHTML = `
${centerNotice}
<h3 class="list-heading">Записи</h3>
${appointmentMarkup || `<div class="empty">Новых записей нет</div>`}
<h3 class="list-heading">Заказ-наряды</h3>
${workOrderMarkup || `<div class="empty">Активных заказ-нарядов нет</div>`}
`;
bindMechanicWorkplaceActions(list);
} catch (error) {
summary.innerHTML = "";
list.innerHTML = `<div class="empty">Рабочее место не загрузилось</div>`;
}
}
function renderMechanicAppointment(item) {
const role = activeServiceCenter()?.employee_role || "owner";
const canManageAppointments = ["owner", "manager", "receptionist"].includes(role);
const canCreateWorkOrder = canManageAppointments && ["confirmed", "confirmed_by_sto"].includes(item.status);
return `
<div class="stack-item work-order-card">
<strong>${escapeHtml(item.service_name)}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}</small>
<span class="trust-badge">${appointmentStatusLabel(item.status)}</span>
<div class="row-actions">
${canManageAppointments && item.status === "requested" ? `<button type="button" data-mechanic-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
${canCreateWorkOrder ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
${canManageAppointments ? `<button type="button" class="ghost-btn" data-mechanic-reject-appointment="${item.id}">Отклонить</button>` : ""}
</div>
</div>
`;
}
function renderMechanicWorkOrder(item) {
const role = activeServiceCenter()?.employee_role || "owner";
const canEditItems = ["owner", "manager", "mechanic"].includes(role);
const canStart = ["owner", "manager", "mechanic"].includes(role)
&& ["draft", "diagnosis", "approved_by_owner"].includes(item.status);
const canSubmitApproval = ["owner", "manager", "receptionist"].includes(role)
&& ["draft", "diagnosis", "in_progress", "rejected_by_owner"].includes(item.status);
const canComplete = ["owner", "manager"].includes(role)
&& ["draft", "diagnosis", "approved_by_owner", "in_progress"].includes(item.status);
return `
<div class="stack-item work-order-card">
<div class="work-order-head">
<div>
<strong>${escapeHtml(item.work_order_number || `Заказ-наряд #${item.id}`)}</strong>
<small>${item.visit_date} · авто #${item.vehicle_id} · ${item.odometer || "-"} км</small>
</div>
<span class="trust-badge">${workOrderStatusLabel(item.status)}</span>
</div>
${item.customer_complaint ? `<small>Жалоба: ${escapeHtml(item.customer_complaint)}</small>` : ""}
${item.diagnosis ? `<small>Диагностика: ${escapeHtml(item.diagnosis)}</small>` : ""}
<div class="work-order-totals">
<span>Работы: <strong>${money(item.labor_total || 0)}</strong></span>
<span>Запчасти: <strong>${money(item.product_total || 0)}</strong></span>
<span>Итого: <strong>${money(item.final_total || item.total_cost || 0)}</strong></span>
</div>
${canEditItems ? `<form class="inline-work-form" data-labor-form="${item.id}">
<input name="title" placeholder="Работа" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<button type="submit">+ Работа</button>
</form>
<form class="inline-work-form" data-product-form="${item.id}">
<input name="title" placeholder="Запчасть / материал" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<button type="submit">+ Материал</button>
</form>` : ""}
<div class="row-actions">
${canStart ? `<button type="button" data-start-work-order="${item.id}">В работу</button>` : ""}
${canSubmitApproval ? `<button type="button" data-submit-work-order="${item.id}">На согласование</button>` : ""}
${canComplete ? `<button type="button" class="ghost-btn" data-complete-work-order="${item.id}">Завершить</button>` : ""}
</div>
</div>
`;
}
function bindMechanicWorkplaceActions(root) {
root.querySelectorAll("[data-mechanic-confirm-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
await api(`/sto/appointments/${button.dataset.mechanicConfirmAppointment}/confirm`, {
method: "POST",
body: JSON.stringify({ comment: "Подтверждено в рабочем месте СТО" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-mechanic-reject-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/sto/appointments/${button.dataset.mechanicRejectAppointment}/reject`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в рабочем месте СТО" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-create-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Открываю заказ-наряд...", async () => {
const odometerValue = window.prompt("Пробег на приемке, км") || "";
await api(`/sto/appointments/${button.dataset.createWorkOrder}/create-work-order`, {
method: "POST",
body: JSON.stringify({ odometer: numberOrNull(odometerValue), notes: "Создано в рабочем месте СТО" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-labor-form]").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(form.querySelector('button[type="submit"]'), "Добавляю работу...", async () => {
const data = formData(form);
await api(`/work-orders/${form.dataset.laborForm}/labor-items`, {
method: "POST",
body: JSON.stringify({
title: data.title,
quantity: Number(data.quantity || 1),
unit: "job",
unit_price: Number(data.unit_price || 0),
work_type: "repair",
}),
});
await loadMechanicWorkplace();
});
});
});
root.querySelectorAll("[data-product-form]").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(form.querySelector('button[type="submit"]'), "Добавляю материал...", async () => {
const data = formData(form);
await api(`/work-orders/${form.dataset.productForm}/product-items`, {
method: "POST",
body: JSON.stringify({
title: data.title,
quantity: Number(data.quantity || 1),
unit: "pcs",
unit_price: Number(data.unit_price || 0),
product_type: "part",
}),
});
await loadMechanicWorkplace();
});
});
});
root.querySelectorAll("[data-start-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Запускаю работу...", async () => {
await api(`/work-orders/${button.dataset.startWorkOrder}/start`, {
method: "POST",
body: JSON.stringify({ comment: "Взято в работу" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-submit-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отправляю на согласование...", async () => {
await api(`/work-orders/${button.dataset.submitWorkOrder}/submit-approval`, {
method: "POST",
body: JSON.stringify({ comment: "Смета готова к согласованию" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-complete-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Завершаю заказ-наряд...", async () => {
await api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST",
body: JSON.stringify({ comment: "Работы завершены" }),
});
await loadMechanicWorkplace();
}));
});
}
function trustLabel(level) { function trustLabel(level) {
const labels = { const labels = {
new_service: "Новый сервис", new_service: "Новый сервис",
@@ -1428,6 +1682,71 @@ function trustLabel(level) {
return labels[level] || "Новый сервис"; return labels[level] || "Новый сервис";
} }
function serviceRoleLabel(role) {
const labels = {
owner: "Владелец",
manager: "Менеджер",
receptionist: "Администратор",
mechanic: "Механик",
};
return labels[role] || role || "Сотрудник";
}
function serviceStatusLabel(status) {
const labels = {
draft: "Черновик",
pending: "На проверке",
needs_changes: "Нужны правки",
rejected: "Отклонено",
approved: "Проверено",
verified: "Проверено",
suspended: "Приостановлено",
};
return labels[status] || status || "Статус не указан";
}
function workOrderStatusLabel(status) {
const labels = {
draft: "Черновик",
diagnosis: "Диагностика",
waiting_owner_approval: "Ждет согласования",
approved_by_owner: "Согласован",
rejected_by_owner: "Отклонен клиентом",
in_progress: "В работе",
completed: "Завершен",
cancelled: "Отменен",
archived: "Архив",
pending_owner_confirmation: "Ждет клиента",
confirmed: "Подтвержден",
disputed: "Спор",
};
return labels[status] || status || "Без статуса";
}
function appointmentStatusLabel(status) {
const labels = {
requested: "Новая заявка",
confirmed: "Подтверждена клиентом",
confirmed_by_sto: "Подтверждена СТО",
proposed_new_time: "Предложено другое время",
converted_to_work_order: "Заказ-наряд создан",
completed: "Завершена",
rejected_by_sto: "Отклонена СТО",
cancelled_by_owner: "Отменена владельцем",
cancelled_by_customer: "Отменена клиентом",
cancelled_by_sto: "Отменена СТО",
};
return labels[status] || status || "Без статуса";
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderPlaceholderList(selector, message) { function renderPlaceholderList(selector, message) {
const root = document.querySelector(selector); const root = document.querySelector(selector);
if (root) root.innerHTML = `<div class="empty">${message}</div>`; if (root) root.innerHTML = `<div class="empty">${message}</div>`;
@@ -2260,6 +2579,7 @@ async function openDrawerSection(sectionId, options = {}) {
if (sectionId === "appointmentsSection") await loadAppointments(); if (sectionId === "appointmentsSection") await loadAppointments();
if (sectionId === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations(); if (sectionId === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations();
if (sectionId === "stoCalendarSection") await loadStoCalendar(); if (sectionId === "stoCalendarSection") await loadStoCalendar();
if (sectionId === "mechanicWorkplaceSection") await loadMechanicWorkplace();
if (sectionId === "reviewsSection") renderServiceReviews(); if (sectionId === "reviewsSection") renderServiceReviews();
if (sectionId === "adminSection") await loadAdminPendingServices(); if (sectionId === "adminSection") await loadAdminPendingServices();
if (options.expenseCategory) { if (options.expenseCategory) {
@@ -2329,6 +2649,11 @@ document.querySelectorAll("[data-menu-section]").forEach((button) => {
}); });
}); });
document.querySelector("#mechanicCenterSelect")?.addEventListener("change", async (event) => {
state.activeServiceCenterId = Number(event.currentTarget.value);
await runAction(event.currentTarget, "Обновляю рабочее место...", loadMechanicWorkplace);
});
document.querySelectorAll("[data-expense-preset]").forEach((button) => { document.querySelectorAll("[data-expense-preset]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
openDrawerSection("expensesSection"); openDrawerSection("expensesSection");
@@ -2453,7 +2778,7 @@ Promise.all([loadAuthConfig()])
mountEntryForms(); mountEntryForms();
applyTranslations(); applyTranslations();
initCarCatalog(); initCarCatalog();
return loadCars(); return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]);
}) })
.catch((error) => { .catch((error) => {
if (error.message === "Требуется вход через Telegram") return; if (error.message === "Требуется вход через Telegram") return;

View File

@@ -1171,6 +1171,31 @@ button.is-busy {
color: #0e604f; color: #0e604f;
} }
.service-profile-card {
display: grid;
gap: 10px;
margin: 4px 0 12px;
padding: 12px;
border: 1px solid rgba(18, 115, 95, 0.24);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(18, 115, 95, 0.1), rgba(47, 111, 159, 0.08)),
#fff;
}
.service-profile-card strong {
display: block;
margin-top: 2px;
}
.service-profile-card small {
color: var(--muted);
}
.service-profile-card.hidden {
display: none;
}
.drawer-cars { .drawer-cars {
max-height: 320px; max-height: 320px;
overflow: auto; overflow: auto;
@@ -1466,6 +1491,48 @@ select {
color: var(--muted); color: var(--muted);
} }
.list-heading {
margin: 12px 0 2px;
color: var(--text);
font-size: 14px;
}
.work-order-card {
background: #fff;
}
.work-order-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.work-order-totals {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 6px 0;
color: var(--muted);
font-size: 12px;
}
.inline-work-form {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(74px, 0.6fr) minmax(86px, 0.8fr) auto;
gap: 8px;
align-items: end;
}
.inline-work-form input {
min-height: 36px;
}
.inline-work-form button {
min-height: 36px;
padding: 0 10px;
}
.row-actions { .row-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1650,6 +1717,16 @@ select {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.work-order-head,
.work-order-totals,
.inline-work-form {
grid-template-columns: 1fr;
}
.work-order-head {
display: grid;
}
.auth-overlay { .auth-overlay {
align-items: stretch; align-items: stretch;
padding: 14px; padding: 14px;