This commit is contained in:
@@ -15,7 +15,12 @@ ALLOW_DEV_AUTH=false
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
SECRET_KEY=change-this-long-random-secret
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
OCR_PROVIDER=tesseract
|
||||
OCR_LANGUAGES=eng+rus+kor
|
||||
LLM_BASE_URL=
|
||||
LLM_MODEL=
|
||||
ADMIN_TELEGRAM_IDS=
|
||||
ADMIN_BOOTSTRAP_TOKEN=
|
||||
|
||||
43
.github/workflows/ci.yml
vendored
Normal file
43
.github/workflows/ci.yml
vendored
Normal 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
111
DEPLOY.md
Normal 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.
|
||||
21
README.md
21
README.md
@@ -38,7 +38,15 @@ CarPass — цифровой паспорт автомобиля в Telegram. О
|
||||
|
||||
Если автомобиль уже привязан к СТО, владелец может открыть карточку авто и записаться сразу в календарь этого сервиса. Если привязки нет, пользователь выбирает СТО из каталога, смотрит свободные окна и создает заявку.
|
||||
|
||||
СТО получает уведомление о новой заявке, подтверждает время, отклоняет запись или предлагает другое окно. Когда запись подтверждена, она появляется в календаре СТО. После визита сервис может создать заказ-наряд из записи, провести работы, отправить результат владельцу и обновить историю автомобиля через существующий сценарий подтверждения визита.
|
||||
СТО получает уведомление о новой заявке, подтверждает время, отклоняет запись или предлагает другое окно. Когда запись подтверждена, она появляется в календаре СТО. После этого сервис создает заказ-наряд, добавляет работы, товары, жидкости, запчасти, комментарии и при необходимости отправляет заказ-наряд владельцу на согласование.
|
||||
|
||||
После закрытия заказ-наряда CarPass атомарно создает сервисную запись, расход автомобиля, историю одометра, рекомендации следующего ТО и уведомление владельцу. Завершенная работа появляется в истории автомобиля, а стоимость попадает в стоимость владения без двойного учета.
|
||||
|
||||
## Заказ-наряды СТО
|
||||
|
||||
Заказ-наряд хранит номер, СТО, автомобиль, владельца, сотрудника, пробег, жалобу клиента, диагностику, работы, материалы, рекомендации, комментарии, файлы, суммы работ и товаров, скидку, итог и статус. Поддержаны статусы `draft`, `diagnosis`, `waiting_owner_approval`, `approved_by_owner`, `rejected_by_owner`, `in_progress`, `completed`, `cancelled`, `archived`.
|
||||
|
||||
Завершенные заказ-наряды нельзя редактировать обычным способом. Если после согласования изменилась сумма, заказ-наряд возвращается на согласование владельцу.
|
||||
|
||||
## Рекомендации ТО
|
||||
|
||||
@@ -50,16 +58,27 @@ CarPass создает рекомендации обслуживания из д
|
||||
|
||||
- СТО получает новую заявку на запись, отмену клиента и решение по предложенному времени.
|
||||
- Владелец получает подтверждение, отклонение или предложение нового времени.
|
||||
- Владелец получает уведомления о создании заказ-наряда, ожидании согласования и завершении работы.
|
||||
- Рекомендации ТО фиксируются в истории уведомлений.
|
||||
|
||||
Уведомления имеют статусы `pending`, `processing`, `sent`, `failed`, `retrying`, `abandoned`, `read`, счетчик повторов и idempotency key, чтобы не плодить дубли.
|
||||
|
||||
## Безопасность данных
|
||||
|
||||
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||
|
||||
Чувствительные действия ограничены rate limiting: OCR, VIN/номер, запросы доступа к автомобилю, записи в СТО, приглашения сотрудников и отзывы. В production лимиты работают через Redis, а локально могут падать обратно на in-memory режим.
|
||||
|
||||
## Telegram Mini App
|
||||
|
||||
Mini App открывается через кнопку внутри Telegram-бота. Так Telegram передает защищенную авторизацию, а гараж привязывается к аккаунту пользователя. Если страницу открыть напрямую в браузере, CarPass покажет понятное приглашение открыть приложение через Telegram.
|
||||
|
||||
## Deploy
|
||||
|
||||
Production/pilot deploy описан в [DEPLOY.md](DEPLOY.md). Основной путь обновления сервера: git clone/pull, Docker Compose, Alembic migrations и `/ready` health check. Admin bootstrap выполняется через `ADMIN_TELEGRAM_IDS`, без hardcoded Telegram ID в миграциях.
|
||||
|
||||
Production-контур включает Redis-backed rate limiting, security headers, `/health`, `/ready`, Prometheus-ready `/metrics`, cleanup jobs, backup/restore скрипты и CI-шаблон для lint/tests/migrations/docker build.
|
||||
|
||||
## Команды бота
|
||||
|
||||
- `/start` и `/menu` — правильный вход в Mini App.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""promote requested admin user
|
||||
"""legacy admin bootstrap placeholder
|
||||
|
||||
Revision ID: 202605150001
|
||||
Revises: 202605140002
|
||||
@@ -7,33 +7,15 @@ Create Date: 2026-05-15 05:00:00.000000
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "202605150001"
|
||||
down_revision: str | None = "202605140002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
ADMIN_TELEGRAM_ID = 556399210
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
insert into users (telegram_id, username, platform_role)
|
||||
values ({ADMIN_TELEGRAM_ID}, '{ADMIN_TELEGRAM_ID}', 'admin')
|
||||
on conflict (telegram_id) do update
|
||||
set platform_role = 'admin'
|
||||
"""
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
update users
|
||||
set platform_role = 'user'
|
||||
where telegram_id = {ADMIN_TELEGRAM_ID}
|
||||
and platform_role = 'admin'
|
||||
"""
|
||||
)
|
||||
return None
|
||||
|
||||
210
alembic/versions/202605150003_production_work_orders.py
Normal file
210
alembic/versions/202605150003_production_work_orders.py
Normal 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")
|
||||
114
alembic/versions/202605150004_production_guards.py
Normal file
114
alembic/versions/202605150004_production_guards.py
Normal 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")
|
||||
@@ -1,21 +1,29 @@
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.services.ocr_provider import get_ocr_provider
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
||||
|
||||
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
||||
MAX_OCR_FILE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
class ReceiptSuggestion(BaseModel):
|
||||
entry_date: date | None = None
|
||||
total_cost: Decimal | None = None
|
||||
liters: Decimal | None = None
|
||||
price_per_liter: Decimal | None = None
|
||||
station: str | None = None
|
||||
category: str | None = None
|
||||
confidence: float
|
||||
message: str
|
||||
|
||||
@@ -34,10 +42,20 @@ class OCRResultRead(BaseModel):
|
||||
|
||||
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||
async def parse_text_receipt(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReceiptSuggestion:
|
||||
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
max_bytes=MAX_OCR_FILE_BYTES,
|
||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||
)
|
||||
content_type = (file.content_type or "").lower()
|
||||
if content_type.startswith("image/") or content_type == "application/pdf":
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
@@ -62,6 +80,7 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion:
|
||||
numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)]
|
||||
|
||||
station = detect_station(compact)
|
||||
entry_date = detect_date(compact)
|
||||
liters = find_liters(compact, numbers)
|
||||
price = find_price_per_liter(compact, numbers)
|
||||
total = find_total(compact, numbers, liters, price)
|
||||
@@ -80,10 +99,12 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion:
|
||||
confidence = max(0, min(float(confidence), 0.95))
|
||||
|
||||
return ReceiptSuggestion(
|
||||
entry_date=entry_date,
|
||||
total_cost=total,
|
||||
liters=liters,
|
||||
price_per_liter=price,
|
||||
station=station,
|
||||
category="fuel" if liters or price else None,
|
||||
confidence=round(confidence, 2) if numbers else 0,
|
||||
message=(
|
||||
"Разобрал текст чека и заполнил форму. Проверь значения перед сохранением."
|
||||
@@ -95,18 +116,25 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion:
|
||||
|
||||
@router.post("/fuel-receipt", response_model=ReceiptSuggestion, deprecated=True)
|
||||
async def scan_fuel_receipt(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReceiptSuggestion:
|
||||
return await parse_text_receipt(file, current_user)
|
||||
return await parse_text_receipt(request, file, current_user, session)
|
||||
|
||||
|
||||
@router.post("/license-plate", response_model=OCRResultRead)
|
||||
async def recognize_license_plate(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OCRResultRead:
|
||||
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||
@@ -116,10 +144,15 @@ async def recognize_license_plate(
|
||||
|
||||
@router.post("/vin", response_model=OCRResultRead)
|
||||
async def recognize_vin(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OCRResultRead:
|
||||
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||
@@ -129,10 +162,15 @@ async def recognize_vin(
|
||||
|
||||
@router.post("/service-document", response_model=OCRResultRead)
|
||||
async def recognize_service_document(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OCRResultRead:
|
||||
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||
@@ -158,6 +196,24 @@ def detect_station(text: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def detect_date(text: str) -> date | None:
|
||||
for pattern in (
|
||||
r"\b(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})\b",
|
||||
r"\b(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\b",
|
||||
):
|
||||
match = re.search(pattern, text)
|
||||
if not match:
|
||||
continue
|
||||
first, second, third = [int(item) for item in match.groups()]
|
||||
try:
|
||||
if first > 1900:
|
||||
return date(first, second, third)
|
||||
return date(third, second, first)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def decimal_from_match(match: re.Match[str] | None) -> Decimal | None:
|
||||
if not match:
|
||||
return None
|
||||
@@ -183,9 +239,9 @@ def find_price_per_liter(text: str, numbers: list[Decimal]) -> Decimal | None:
|
||||
]
|
||||
for pattern in patterns:
|
||||
value = decimal_from_match(re.search(pattern, text, re.IGNORECASE))
|
||||
if value and Decimal("10") <= value <= Decimal("500"):
|
||||
if value and Decimal("0.1") <= value <= Decimal("500"):
|
||||
return value
|
||||
candidates = [item for item in numbers if Decimal("10") <= item <= Decimal("500")]
|
||||
candidates = [item for item in numbers if Decimal("0.1") <= item <= Decimal("500")]
|
||||
return candidates[-1] if candidates else None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import UTC, datetime
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -48,6 +49,7 @@ from app.schemas.service_center import (
|
||||
)
|
||||
from app.services.notifications import notify_platform_moderators
|
||||
from app.services.odometer import validate_odometer_change
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.vehicle_identity import mask_license_plate, mask_vin
|
||||
|
||||
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
|
||||
@@ -162,12 +164,17 @@ async def my_service_centers(
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> list[ServiceCenter]:
|
||||
result = await session.execute(
|
||||
select(ServiceCenter)
|
||||
select(ServiceCenter, ServiceEmployee.role, ServiceEmployee.status)
|
||||
.join(ServiceEmployee, ServiceEmployee.service_center_id == ServiceCenter.id)
|
||||
.where(ServiceEmployee.user_id == current_user.id, ServiceEmployee.status == "active")
|
||||
.order_by(ServiceCenter.created_at.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
centers = []
|
||||
for center, role, employee_status in result.all():
|
||||
center.employee_role = role
|
||||
center.employee_status = employee_status
|
||||
centers.append(center)
|
||||
return centers
|
||||
|
||||
|
||||
@router.get("/public", response_model=list[ServiceCenterPublicRead])
|
||||
@@ -253,9 +260,11 @@ async def submit_verification(
|
||||
async def invite_employee(
|
||||
service_center_id: int,
|
||||
payload: ServiceEmployeeInvite,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceEmployee:
|
||||
await check_rate_limit(scope="employee_invite", limit=10, window_seconds=3600, request=request, user=current_user, session=session)
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
|
||||
user = await get_or_create_telegram_user(session, telegram_id=payload.telegram_id)
|
||||
result = await session.execute(
|
||||
@@ -272,18 +281,95 @@ async def invite_employee(
|
||||
role=payload.role,
|
||||
permissions=payload.permissions,
|
||||
status="invited",
|
||||
invite_token=secrets.token_urlsafe(32),
|
||||
invite_expires_at=datetime.now(UTC) + timedelta(hours=payload.expires_in_hours),
|
||||
)
|
||||
session.add(employee)
|
||||
else:
|
||||
employee.role = payload.role
|
||||
employee.permissions = payload.permissions
|
||||
employee.status = "invited"
|
||||
employee.invite_token = secrets.token_urlsafe(32)
|
||||
employee.invite_expires_at = datetime.now(UTC) + timedelta(hours=payload.expires_in_hours)
|
||||
employee.invite_revoked_at = None
|
||||
employee.activated_at = None
|
||||
await log_audit(session, actor=current_user, action="service_employee.invite", target_type="service_center", target_id=service_center_id, metadata={"telegram_id": payload.telegram_id})
|
||||
await session.commit()
|
||||
await session.refresh(employee)
|
||||
return employee
|
||||
|
||||
|
||||
@router.post("/employees/invites/{invite_token}/accept", response_model=ServiceEmployeeRead)
|
||||
async def accept_employee_invite(
|
||||
invite_token: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceEmployee:
|
||||
result = await session.execute(
|
||||
select(ServiceEmployee).where(ServiceEmployee.invite_token == invite_token)
|
||||
)
|
||||
employee = result.scalar_one_or_none()
|
||||
if employee is None:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
if employee.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Invite belongs to another Telegram account")
|
||||
if employee.status != "invited":
|
||||
raise HTTPException(status_code=409, detail="Invite is not active")
|
||||
if employee.invite_revoked_at is not None:
|
||||
raise HTTPException(status_code=409, detail="Invite was revoked")
|
||||
if employee.invite_expires_at:
|
||||
expires_at = employee.invite_expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
else:
|
||||
expires_at = None
|
||||
if expires_at and expires_at <= datetime.now(UTC):
|
||||
employee.status = "expired"
|
||||
await log_audit(session, actor=current_user, action="service_employee.invite_expired", target_type="service_employee", target_id=employee.id)
|
||||
await session.commit()
|
||||
raise HTTPException(status_code=409, detail="Invite expired")
|
||||
employee.status = "active"
|
||||
employee.activated_at = datetime.now(UTC)
|
||||
employee.invite_token = None
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="service_employee.invite_accept",
|
||||
target_type="service_employee",
|
||||
target_id=employee.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(employee)
|
||||
return employee
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/revoke-invite", response_model=ServiceEmployeeRead)
|
||||
async def revoke_employee_invite(
|
||||
employee_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceEmployee:
|
||||
employee = await session.get(ServiceEmployee, employee_id)
|
||||
if employee is None:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
await ensure_service_employee(session, employee.service_center_id, current_user, {"owner", "manager"})
|
||||
if employee.status != "invited":
|
||||
raise HTTPException(status_code=409, detail="Only invited employees can be revoked")
|
||||
employee.status = "revoked"
|
||||
employee.invite_revoked_at = datetime.now(UTC)
|
||||
employee.invite_token = None
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="service_employee.invite_revoke",
|
||||
target_type="service_employee",
|
||||
target_id=employee.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(employee)
|
||||
return employee
|
||||
|
||||
|
||||
@router.get("/{service_center_id}/visits", response_model=list[ServiceVisitRead])
|
||||
async def service_center_visits(
|
||||
service_center_id: int,
|
||||
@@ -355,6 +441,7 @@ async def create_visit(
|
||||
visit = ServiceVisit(
|
||||
service_center_id=service_center_id,
|
||||
vehicle_id=payload.vehicle_id,
|
||||
owner_id=vehicle.owner_id,
|
||||
created_by_employee_id=employee.id,
|
||||
visit_date=payload.visit_date,
|
||||
odometer=payload.odometer,
|
||||
@@ -374,9 +461,11 @@ async def create_visit(
|
||||
async def request_vehicle_access(
|
||||
service_center_id: int,
|
||||
payload: VehicleSearchRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleSearchResult:
|
||||
await check_rate_limit(scope="vehicle_access_request", limit=20, window_seconds=3600, request=request, user=current_user, session=session)
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||
await ensure_service_center_approved(session, service_center_id)
|
||||
stmt = select(Car)
|
||||
@@ -610,9 +699,11 @@ async def service_center_reviews(
|
||||
async def create_service_center_review(
|
||||
service_center_id: int,
|
||||
payload: ServiceCenterReviewCreate,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenterReview:
|
||||
await check_rate_limit(scope="service_review", limit=10, window_seconds=3600, request=request, user=current_user, session=session)
|
||||
await ensure_service_center_approved(session, service_center_id)
|
||||
result = await session.execute(
|
||||
select(ServiceCenterReview).where(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
|
||||
ServiceCenterHolidayRead,
|
||||
STODashboardRead,
|
||||
)
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.sto_booking import (
|
||||
calculate_available_slots,
|
||||
create_service_notification,
|
||||
@@ -46,6 +47,7 @@ from app.services.sto_booking import (
|
||||
money_to_float,
|
||||
notify_service_staff,
|
||||
)
|
||||
from app.services.work_orders import add_status_history, assign_work_order_number
|
||||
|
||||
APPROVED_SERVICE_STATUSES = {"verified", "approved"}
|
||||
|
||||
@@ -173,9 +175,11 @@ async def get_available_slots(
|
||||
@router.post("/appointments", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_appointment(
|
||||
payload: AppointmentCreate,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceAppointment:
|
||||
await check_rate_limit(scope="appointment_create", limit=20, window_seconds=3600, request=request, user=current_user, session=session)
|
||||
await _approved_service_center(session, payload.service_center_id)
|
||||
vehicle = await _owned_vehicle(session, payload.vehicle_id, current_user)
|
||||
duration = estimate_duration(payload.service_type, payload.estimated_duration_minutes)
|
||||
@@ -253,15 +257,15 @@ async def cancel_appointment(
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceAppointment:
|
||||
appointment = await _appointment_for_owner(session, appointment_id, current_user)
|
||||
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
|
||||
if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}:
|
||||
raise HTTPException(status_code=409, detail="Appointment cannot be cancelled")
|
||||
appointment.status = "cancelled_by_customer"
|
||||
appointment.status = "cancelled_by_owner"
|
||||
appointment.cancelled_at = datetime.now(UTC)
|
||||
appointment.cancellation_reason = payload.reason
|
||||
await notify_service_staff(
|
||||
session,
|
||||
service_center_id=appointment.service_center_id,
|
||||
notification_type="appointment.cancelled_by_customer",
|
||||
notification_type="appointment.cancelled_by_owner",
|
||||
title="Клиент отменил запись",
|
||||
body=payload.reason,
|
||||
appointment_id=appointment.id,
|
||||
@@ -316,7 +320,7 @@ async def reject_proposed_time(
|
||||
appointment = await _appointment_for_owner(session, appointment_id, current_user)
|
||||
if appointment.status != "proposed_new_time":
|
||||
raise HTTPException(status_code=409, detail="Appointment has no proposed time")
|
||||
appointment.status = "rejected"
|
||||
appointment.status = "rejected_by_sto"
|
||||
appointment.service_center_comment = payload.comment
|
||||
await notify_service_staff(
|
||||
session,
|
||||
@@ -365,13 +369,13 @@ async def get_sto_dashboard(
|
||||
confirmed_appointments = int(
|
||||
(await session.execute(select(func.count(ServiceAppointment.id)).where(
|
||||
ServiceAppointment.service_center_id == service_center_id,
|
||||
ServiceAppointment.status == "confirmed",
|
||||
ServiceAppointment.status.in_(["confirmed", "confirmed_by_sto"]),
|
||||
))).scalar_one() or 0
|
||||
)
|
||||
active_work_orders = int(
|
||||
(await session.execute(select(func.count(ServiceVisit.id)).where(
|
||||
ServiceVisit.service_center_id == service_center_id,
|
||||
ServiceVisit.status.in_(["draft", "pending_owner_confirmation"]),
|
||||
ServiceVisit.status.in_(["draft", "diagnosis", "waiting_owner_approval", "approved_by_owner", "in_progress", "pending_owner_confirmation"]),
|
||||
))).scalar_one() or 0
|
||||
)
|
||||
completed_result = await session.execute(
|
||||
@@ -465,7 +469,7 @@ async def confirm_appointment(
|
||||
duration_minutes=appointment.estimated_duration_minutes,
|
||||
exclude_appointment_id=appointment.id,
|
||||
)
|
||||
appointment.status = "confirmed"
|
||||
appointment.status = "confirmed_by_sto"
|
||||
appointment.confirmed_start_at = appointment.requested_start_at
|
||||
appointment.confirmed_end_at = appointment.requested_end_at
|
||||
appointment.service_center_comment = payload.comment
|
||||
@@ -492,9 +496,9 @@ async def reject_appointment(
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceAppointment:
|
||||
appointment = await _appointment_for_sto(session, appointment_id, current_user)
|
||||
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
|
||||
if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}:
|
||||
raise HTTPException(status_code=409, detail="Appointment cannot be rejected")
|
||||
appointment.status = "rejected"
|
||||
appointment.status = "rejected_by_sto"
|
||||
appointment.service_center_comment = payload.comment
|
||||
await create_service_notification(
|
||||
session,
|
||||
@@ -519,7 +523,7 @@ async def propose_appointment_time(
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceAppointment:
|
||||
appointment = await _appointment_for_sto(session, appointment_id, current_user)
|
||||
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
|
||||
if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}:
|
||||
raise HTTPException(status_code=409, detail="Appointment cannot be changed")
|
||||
duration = estimate_duration(appointment.service_type, payload.estimated_duration_minutes or appointment.estimated_duration_minutes)
|
||||
proposed_start = _utc(payload.proposed_start_at)
|
||||
@@ -559,7 +563,7 @@ async def create_work_order_from_appointment(
|
||||
) -> ServiceVisit:
|
||||
appointment = await _appointment_for_sto(session, appointment_id, current_user)
|
||||
employee = await ensure_service_employee(session, appointment.service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||
if appointment.status != "confirmed":
|
||||
if appointment.status not in {"confirmed", "confirmed_by_sto"}:
|
||||
raise HTTPException(status_code=409, detail="Only confirmed appointment can become work order")
|
||||
if appointment.linked_work_order_id:
|
||||
visit = await session.get(ServiceVisit, appointment.linked_work_order_id)
|
||||
@@ -569,15 +573,32 @@ async def create_work_order_from_appointment(
|
||||
visit = ServiceVisit(
|
||||
service_center_id=appointment.service_center_id,
|
||||
vehicle_id=appointment.vehicle_id,
|
||||
owner_id=appointment.owner_id,
|
||||
created_by_employee_id=employee.id,
|
||||
assigned_employee_id=employee.id,
|
||||
visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(),
|
||||
odometer=payload.odometer,
|
||||
status="draft",
|
||||
customer_complaint=appointment.customer_comment,
|
||||
notes=payload.notes or appointment.customer_comment,
|
||||
opened_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(visit)
|
||||
await session.flush()
|
||||
await assign_work_order_number(session, visit)
|
||||
await add_status_history(session, visit, to_status="diagnosis", actor=current_user, comment="Created from appointment")
|
||||
appointment.linked_work_order_id = visit.id
|
||||
appointment.status = "converted_to_work_order"
|
||||
await create_service_notification(
|
||||
session,
|
||||
recipient_user_id=appointment.owner_id,
|
||||
service_center_id=appointment.service_center_id,
|
||||
appointment_id=appointment.id,
|
||||
notification_type="work_order.created",
|
||||
title="СТО создало заказ-наряд",
|
||||
body=visit.work_order_number,
|
||||
idempotency_key=f"work_order:{visit.id}:created",
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.schemas.user import (
|
||||
UserUpsert,
|
||||
WebAppAuthRequest,
|
||||
)
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.telegram_auth import verify_login_widget, verify_webapp_init_data
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
@@ -56,8 +57,11 @@ async def auth_config() -> AuthConfig:
|
||||
|
||||
@router.post("/webapp-auth", response_model=UserRead)
|
||||
async def webapp_auth(
|
||||
payload: WebAppAuthRequest, session: AsyncSession = Depends(get_session)
|
||||
payload: WebAppAuthRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
await check_rate_limit(scope="auth_webapp", limit=30, window_seconds=60, request=request, session=session)
|
||||
user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
|
||||
telegram_id = int(user_data["id"])
|
||||
return await get_or_create_telegram_user(
|
||||
@@ -72,8 +76,11 @@ async def webapp_auth(
|
||||
|
||||
@router.post("/telegram-login", response_model=UserRead)
|
||||
async def telegram_login(
|
||||
payload: TelegramLoginRequest, session: AsyncSession = Depends(get_session)
|
||||
payload: TelegramLoginRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
await check_rate_limit(scope="auth_login", limit=12, window_seconds=60, request=request, session=session)
|
||||
values = verify_login_widget(payload.model_dump(), settings.bot_token)
|
||||
telegram_id = int(values["id"])
|
||||
return await get_or_create_telegram_user(
|
||||
|
||||
337
app/api/work_orders.py
Normal file
337
app/api/work_orders.py
Normal 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())
|
||||
@@ -16,11 +16,16 @@ class Settings(BaseSettings):
|
||||
cors_origins: str = ""
|
||||
internal_api_token: str = ""
|
||||
vapid_public_key: str = ""
|
||||
vapid_private_key: str = ""
|
||||
secret_key: str = ""
|
||||
redis_url: str = ""
|
||||
allow_dev_auth: bool = False
|
||||
ocr_provider: str = "tesseract"
|
||||
ocr_languages: str = "eng+rus+kor"
|
||||
llm_base_url: str = ""
|
||||
llm_model: str = ""
|
||||
admin_telegram_ids: str = ""
|
||||
admin_bootstrap_token: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@@ -36,6 +41,16 @@ class Settings(BaseSettings):
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
@property
|
||||
def admin_telegram_id_list(self) -> list[int]:
|
||||
values: list[int] = []
|
||||
for item in self.admin_telegram_ids.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
values.append(int(item))
|
||||
return values
|
||||
|
||||
def validate_webapp_url_for_telegram(self) -> None:
|
||||
url = self.effective_webapp_url
|
||||
if self.is_production and not url.startswith("https://"):
|
||||
@@ -44,6 +59,25 @@ class Settings(BaseSettings):
|
||||
if self.is_production and any(item in url for item in forbidden):
|
||||
raise RuntimeError("Telegram Mini App URL must not use localhost, internal IP, or http://")
|
||||
|
||||
def validate_production_settings(self) -> None:
|
||||
if not self.is_production:
|
||||
return
|
||||
if self.allow_dev_auth:
|
||||
raise RuntimeError("ALLOW_DEV_AUTH must be false in production")
|
||||
if not self.bot_token or self.bot_token == "change-me":
|
||||
raise RuntimeError("BOT_TOKEN is required in production")
|
||||
if not self.internal_api_token or self.internal_api_token.startswith("change-"):
|
||||
raise RuntimeError("INTERNAL_API_TOKEN must be a real secret in production")
|
||||
if not self.secret_key or self.secret_key.startswith("change-"):
|
||||
raise RuntimeError("SECRET_KEY must be configured in production")
|
||||
if not self.redis_url:
|
||||
raise RuntimeError("REDIS_URL is required in production for rate limiting and queues")
|
||||
if bool(self.vapid_public_key) != bool(self.vapid_private_key):
|
||||
raise RuntimeError("VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together")
|
||||
if not self.cors_origin_list:
|
||||
raise RuntimeError("CORS_ORIGINS is required in production")
|
||||
self.validate_webapp_url_for_telegram()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
92
app/main.py
92
app/main.py
@@ -1,6 +1,12 @@
|
||||
from fastapi import FastAPI
|
||||
from contextlib import asynccontextmanager
|
||||
from time import monotonic
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api import (
|
||||
admin,
|
||||
@@ -16,10 +22,54 @@ from app.api import (
|
||||
service_visits,
|
||||
sto_booking,
|
||||
users,
|
||||
work_orders,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.services.rate_limit import get_redis_client
|
||||
|
||||
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.validate_production_settings()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Drivers Bot API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
REQUEST_COUNT = 0
|
||||
REQUEST_ERRORS = 0
|
||||
REQUEST_DURATION_TOTAL = 0.0
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def production_headers_and_metrics(request: Request, call_next):
|
||||
global REQUEST_COUNT, REQUEST_DURATION_TOTAL, REQUEST_ERRORS
|
||||
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||
start = monotonic()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
REQUEST_ERRORS += 1
|
||||
raise
|
||||
duration = monotonic() - start
|
||||
REQUEST_COUNT += 1
|
||||
REQUEST_DURATION_TOTAL += duration
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
if settings.is_production:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self' https://telegram.org https://*.telegram.org; "
|
||||
"connect-src 'self' https://api.telegram.org; "
|
||||
"img-src 'self' data: https:; "
|
||||
"script-src 'self' 'unsafe-inline' https://telegram.org https://*.telegram.org; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
return response
|
||||
|
||||
dev_origins = ["http://localhost:8000", "http://127.0.0.1:8000"] if not settings.is_production else []
|
||||
cors_origins = settings.cors_origin_list or dev_origins
|
||||
@@ -43,6 +93,7 @@ app.include_router(parser.router, prefix="/api")
|
||||
app.include_router(service_centers.router, prefix="/api")
|
||||
app.include_router(sto_booking.router, prefix="/api")
|
||||
app.include_router(service_visits.router, prefix="/api")
|
||||
app.include_router(work_orders.router, prefix="/api")
|
||||
app.include_router(change_requests.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
|
||||
@@ -52,4 +103,41 @@ async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/metrics")
|
||||
async def metrics() -> Response:
|
||||
avg = REQUEST_DURATION_TOTAL / REQUEST_COUNT if REQUEST_COUNT else 0
|
||||
body = "\n".join(
|
||||
[
|
||||
"# TYPE carpass_requests_total counter",
|
||||
f"carpass_requests_total {REQUEST_COUNT}",
|
||||
"# TYPE carpass_request_errors_total counter",
|
||||
f"carpass_request_errors_total {REQUEST_ERRORS}",
|
||||
"# TYPE carpass_request_duration_seconds_avg gauge",
|
||||
f"carpass_request_duration_seconds_avg {avg:.6f}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return Response(body, media_type="text/plain; version=0.0.4")
|
||||
|
||||
|
||||
@app.get("/ready")
|
||||
async def ready(session: AsyncSession = Depends(get_session)) -> dict[str, str]:
|
||||
await session.execute(text("select 1"))
|
||||
migration = "unknown"
|
||||
try:
|
||||
version = await session.execute(text("select version_num from alembic_version limit 1"))
|
||||
migration = version.scalar_one_or_none() or "unknown"
|
||||
except Exception:
|
||||
migration = "not_checked"
|
||||
redis_status = "disabled"
|
||||
if settings.redis_url:
|
||||
redis = await get_redis_client()
|
||||
if redis is None:
|
||||
redis_status = "client_missing"
|
||||
else:
|
||||
await redis.ping()
|
||||
redis_status = "ok"
|
||||
return {"status": "ready", "database": "ok", "redis": redis_status, "migration": migration}
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||
|
||||
@@ -264,6 +264,10 @@ class ServiceEmployee(Base):
|
||||
role: Mapped[str] = mapped_column(String(32), default="receptionist", server_default="receptionist", index=True)
|
||||
permissions: Mapped[dict | None] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
|
||||
invite_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True)
|
||||
invite_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
invite_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="employees")
|
||||
@@ -311,15 +315,35 @@ class ServiceVisit(Base):
|
||||
__tablename__ = "service_visits"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
work_order_number: Mapped[str | None] = mapped_column(String(40), unique=True, index=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
created_by_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
|
||||
assigned_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
|
||||
visit_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
odometer: Mapped[int | None]
|
||||
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
|
||||
customer_complaint: Mapped[str | None] = mapped_column(Text)
|
||||
diagnosis: Mapped[str | None] = mapped_column(Text)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
service_comment: Mapped[str | None] = mapped_column(Text)
|
||||
owner_comment: Mapped[str | None] = mapped_column(Text)
|
||||
recommendations_text: Mapped[str | None] = mapped_column(Text)
|
||||
attachment_urls: Mapped[list | None] = mapped_column(JSON)
|
||||
total_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
labor_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
product_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
discount_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
final_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
price_approved_total: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
approval_required: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
completed_snapshot: Mapped[dict | None] = mapped_column(JSON)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||
opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
owner_resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
@@ -328,6 +352,9 @@ class ServiceVisit(Base):
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="visits")
|
||||
work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan")
|
||||
product_items = relationship("ServiceProductItem", back_populates="visit", cascade="all, delete-orphan")
|
||||
status_history = relationship("WorkOrderStatusHistory", back_populates="visit", cascade="all, delete-orphan")
|
||||
corrections = relationship("WorkOrderCorrection", back_populates="visit", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MaintenanceRecommendation(Base):
|
||||
@@ -395,7 +422,12 @@ class ServiceNotification(Base):
|
||||
notification_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
body: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||
retry_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(160), unique=True, index=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
@@ -406,7 +438,13 @@ class ServiceWorkItem(Base):
|
||||
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
|
||||
work_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
category: Mapped[str | None] = mapped_column(String(80))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=1, server_default="1")
|
||||
unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs")
|
||||
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
discount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
total: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
parts: Mapped[list | None] = mapped_column(JSON)
|
||||
oil_brand: Mapped[str | None] = mapped_column(String(80))
|
||||
oil_viscosity: Mapped[str | None] = mapped_column(String(40))
|
||||
@@ -414,11 +452,85 @@ class ServiceWorkItem(Base):
|
||||
next_due_odometer: Mapped[int | None]
|
||||
next_due_date: Mapped[date | None] = mapped_column(Date)
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
warranty_days: Mapped[int | None] = mapped_column(Integer)
|
||||
warranty_odometer_km: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
visit = relationship("ServiceVisit", back_populates="work_items")
|
||||
|
||||
|
||||
class ServiceProductItem(Base):
|
||||
__tablename__ = "service_product_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
category: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||
product_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True)
|
||||
brand: Mapped[str | None] = mapped_column(String(80))
|
||||
sku: Mapped[str | None] = mapped_column(String(120))
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=1, server_default="1")
|
||||
unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs")
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
discount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
volume: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
|
||||
viscosity: Mapped[str | None] = mapped_column(String(40))
|
||||
specification: Mapped[str | None] = mapped_column(String(120))
|
||||
used_volume: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
visit = relationship("ServiceVisit", back_populates="product_items")
|
||||
|
||||
|
||||
class WorkOrderStatusHistory(Base):
|
||||
__tablename__ = "work_order_status_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
|
||||
from_status: Mapped[str | None] = mapped_column(String(40))
|
||||
to_status: Mapped[str] = mapped_column(String(40), index=True)
|
||||
changed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
visit = relationship("ServiceVisit", back_populates="status_history")
|
||||
|
||||
|
||||
class WorkOrderCorrection(Base):
|
||||
__tablename__ = "work_order_corrections"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
|
||||
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
reason: Mapped[str] = mapped_column(Text)
|
||||
proposed_changes: Mapped[dict | None] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||
owner_approval_required: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
created_version: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
visit = relationship("ServiceVisit", back_populates="corrections")
|
||||
|
||||
|
||||
class InventoryTransaction(Base):
|
||||
__tablename__ = "inventory_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
|
||||
product_item_id: Mapped[int | None] = mapped_column(ForeignKey("service_product_items.id", ondelete="SET NULL"), index=True)
|
||||
transaction_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
sku: Mapped[str | None] = mapped_column(String(120), index=True)
|
||||
title: Mapped[str | None] = mapped_column(String(180))
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=0, server_default="0")
|
||||
unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs")
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class ServiceCenterReview(Base):
|
||||
__tablename__ = "service_center_reviews"
|
||||
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),)
|
||||
|
||||
@@ -66,6 +66,7 @@ class ServiceEntry(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
|
||||
entry_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
odometer: Mapped[int | None]
|
||||
service_type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
|
||||
@@ -86,6 +87,7 @@ class ExpenseEntry(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
|
||||
entry_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
category: Mapped[ExpenseCategory] = mapped_column(Enum(ExpenseCategory), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
|
||||
@@ -30,6 +30,8 @@ class ServiceCenterRead(ServiceCenterCreate):
|
||||
name: str
|
||||
verification_status: str
|
||||
owner_user_id: int | None = None
|
||||
employee_role: str | None = None
|
||||
employee_status: str | None = None
|
||||
created_at: datetime
|
||||
verified_at: datetime | None = None
|
||||
suspended_at: datetime | None = None
|
||||
@@ -78,6 +80,7 @@ class ServiceEmployeeInvite(BaseModel):
|
||||
telegram_id: int
|
||||
role: str = "receptionist"
|
||||
permissions: dict | None = None
|
||||
expires_in_hours: int = Field(default=72, ge=0, le=720)
|
||||
|
||||
|
||||
class ServiceEmployeeRead(BaseModel):
|
||||
@@ -87,6 +90,10 @@ class ServiceEmployeeRead(BaseModel):
|
||||
role: str
|
||||
permissions: dict | None = None
|
||||
status: str
|
||||
invite_token: str | None = None
|
||||
invite_expires_at: datetime | None = None
|
||||
invite_revoked_at: datetime | None = None
|
||||
activated_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -284,8 +291,27 @@ class ServiceVisitCreate(BaseModel):
|
||||
class ServiceVisitRead(ServiceVisitCreate):
|
||||
id: int
|
||||
service_center_id: int
|
||||
work_order_number: str | None = None
|
||||
owner_id: int | None = None
|
||||
created_by_employee_id: int | None = None
|
||||
assigned_employee_id: int | None = None
|
||||
status: str
|
||||
customer_complaint: str | None = None
|
||||
diagnosis: str | None = None
|
||||
service_comment: str | None = None
|
||||
owner_comment: str | None = None
|
||||
recommendations_text: str | None = None
|
||||
attachment_urls: list[str] | None = None
|
||||
labor_total: Decimal = Decimal("0")
|
||||
product_total: Decimal = Decimal("0")
|
||||
discount_total: Decimal = Decimal("0")
|
||||
final_total: Decimal = Decimal("0")
|
||||
approval_required: bool = False
|
||||
version: int = 1
|
||||
completed_snapshot: dict | None = None
|
||||
opened_at: datetime | None = None
|
||||
approved_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
owner_resolved_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -295,7 +321,12 @@ class ServiceVisitRead(ServiceVisitCreate):
|
||||
class ServiceWorkItemCreate(BaseModel):
|
||||
work_type: str = "other"
|
||||
title: str
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
quantity: Decimal = Decimal("1")
|
||||
unit: str = "pcs"
|
||||
unit_price: Decimal | None = None
|
||||
discount: Decimal = Decimal("0")
|
||||
parts: list[dict] | None = None
|
||||
oil_brand: str | None = None
|
||||
oil_viscosity: str | None = None
|
||||
@@ -303,11 +334,108 @@ class ServiceWorkItemCreate(BaseModel):
|
||||
next_due_odometer: int | None = None
|
||||
next_due_date: date | None = None
|
||||
price: Decimal | None = None
|
||||
warranty_days: int | None = None
|
||||
warranty_odometer_km: int | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_item(self) -> "ServiceWorkItemCreate":
|
||||
if self.quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
if self.discount < 0:
|
||||
raise ValueError("discount must be non-negative")
|
||||
if self.unit_price is not None and self.unit_price < 0:
|
||||
raise ValueError("unit_price must be non-negative")
|
||||
if self.price is not None and self.price < 0:
|
||||
raise ValueError("price must be non-negative")
|
||||
return self
|
||||
|
||||
|
||||
class ServiceWorkItemRead(ServiceWorkItemCreate):
|
||||
id: int
|
||||
service_visit_id: int
|
||||
total: Decimal | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceProductItemCreate(BaseModel):
|
||||
title: str
|
||||
category: str | None = None
|
||||
product_type: str = "other"
|
||||
brand: str | None = None
|
||||
sku: str | None = None
|
||||
quantity: Decimal = Decimal("1")
|
||||
unit: str = "pcs"
|
||||
unit_price: Decimal = Decimal("0")
|
||||
discount: Decimal = Decimal("0")
|
||||
volume: Decimal | None = None
|
||||
viscosity: str | None = None
|
||||
specification: str | None = None
|
||||
used_volume: Decimal | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_product(self) -> "ServiceProductItemCreate":
|
||||
if self.quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
if self.unit_price < 0 or self.discount < 0:
|
||||
raise ValueError("price and discount must be non-negative")
|
||||
return self
|
||||
|
||||
|
||||
class ServiceProductItemRead(ServiceProductItemCreate):
|
||||
id: int
|
||||
service_visit_id: int
|
||||
total: Decimal
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkOrderUpdate(BaseModel):
|
||||
odometer: int | None = None
|
||||
assigned_employee_id: int | None = None
|
||||
customer_complaint: str | None = None
|
||||
diagnosis: str | None = None
|
||||
notes: str | None = None
|
||||
service_comment: str | None = None
|
||||
owner_comment: str | None = None
|
||||
recommendations_text: str | None = None
|
||||
attachment_urls: list[str] | None = None
|
||||
discount_total: Decimal | None = None
|
||||
approval_required: bool | None = None
|
||||
|
||||
|
||||
class WorkOrderDecision(BaseModel):
|
||||
comment: str | None = None
|
||||
confirm_lower_odometer: bool = False
|
||||
|
||||
|
||||
class WorkOrderStatusHistoryRead(BaseModel):
|
||||
id: int
|
||||
service_visit_id: int
|
||||
from_status: str | None = None
|
||||
to_status: str
|
||||
changed_by_user_id: int | None = None
|
||||
comment: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkOrderCorrectionCreate(BaseModel):
|
||||
reason: str = Field(min_length=3, max_length=4000)
|
||||
proposed_changes: dict | None = None
|
||||
owner_approval_required: bool = True
|
||||
|
||||
|
||||
class WorkOrderCorrectionRead(WorkOrderCorrectionCreate):
|
||||
id: int
|
||||
service_visit_id: int
|
||||
requested_by_user_id: int | None = None
|
||||
status: str
|
||||
created_version: int
|
||||
resolved_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -6,10 +6,14 @@ APPOINTMENT_STATUSES = {
|
||||
"draft",
|
||||
"requested",
|
||||
"confirmed",
|
||||
"confirmed_by_sto",
|
||||
"proposed_new_time",
|
||||
"rejected",
|
||||
"rejected_by_sto",
|
||||
"cancelled_by_owner",
|
||||
"cancelled_by_customer",
|
||||
"cancelled_by_sto",
|
||||
"converted_to_work_order",
|
||||
"completed",
|
||||
"no_show",
|
||||
}
|
||||
|
||||
@@ -260,6 +260,7 @@ async def expense_period_totals(
|
||||
.where(
|
||||
ExpenseEntry.car_id == car_id,
|
||||
ExpenseEntry.entry_date <= date_to,
|
||||
ExpenseEntry.service_visit_id.is_(None),
|
||||
)
|
||||
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
|
||||
)
|
||||
|
||||
@@ -1,27 +1,70 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.car import ServiceNotification
|
||||
from app.models.user import User
|
||||
|
||||
MODERATOR_ROLES = {"admin", "verifier", "moderator"}
|
||||
|
||||
|
||||
async def notify_user(user: User, text: str) -> None:
|
||||
async def notify_user(user: User, text: str) -> bool:
|
||||
if not settings.bot_token or settings.app_env == "test":
|
||||
return
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
await client.post(
|
||||
response = await client.post(
|
||||
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
|
||||
data={"chat_id": str(user.telegram_id), "text": text},
|
||||
)
|
||||
return response.status_code < 400
|
||||
except Exception:
|
||||
return
|
||||
return False
|
||||
|
||||
|
||||
async def notify_platform_moderators(session: AsyncSession, text: str) -> None:
|
||||
result = await session.execute(select(User).where(User.platform_role.in_(MODERATOR_ROLES)))
|
||||
for user in result.scalars():
|
||||
await notify_user(user, text)
|
||||
|
||||
|
||||
async def retry_failed_notifications(session: AsyncSession, *, limit: int = 50) -> int:
|
||||
return await process_notification_queue(session, limit=limit)
|
||||
|
||||
|
||||
async def process_notification_queue(session: AsyncSession, *, limit: int = 50) -> int:
|
||||
now = datetime.now(UTC)
|
||||
result = await session.execute(
|
||||
select(ServiceNotification)
|
||||
.where(
|
||||
ServiceNotification.status.in_(["pending", "failed", "retrying"]),
|
||||
ServiceNotification.retry_count < 5,
|
||||
)
|
||||
.order_by(ServiceNotification.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
delivered = 0
|
||||
for notification in result.scalars():
|
||||
if notification.status == "retrying" and notification.created_at > now - timedelta(seconds=30):
|
||||
continue
|
||||
notification.status = "processing"
|
||||
user = await session.get(User, notification.recipient_user_id)
|
||||
if user is None:
|
||||
notification.status = "abandoned"
|
||||
notification.last_error = "recipient_not_found"
|
||||
continue
|
||||
ok = await notify_user(user, f"{notification.title}\n{notification.body}" if notification.body else notification.title)
|
||||
notification.retry_count += 1
|
||||
if ok:
|
||||
notification.status = "sent"
|
||||
notification.sent_at = datetime.now(UTC)
|
||||
notification.last_error = None
|
||||
delivered += 1
|
||||
else:
|
||||
notification.status = "abandoned" if notification.retry_count >= 5 else "retrying"
|
||||
notification.last_error = "telegram_delivery_failed"
|
||||
await session.commit()
|
||||
return delivered
|
||||
|
||||
@@ -50,17 +50,35 @@ class TesseractOCRProvider:
|
||||
def _recognize_sync(self, content: bytes) -> str:
|
||||
try:
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
except ImportError:
|
||||
return ""
|
||||
try:
|
||||
image = Image.open(BytesIO(content))
|
||||
except Exception:
|
||||
return ""
|
||||
candidates = [image]
|
||||
try:
|
||||
return pytesseract.image_to_string(image, lang=settings.ocr_languages)
|
||||
grayscale = ImageOps.grayscale(image)
|
||||
resized = grayscale.resize((grayscale.width * 2, grayscale.height * 2))
|
||||
contrast = ImageEnhance.Contrast(resized).enhance(1.8)
|
||||
threshold = contrast.point(lambda pixel: 255 if pixel > 165 else 0)
|
||||
candidates.extend([grayscale, contrast, threshold])
|
||||
except Exception:
|
||||
return pytesseract.image_to_string(image)
|
||||
candidates = [image]
|
||||
recognized: list[str] = []
|
||||
for candidate in candidates:
|
||||
for config in ("--psm 6", "--psm 11"):
|
||||
try:
|
||||
text = pytesseract.image_to_string(candidate, lang=settings.ocr_languages, config=config)
|
||||
except Exception:
|
||||
try:
|
||||
text = pytesseract.image_to_string(candidate, config=config)
|
||||
except Exception:
|
||||
text = ""
|
||||
if text.strip():
|
||||
recognized.append(text)
|
||||
return "\n".join(recognized)
|
||||
|
||||
|
||||
class CompositeOCRProvider:
|
||||
|
||||
124
app/services/rate_limit.py
Normal file
124
app/services/rate_limit.py
Normal 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},
|
||||
)
|
||||
)
|
||||
@@ -21,7 +21,7 @@ from app.models.expense import ServiceEntry
|
||||
from app.models.user import User
|
||||
from app.services.notifications import notify_user
|
||||
|
||||
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "proposed_new_time"}
|
||||
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "confirmed_by_sto", "proposed_new_time"}
|
||||
DEFAULT_SERVICE_DURATIONS = {
|
||||
"oil_change": 60,
|
||||
"diagnostics": 60,
|
||||
@@ -190,7 +190,16 @@ async def create_service_notification(
|
||||
service_center_id: int | None = None,
|
||||
appointment_id: int | None = None,
|
||||
send_telegram: bool = True,
|
||||
idempotency_key: str | None = None,
|
||||
) -> ServiceNotification:
|
||||
if idempotency_key:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ServiceNotification).where(ServiceNotification.idempotency_key == idempotency_key)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
notification = ServiceNotification(
|
||||
recipient_user_id=recipient_user_id,
|
||||
service_center_id=service_center_id,
|
||||
@@ -198,12 +207,21 @@ async def create_service_notification(
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
body=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
session.add(notification)
|
||||
if send_telegram:
|
||||
user = await session.get(User, recipient_user_id)
|
||||
if user is not None:
|
||||
await notify_user(user, f"{title}\n{body}" if body else title)
|
||||
notification.status = "processing"
|
||||
delivered = await notify_user(user, f"{title}\n{body}" if body else title)
|
||||
if delivered:
|
||||
notification.status = "sent"
|
||||
notification.sent_at = datetime.now(UTC)
|
||||
else:
|
||||
notification.status = "retrying"
|
||||
notification.retry_count = 1
|
||||
notification.last_error = "telegram_delivery_failed"
|
||||
return notification
|
||||
|
||||
|
||||
|
||||
54
app/services/uploads.py
Normal file
54
app/services/uploads.py
Normal 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
315
app/services/work_orders.py
Normal 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
|
||||
@@ -521,8 +521,9 @@ async def help_message(message: Message) -> None:
|
||||
"• /appointments — мои записи в СТО;\n"
|
||||
"• /sto_bookings — заявки и календарь для владельца СТО;\n"
|
||||
"• /register_sto — заявка на СТО.\n\n"
|
||||
"Для ТО: в карточке авто Mini App показывает рекомендации, доступные СТО, свободные окна, запись и согласование времени.\n"
|
||||
"Для СТО: настрой график, принимай заявки, создавай заказ-наряд из подтвержденной записи и отправляй клиенту результат работ.\n\n"
|
||||
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
|
||||
"СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n"
|
||||
"Безопасность: СТО видит автомобиль только после подтверждения владельца, а спорные изменения VIN, номера и пробега идут через согласование.\n\n"
|
||||
"Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.",
|
||||
reply_markup=menu_inline_keyboard(),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-drivers}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-drivers}
|
||||
@@ -14,9 +15,28 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
logging: *default-logging
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
env_file:
|
||||
- path: .env
|
||||
@@ -36,14 +56,27 @@ services:
|
||||
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
SECRET_KEY: ${SECRET_KEY:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
logging: *default-logging
|
||||
|
||||
bot:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
command: python -m bot.main
|
||||
env_file:
|
||||
- path: .env
|
||||
@@ -60,8 +93,13 @@ services:
|
||||
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
SECRET_KEY: ${SECRET_KEY:-}
|
||||
depends_on:
|
||||
- api
|
||||
api:
|
||||
condition: service_healthy
|
||||
logging: *default-logging
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
|
||||
@@ -11,9 +11,11 @@ dependencies = [
|
||||
"httpx>=0.27,<1.0",
|
||||
"matplotlib>=3.8,<4.0",
|
||||
"pandas>=2.2,<3.0",
|
||||
"pillow>=10.0,<12.0",
|
||||
"pydantic-settings>=2.2,<3.0",
|
||||
"pytesseract>=0.3.13,<1.0",
|
||||
"python-multipart>=0.0.9,<1.0",
|
||||
"redis>=5.0,<6.0",
|
||||
"sqlalchemy[asyncio]>=2.0,<3.0",
|
||||
"uvicorn[standard]>=0.29,<1.0",
|
||||
]
|
||||
|
||||
16
scripts/backup_db.sh
Executable file
16
scripts/backup_db.sh
Executable 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"
|
||||
30
scripts/bootstrap_admin.py
Normal file
30
scripts/bootstrap_admin.py
Normal 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
9
scripts/check_migrations.sh
Executable 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
67
scripts/cleanup_jobs.py
Normal 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
55
scripts/deploy.sh
Executable 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
22
scripts/restore_db.sh
Executable 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"
|
||||
@@ -15,6 +15,7 @@ from app.db.base import Base
|
||||
from app.db.session import get_session
|
||||
from app.main import app
|
||||
from app.models import car, expense, gamification, push, user # noqa: F401
|
||||
from app.services.rate_limit import reset_rate_limit_state
|
||||
|
||||
TEST_BOT_TOKEN = "123456:test-token"
|
||||
TEST_INTERNAL_TOKEN = "internal-test-token"
|
||||
@@ -34,6 +35,7 @@ def make_init_data(telegram_id: int, first_name: str = "Test") -> str:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_settings() -> None:
|
||||
reset_rate_limit_state()
|
||||
settings.bot_token = TEST_BOT_TOKEN
|
||||
settings.internal_api_token = TEST_INTERNAL_TOKEN
|
||||
settings.app_env = "test"
|
||||
|
||||
275
tests/test_production_flows.py
Normal file
275
tests/test_production_flows.py
Normal 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
|
||||
@@ -244,6 +244,14 @@
|
||||
<h2>Меню</h2>
|
||||
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
|
||||
</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="carFormSection">Добавить авто</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="confirmationsSection">Подтверждения</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" data-menu-section="settingsSection">Настройки</button>
|
||||
|
||||
@@ -534,6 +543,18 @@
|
||||
<div id="stoCalendarList" class="stack-list"></div>
|
||||
</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">
|
||||
<h2>Модерация СТО</h2>
|
||||
<div class="tip-card">Заявки видны только администраторам и модераторам.</div>
|
||||
|
||||
@@ -318,6 +318,9 @@ const state = {
|
||||
allStats: null,
|
||||
analytics: null,
|
||||
serviceCenters: [],
|
||||
activeServiceCenterId: null,
|
||||
mechanicAppointments: [],
|
||||
mechanicWorkOrders: [],
|
||||
publicServiceCenters: [],
|
||||
appointments: [],
|
||||
maintenanceRecommendations: [],
|
||||
@@ -640,6 +643,10 @@ function selectedCar() {
|
||||
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) {
|
||||
return value === "" || value == null ? null : Number(value);
|
||||
}
|
||||
@@ -1024,9 +1031,10 @@ function openCarProfile() {
|
||||
openDrawerSection("carProfileSection");
|
||||
}
|
||||
|
||||
async function loadServiceCenters() {
|
||||
async function loadMyServiceCenters({ withTrust = false } = {}) {
|
||||
const centers = await api("/service-centers/my");
|
||||
state.serviceCenters = await Promise.all(
|
||||
state.serviceCenters = withTrust
|
||||
? await Promise.all(
|
||||
centers.map(async (center) => {
|
||||
try {
|
||||
return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) };
|
||||
@@ -1034,7 +1042,33 @@ async function loadServiceCenters() {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
const labels = {
|
||||
new_service: "Новый сервис",
|
||||
@@ -1428,6 +1682,71 @@ function trustLabel(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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderPlaceholderList(selector, message) {
|
||||
const root = document.querySelector(selector);
|
||||
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 === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations();
|
||||
if (sectionId === "stoCalendarSection") await loadStoCalendar();
|
||||
if (sectionId === "mechanicWorkplaceSection") await loadMechanicWorkplace();
|
||||
if (sectionId === "reviewsSection") renderServiceReviews();
|
||||
if (sectionId === "adminSection") await loadAdminPendingServices();
|
||||
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) => {
|
||||
button.addEventListener("click", () => {
|
||||
openDrawerSection("expensesSection");
|
||||
@@ -2453,7 +2778,7 @@ Promise.all([loadAuthConfig()])
|
||||
mountEntryForms();
|
||||
applyTranslations();
|
||||
initCarCatalog();
|
||||
return loadCars();
|
||||
return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.message === "Требуется вход через Telegram") return;
|
||||
|
||||
@@ -1171,6 +1171,31 @@ button.is-busy {
|
||||
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 {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
@@ -1466,6 +1491,48 @@ select {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1650,6 +1717,16 @@ select {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.work-order-head,
|
||||
.work-order-totals,
|
||||
.inline-work-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.work-order-head {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.auth-overlay {
|
||||
align-items: stretch;
|
||||
padding: 14px;
|
||||
|
||||
Reference in New Issue
Block a user