Qurylys Hub — MVP маркетплейса строительства и ремонта (Казахстан)
Цифровая «операционная система» стройки и ремонта: объект → этапы → структурированные заявки (3 режима) → КП по единому формату → сравнение → сделка → эскроу по милстоунам → приёмка с чек-листом → акты → репутация → гарантия. Работает по всему Казахстану (регион — поле в базе), пилот — Алматы и пригороды (Каскелен, Боралдай, Узын-Агач, Чемолган, Талгар, Алатау, Конаев). Интерфейс ru/kk.
Юридическая рамка: платформа — информационный посредник/агрегатор, НЕ сторона договора и НЕ технадзор. Она не выносит инженерных заключений и не подписывает акты — только генерирует документ; подписывают стороны (технадзор — живой аттестованный специалист). Персональные данные должны храниться на серверах, физически размещённых в Республике Казахстан (см. «Юридические оговорки»).
1. Стек
| Слой | Технология |
|---|
| БД | PostgreSQL 16 (обязателен: SELECT … FOR UPDATE, права на уровне БД для append-only activity_log) + Prisma 6 |
| Приложение | Next.js 15 (App Router, TypeScript, Server Components + Server Actions) + API-роуты (JSON) |
| UI | Tailwind CSS 3 |
| Аутентификация | телефон + OTP (мок-SMS), JWT в httpOnly-cookie, token_version для «выйти со всех устройств», защита от перебора OTP |
| Файлы | локальное хранилище ./uploads с валидацией размера/формата |
| AI | @anthropic-ai/sdk при наличии ключа; детерминированный мок без ключа |
| Внешние системы | абстрактные провайдеры + мок: PaymentProvider, EscrowProvider, GovRegistryProvider, NotificationProvider, SignatureProvider, LlmProvider |
| Фоновые задачи | встроенный планировщик (src/instrumentation.ts → src/server/jobs), 14 джобов |
2. Установка и запуск
# 0) требования: Node 20+, PostgreSQL 16 (локально: brew install postgresql@16 && brew services start postgresql@16)
npm install # postinstall может ругнуться на prisma generate до .env — это нормально
cp .env.example .env # при необходимости поправьте DATABASE_URL / DATABASE_MIGRATE_URL
# 1) роли и база (нужен доступ суперпользователя к postgres; создаёт роли qurylys (владелец) и qurylys_app (приложение))
npm run db:setup
# 2) миграции (под ролью-владельцем DATABASE_MIGRATE_URL) и сиды
npm run db:migrate
npm run db:seed
# 3) запуск
npm run dev # http://localhost:3000 (или npm run build && npm start)
Полезное:
npm run scenarios — пересидить БД и прогнать 35 сценариев из раздела 16 спеки (сервисный слой, против Postgres).
npm run smoke:http — HTTP-проверки против запущенного сервера (рендер всех кабинетов + API-блокировки).
npm run job <name> — запустить джоб вручную (rematch, reputation, predictive_timeline, deliver_deferred, lead_auto_refund, offer_reminders, expire_stale, expire_pitches, act_auto_dispute, retry_payments, reconciliation, billing, auto_soft_ban, license_reminders).
npm run db:reset — полный сброс + сиды. npm run db:studio — Prisma Studio.
Развёртывание (Vercel + Neon)
- В Vercel задайте
DATABASE_URL (pooled-строка Neon, хост с -pooler), DATABASE_MIGRATE_URL (direct-строка), JWT_SECRET, DEV_OTP_CODE (для демо) и остальное из .env.example.
- Миграции и сиды выполняются с локальной машины:
DATABASE_URL=… DATABASE_MIGRATE_URL=… npx prisma migrate deploy и … npm run db:seed (сид делает TRUNCATE!).
- Роль приложения
qurylys_app (append-only activity_log) на managed-хостинге автоматически не создаётся (Neon отклоняет слабый пароль). Чтобы включить ограничение в проде: до миграций выполните в SQL-консоли CREATE ROLE qurylys_app WITH LOGIN PASSWORD '<сильный пароль>';, затем migrate deploy выдаст права, а в DATABASE_URL укажите этого пользователя. Без этого приложение работает под владельцем базы и ограничение не действует.
- Файлы: на Vercel задайте
UPLOAD_STORAGE=inline (см. §10) — иначе загруженные фото и файлы будут пропадать между запусками функций.
package.json содержит allowScripts для prisma/@prisma/*/esbuild — npm 12 иначе блокирует prisma generate при установке.
3. Тестовые аккаунты
OTP-код в демо-режиме: 000000 (DEV_OTP_CODE в .env; принимается без предварительного запроса кода — только для dev).
| Телефон | Кто | Роль(и) | Что смотреть |
|---|
+77010000001 | Айдар | частный заказчик | «Дом в Каскелене» (регуляторный блок), входящие/исходящие, сделки, эскроу, акты, отзыв |
+77010000011 | Асель | частный заказчик | «Дом в Талгаре» открыт на карте — получает встречные предложения |
+77010000002 | Данияр, BI Construct (ТОО, крупный) | заказчик-компания | ЖК «Алатау Сити» (parent) + Корпус А/Б, склад, кафе → /broadcast массовая рассылка |
+77010000003 | Окна Алматы (ТОО, pro) | поставщик | окна + благоустройство; шаблон КП; кошелёк; карта объектов; лимиты pitch |
+77010000004 | БетонСервис (ТОО) | поставщик | бетон/стены; зона — Боралдай, радиус 45 км (fallback-расширение радиуса) |
+77010000005 | КровляМастер (ИП) | подрядчик + поставщик | крыша, окна, электроснабжение (лицензия verified) |
+77010000008 | РемСтрой (ИП) | подрядчик | ремонт: электрика (лицензия verified), сантехника, плитка |
+77010000010 | МастерПлитка (ИП) | подрядчик | плитка/штукатурка — второе КП для сравнения |
+77010000009 | ЭлектроМонтаж (самозанятый) | подрядчик | лицензия pending → заявки по электрике не приходят, КП заблокировано на бэкенде (есть в демо-переключателе) |
+77010000006 | Марат | технадзор (аттестат verified) | /supervisor: чек-лист с фото, заключение с подписью |
+77010000012 | ТрансКарго (ТОО) | перевозчик (поставщик, категория «Перевозки») | коридор Алматы ↔ Каскелен/Талгар/Боралдай/Конаев/Чемолган; заявки на доставку |
+77010000007 | Диспетчер | админ | /admin: needs_dispatcher, верификации, споры, выплаты, soft-ban, джобы, activity_log |
Демо-режим: переключение ролей
Чтобы смотреть сайт от лица разных ролей без ввода телефона и кода:
- На странице
/login — блок «Демо-режим: войти одним кликом» со списком 8 тестовых аккаунтов (роль, имя, номер) и кнопкой «Войти» у каждого.
- Внутри приложения — жёлтый виджет в правом нижнем углу показывает текущую тестовую роль; клик раскрывает список, выбор другой роли переключает сессию мгновенно (без выхода).
Как это устроено и почему безопасно:
- Демо-режим включён, только если задан
DEMO_MODE=1 или DEV_OTP_CODE. Проверка — в сервисе src/server/demo.ts, который вызывает эндпоинт POST /api/auth/demo-switch; вне демо-режима эндпоинт отвечает 403 даже при прямом вызове (UI-элементы при этом тоже не рендерятся).
- Переключиться можно только на номера из фиксированного списка
DEMO_ACCOUNTS — произвольный номер отклоняется (400).
- Каждое переключение пишется в
activity_log с действием dev_role_switch (кто был, на кого переключился, IP, user-agent) — тестовые переключения легко отфильтровать от реальных действий пользователей.
- Для реального прода: оставьте
DEMO_MODE и DEV_OTP_CODE пустыми — переключатель и фиксированный OTP отключатся одновременно.
4. Структура проекта
prisma/schema.prisma # 54 таблицы (все сущности раздела 13 + служебные), 45 enum
prisma/migrations/ # init → app_role_grants (append-only activity_log) → acts/deals
prisma/seed.ts, seed-data/ # справочники, шаблоны (25 категорий / 142 параметра), тестовые данные
src/server/db.ts # Prisma-клиент
src/server/auth.ts # OTP, brute-force lock, JWT, logout-all
src/server/activity.ts # append-only лог с hash-цепочкой (verifyChain)
src/server/providers/ # мок-провайдеры (платежи, эскроу, гос-API, уведомления, ЭЦП) и LLM
src/server/services/ # вся бизнес-логика: matching, requests, leads, offers, deals, chat, pitches,
# payments, escrow, disputes, acts, payouts, reviews, timeline, ai, admin, catalog
src/server/jobs/ # планировщик и джобы
src/server/actions/ # server actions (формы UI) — тонкие обёртки над services
src/app/api/ # JSON API-роуты — те же services (см. §7)
src/app/ # страницы: catalog, projects, requests, inbox/outbox, broadcast, deals, threads,
# supplier/*, admin, supervisor, settings, notifications
scripts/scenarios*.ts # раннер сценариев раздела 16; scripts/http-smoke.ts — HTTP-проверки
5. Замоканные провайдеры и почему
| Интерфейс | Мок | Почему мок |
|---|
PaymentProvider (Kaspi Pay/QR, Halyk Epay, Freedom Pay) | MockPaymentProvider — идемпотентность по ключу, FAIL_ONCE в ключе имитирует сбой шлюза, MOCK_PAYMENT_FAIL_RATE, fetchStatus для сверки, вебхук с секретом | нужны договоры с эквайерами и боевые ключи; интерфейс готов |
EscrowProvider | hold/release/refund возвращают provider_ref | реальный банковский эскроу-счёт — отдельная интеграция с БВУ |
GovRegistryProvider | проверка БИН по формату, лицензия «валидна» | eGov/ГБД ЮЛ/реестр лицензий требуют доступа по договору |
NotificationProvider (push/SMS/WhatsApp Business/email) | пишет в notifications + лог сервера; OTP печатается в консоль | SMS-шлюз и WhatsApp Business API — платные внешние сервисы |
SignatureProvider (ЭЦП НУЦ РК) | mock-sig-<hash> | реальная ЭЦП — NCALayer/КАЗТОКЕН на клиенте + серверная проверка |
LlmProvider | эвристический разбор текста / саммари отзывов | при ANTHROPIC_API_KEY используется реальный вызов Claude (LLM_MODEL) |
6. Что реализовано (по разделам спеки)
- 3 / 3А — роли (несколько на пользователя), компании (ИП/ТОО/самозанятый, БИН, счёт, документ регистрации, tax_status, масштаб), сотрудники с правами,
consents, phone_change_log, device_tokens, реферальный код, otp_attempts (5 попыток → блок 15 мин), token_version = выйти со всех устройств.
- 4 — публичный каталог с фильтрами, карточка компании (репутация, AI-саммари, верифицированные отзывы с ответами/оспариванием, лицензии, портфолио, внешние ссылки — не парсятся); карта объектов для поставщика только в scope его категорий, без адреса; зона обслуживания — полигон и/или радиус.
- 5 / 5А–5Д — три режима заявок (direct / matched / broadcast с
request_batches), встречные предложения; матчинг-движок (гео + лицензия + soft-ban + ранжирование рейтинг/дистанция/on_time + нагрузка daily_lead_limit + лимит получателей + fallback ×2 радиус → needs_dispatcher + rematch раз в час + объяснимость в activity_log); покупка лида в транзакции с FOR UPDATE; возврат лида (48 ч / дубль / спор через админа); чат с маскировкой контактов до сделки; declined-лиды; auto/ручной soft-ban; предпочтения каналов, digest, quiet hours, дедупликация; лимиты и cooldown встречных предложений, expires_at, open_to_pitches.
- 6 — 25 шаблонов как данные (бетон, окна, крыша, стены, забор, благоустройство, 10 категорий ремонта, 9 инженерных/слаботочных), версионирование шаблонов, сезонные ограничения и зависимости этапов (предупреждения), дубль адреса (предупреждение), групповые объекты.
- 7 — единый формат КП, сравнение (Материал | Работа | Доставка | Срок | Гарантия | ИТОГО | Соответствие | Рейтинг | Лицензия),
valid_until, версии, declined/not_selected/expired, «подозрительно дёшево» через price_reference, лицензионная блокировка на бэкенде, частичное КП (offer_scope), разделённый выбор (deal_items), вложения, шаблоны КП, напоминание о непринятых КП, уведомление проигравшим без цены победителя.
- 8 — отзывы по
deal_id (verified в окне 30 дн., вес по сумме, recency decay, author_role buyer/supervisor, фото → портфолио), ответы, оспаривание, антинакрутка (flagged_suspicious), двусторонние рейтинги, асинхронный пересчёт company_reputation, AI-саммари только по проверяемым данным.
- 9 — оплата в один тап с
idempotency_key, retry_pending + fallback-провайдер, вебхуки с подписью и replay-защитой, ежедневная сверка, фискализация (флаг ЭСФ по ), (до/после начала работ), комиссия по , подписки с биллингом, эскроу по милстоунам, (единственная точка , проверка в транзакции с ; UI и API получают 409), частичная приёмка, пеня 0,1 %/день (cap 10 %), выводы средств pending→approved→completed/rejected.
6А. Новые возможности (раунд 3)
Физлицо-исполнитель
companies.legal_type = individual_contractor. Регистрация в /settings: только ФИО, телефон, категории, зона — без БИН (companies.bin стал nullable) и документов. Переиспользовано: ограничение реализовано внутри единственной проверки лицензии hasValidLicense() (src/server/services/matching.ts) — для физлица она всегда false, поэтому матчинг, отправка КП (403 license_required), рассылка, сравнение и встречные предложения автоматически исключают его из категорий с required_license. В общих категориях — наравне с ИП/ТОО. В каталоге и на карточке — пометка «физлицо-исполнитель». Демо-сценарий: F1 в scripts/qa-live/features.ts.
Доска обсуждений (/board)
board_posts / board_replies / board_reports. Только публичные ветки по категории и (опционально) региону: /board/<категория>[/<регион>], /board/post/<id>. Чтение — без входа, пост/ответ/жалоба — после входа. Переиспользовано: анти-спам — тот же приём, что daily_lead_limit/pitch_daily_limit (счётчик за календарный день против BOARD_POST_DAILY_LIMIT для пользователя и companies.board_post_daily_limit для компании); модерация — тот же поток, что у review_disputes: список открытых жалоб в /admin → решение (удалить/оставить) → статус. Намеренно нет: личных сообщений, приватных групп и каналов — переписка по конкретной заявке остаётся в threads/messages.
Каталог товаров
products у компании (/supplier/products), покупка с карточки компании: «Купить» + количество → сразу deals с deal_type = product_purchase (все прежние сделки — service_request по умолчанию). Цикл created → paid → received → completed без milestones/эскроу/актов/чек-листов; отзыв — после completed. Переиспользовано: комиссия — resolveCommission() по commission_tiers (тот же код, что у сделок-услуг); оплата — createPayment() с идемпотентностью; списание stock_qty — SELECT … FOR UPDATE в транзакции, как при покупке лида (stock_qty = null — под заказ, без ограничения). Деньги продавцу за вычетом комиссии зачисляются на кошелёк при подтверждении получения.
Перевозки и связка «материал → доставка»
categories.category_type (material / work / logistics); категория «Перевозки / доставка» с шаблоном через обычные request_templates/request_parameters (тип груза, вес/объём, регион и точка загрузки/выгрузки, транспорт, срочность). companies.service_route_json — коридор перевозчика (пары регионов, в обе стороны), настраивается в /supplier/settings. Переиспользовано: в matchRequest() для category_type = logistics заменён только гео-шаг (вместо полигона/радиуса — обе точки внутри коридора); лицензия, ранжирование, лимит получателей, лиды, уведомления, fallback → needs_dispatcher, атомарность покупки лида, explainability, rematch — общий код без изменений. price_reference для перевозок с единицами «тг/км» и «тг/рейс» показывается на форме заявки. Связка: на сделке по материалу — «Нужна доставка? Найти перевозчика» → форма заявки на перевозку с предзаполненными (редактируемыми) точками (загрузка = поставщик сделки, выгрузка = объект) → request_links (link_type = delivery, тот же принцип, что deal_items) → заявка проходит обычный полный путь. Тестовый перевозчик: +77010000012 ТрансКарго (в демо-переключателе). Для уже работающей базы: npx tsx scripts/upsert-logistics.ts (идемпотентно).
Сценарии раздела 6 (живьём, scripts/qa-live/features.ts)
| # | Сценарий | Шаги | Результат | Как проверить самому |
|---|
| F1 | Физлицо-исполнитель | регистрация без БИН → заявка на пожарную безопасность → pitch и КП (в т.ч. прямой API) → малярка | лид не выдан; pitch/КП → 403 с объяснением; малярка — полный цикл до отзыва | /settings → форма «Физлицо-исполнитель» → /supplier/leads (баннер) |
| F2 | Доска | чтение без входа; пост от пользователя и от компании; лимиты; жалобы → админ | без входа формы нет; 6-й пост и 2-й пост компании (лимит 1) отклонены; resolved удаляет ветку (404), rejected оставляет | /board/concrete/kaskelen, /admin → «Жалобы на доске» |
| F3 | Товар | товар с остатком 1 → две параллельные покупки → оплата → «Товар получен» → отзыв; товар под заказ ×500 | прошла одна покупка, второй — «Товар закончился»; без milestones/escrow; продавцу 34 000 − 5 %; под заказ 15 млн → комиссия 1 % | /supplier/products → /catalog/betonservice → «Купить» |
| F4 | Логистика | заявка Астана→Алматы; заявка Каскелен→Алматы | вне коридоров → needs_dispatcher (explainability «вне коридора», fallback); внутри → лид ТрансКарго → сделка → акт → отзыв | Объект → «+ Перевозки / доставка» |
| F5 | Связка | сделка по бетону → «Найти перевозчика» → форма → публикация | точки предзаполнены (БетонСервис / адрес объекта), request_links создан, ТрансКарго получил лид, сделка с перевозчиком | Сделка по бетону → синяя карточка «Нужна доставка?» |
Регрессия после каждой фичи: 35 автосценариев + HTTP-smoke — зелёные; старая живая матрица 49 кейсов — без изменений.
6Б. Раунд 4: фото товара, сравнение, аудит стыков
Фото товара
Несколько фото на товар (products.photos_json, до PRODUCT_PHOTOS_MAX = 6). Переиспользовано: загрузка и хранение — тот же saveUpload() (file_blobs + /f/<id>/<имя>), что у фото чек-листов приёмки и отзывов; лимит размера — общий uploadEffectiveMaxMb; клиентская проверка — тот же подход, что FileInput. Форма /supplier/products: множественный выбор с превью и удалением до сохранения (MultiPhotoInput), при редактировании — удаление отдельных фото и добавление новых. Карточка компании и таблица сравнения: миниатюра первого фото + галерея (ProductGallery), без фото — плейсхолдер «нет фото».
Сравнение товаров (/compare)
comparison_items (user_id, product_id) — доступно любой вошедшей роли; списки раздельны по пользователю. Ограничения: одна категория в списке (иначе понятное сообщение «сначала очистите список…») и не больше COMPARE_MAX_ITEMS = 5. Переиспользовано: таблица построена по паттерну сравнения КП (/requests/<id>), а подсветка разницы вынесена в общий src/lib/compare.ts (extremes/hl: лучшее — зелёным, худшее — красным) и применена и к существующей таблице КП (ИТОГО, срок), и к товарам (цена, срок поставки, рейтинг). Колонки: продавец, цена, единица, остаток, рейтинг компании, срок поставки (products.delivery_days), «Купить» (якорь на товар в карточке), «убрать». Гость при «+ Сравнить» или на /compare получает предложение войти с возвратом. Остаток показывается на момент открытия; при покупке остаток проверяется заново (SELECT … FOR UPDATE).
Аудит стыков между фичами (раунд 4)
| Стык | Что проверено | Что нашлось | Как починено | Коммит | Проверить самому |
|---|
| Лицензия × каталог товаров | физлицо / компания с pending-лицензией выставляют товар в пожарке/электрике; лицензированная — тоже; лицензия истекает после публикации | дыра: товары не проверяли лицензию и soft-ban вовсе | та же hasValidLicense() в createProduct, при включении is_active и в момент покупки; soft-ban — та же проверка, что у КП/pitch | 5eb9b8a | физлицо → /supplier/products → категория «Пожарная безопасность» → 403 с объяснением |
Сравнение × остаток (stock_qty) | 5 товаров в сравнении, другой пользователь выкупает последнюю единицу | без бага: row lock на покупке; страница показывает остаток на момент открытия | без изменений кода; в таблице подпись «остатки — на момент открытия», при 0 нет «Купить», снятый товар помечен | — | /compare после чужой покупки → «нет в наличии» |
| Доска × каталог | пост компании с ценой | инструментов покупки нет (и не должно быть) | добавлена пометка «цены в обсуждениях — не оферта; покупка только через каталог/заявку» | fb88912 | /board/post/ |
Перевозки (request_links) × товары | «Нужна доставка» на сделке-покупке | связка была только для сделок-услуг | включено осознанно: request_links.primary_deal_id для товарных сделок, выбор объекта выгрузки на странице покупки, то же предзаполнение и обычный матчинг | 2315485 | покупка бетона → «Нужна доставка?» → объект → заявка → ТрансКарго |
| Физлицо × доска | лимиты постов пользователя и компании без БИН | без бага: лимит считается по company_id, БИН не участвует | — | — | G7 в scripts/qa-live/features-3.ts |
| Soft-ban × товары | покупка у забаненного продавца, новый товар под баном | дыра: бан не действовал на каталог | та же проверка бана, что у КП/pitch — в публикации и покупке | 5eb9b8a | /admin → бан → купить → понятный отказ |
| Гость × сравнение/доска/каталог | чтение без входа, действия |
Стоп-критерий: список стыков (G1–G9 + F1–F5) пройден живьём без находок два раза подряд на стенде и два раза подряд на проде.
7. API (JSON, cookie qh_session или Cookie: qh_session=<jwt> из /api/auth/otp/verify)
POST /api/auth/otp/request|verify, POST /api/auth/logout|logout-all, GET /api/me, GET /api/health,
GET|POST /api/projects, GET|POST /api/requests, GET /api/requests/:id/compare, POST /api/requests/:id/offers, POST /api/requests/:id/deals,
POST /api/leads/:id/purchase|decline, GET|POST /api/pitches, GET|POST /api/threads, POST /api/threads/:id/messages,
POST /api/milestones/:id/pay (заголовок Idempotency-Key), POST /api/milestones/:id/accept, GET|POST /api/milestones/:id/checklist,
POST /api/escrow/:milestoneId/release (админ), POST /api/deals/:id/disputes, POST /api/disputes/:id/resolve, POST /api/acts/:id/sign,
POST /api/ai/parse, POST /api/payments/webhook/:provider (заголовок X-Webhook-Secret).
Ошибки: {error, message, details} со статусами 400/401/403/404/409/429.
8. Допущения и решения (правило неоднозначности, раздел 0)
- Одна сделка = один продавец. При разделённом выборе частей КП разных компаний создаётся по
deal на каждого продавца; deal_items связывают части (материал/монтаж/доставка) с офферами. Причина: эскроу, комиссия и акты считаются на продавца.
- direct/broadcast-лиды бесплатны (price=0) и сразу
purchased — их инициировал сам заказчик. Платные — лиды движка (matched/rematch, цена LEAD_PRICE_DEFAULT). daily_lead_limit считает только лиды движка; broadcast ограничивается тем, что рассылка — действие заказчика.
- Broadcast = всем поставщикам категории с проверкой лицензии и soft-ban, без лимита получателей и без строгой геофильтрации (крупный заказчик сознательно шлёт всем).
- Милстоуны по умолчанию: «Материалы и доставка» и «Работы/монтаж» (если обе части есть), иначе один на 100 %. Срок = макс. срок доставки+выполнения из КП (мин. 7 дн.).
- Пеня =
penalty_pct_per_day (0,1 %) × дней просрочки × сумма этапа, не более 10 %; удерживается из раскрываемой суммы и возвращается заказчику.
- cancel_policy: до начала работ — полный возврат эскроу; после — 50 % удержанной суммы исполнителю (упрощённая политика, настраивается в
cancelDeal).
- Контакты в чате до сделки: сообщение сохраняется с замаскированными номерами/мессенджерами (
flagged_contact_leak), фиксируется в activity_log, отправитель получает предупреждение.
- Принятие встречного предложения создаёт точечную заявку этой компании с минимально валидными значениями («уточнить в чате») и бесплатный лид.
- Диспетчер может переназначить лид компании, ранее отказавшейся (
declined), — ручной разбор имеет приоритет.
- Отзыв verified = автор — сторона сделки (заказчик/технадзор), сделка
completed, в окне REVIEW_WINDOW_DAYS. Антинакрутка: ≥3 отзыва одного автора на компанию за 7 дней → flagged_suspicious.
- Уведомления: одно событие → всегда in_app + максимум один внешний канал (push → whatsapp → sms → email); дедупликация по
dedup_key 24 ч; digest к 09:00/18:00 Алматы; критичные (сообщения по сделке, споры, оплата, принятие pitch) обходят quiet hours/digest.
- Регуляторная матрица (
regulatory_rules) упрощена: ИЖС до 500 м²/2 эт. — уведомительный порядок (III), 500–5000 м² или ≥3 эт. — II + разрешение + экспертиза + технадзор, ≥5000 м²/ЖК/ТРЦ/завод — I. Сейсмичность — по региону (Алматы и область 9). Не юридическое заключение.
9. Юридические оговорки
- Хранение ПДн в РК: согласно Закону РК «О персональных данных и их защите» база с персональными данными должна размещаться на серверах, физически находящихся в Казахстане. Разворачивать Postgres/приложение нужно у казахстанского хостинга/ЦОД; в коде это не enforce'ится.
- LLM и приватность (10В): в промпт к LLM не передаются адрес объекта, ФИО, телефон, e-mail:
stripPii() вырезает телефоны, e-mail, явные адресные маркеры и точные значения ПДн из паспорта объекта/профиля владельца; в LLM уходят только тип объекта, площадь, этажность, сейсмичность и техническое описание. Фактически отправленный промпт хранится в ai_parses.sent_prompt для аудита. Полной гарантии на свободном тексте нет (например, имя без маркеров) — перед реальным запуском требуется юридическая проверка и, вероятно, явное согласие пользователя на передачу текста внешнему LLM-провайдеру (за пределы РК).
- Акты формируются платформой, но юридическую силу придают подписи сторон; лабораторные отчёты — предварительная информация, не заменяющая заключение аккредитованной лаборатории.
10. Известные упрощения
- Геологика без PostGIS: пересечение зоны обслуживания = point-in-polygon (ray casting) по
service_area_polygon ИЛИ haversine-расстояние до service_center ≤ service_radius_km. При fallback радиус умножается на MATCH_FALLBACK_RADIUS_MULTIPLIER, полигон «раздувается» на 10 км. Расстояние до полигона считается до вершин, не до рёбер.
- Файлы — локальная папка
./uploads (UPLOAD_STORAGE=files), раздача через /uploads/* только авторизованным. Демо на Vercel работает в режиме UPLOAD_STORAGE=inline: файлы до 2 МБ хранятся как data-URL прямо в Neon (фото чек-листов/отзывов, лабораторные отчёты, файлы объекта) — временное решение на демо-период, потому что диск serverless-функций не сохраняется между запусками; для прода нужно S3-совместимое хранилище в РК. Акты всегда отдаются из БД (/acts/<id>).
- Планировщик — in-process
setInterval в Next (один инстанс). Для нескольких инстансов нужен внешний cron/очередь; каждый джоб можно вызывать npm run job <name>.
- Карта объектов — списком по району/категории (без картографического SDK); координаты объекта подставляются из справочника регионов, если не указаны.
- Мок-LLM — эвристика по ключевым словам; реальное качество разбора — только с
ANTHROPIC_API_KEY.
- Акты — HTML-документы (не PDF); ЭЦП — мок.
- Сравнение «подозрительно дёшево» — по цене за единицу для категорий с известной единицей (бетон м³, окна шт, кровля м², плитка м², электрика точка, кладка м³).
- Права сотрудников компании (
owner/manager/estimator) применяются только к выводу средств; остальные действия доступны всем членам компании.
- i18n: каркас (навигация, лендинг, справочники с
name_kk) на казахском; тексты форм/кабинетов — на русском.
11. Не реализовано / заглушки
- Реальные интеграции (платежи, эскроу, гос-API, SMS/WhatsApp, ЭЦП) — только интерфейсы + моки (см. §5).
- Ограничение функциональности по плану подписки (все функции доступны при любом плане).
- Экспорт актов в PDF; загрузка фото чек-листа через форму (передаётся URL уже загруженного файла).
- Мобильные push (FCM) —
device_tokens хранятся, отправка через мок.
- Полноценная казахская локализация всех экранов.
12. Таблица верификации сценариев (раздел 16)
Все сценарии реально выполнены командой npm run scenarios (сервисный слой против PostgreSQL; каждый сценарий — с проверками assert) и/или npm run smoke:http (HTTP к запущенному серверу). Файлы: scripts/scenarios.ts, scripts/scenarios-money.ts, scripts/scenarios-late.ts, scripts/http-smoke.ts. Итог последнего прогона: 35/35 сценариев + 6/6 HTTP-проверок — ПРОШЛИ.
| Сценарий | Статус | Как проверен |
|---|
| СТРОЙКА: дом в Каскелене, регуляторный блок, точечный запрос окон из каталога; тот же поставщик видит другой объект на карте и шлёт встречное предложение по газону | ПРОШЁЛ | scenarios «СТРОЙКА…»: регуляторный блок III/сейсм. 9; createRequest(mode=direct) → лид direct/purchased; mapProjectsForCompany показывает Талгар без адреса; createPitch → во входящих Асель |
| КРУПНЫЙ ЗАКАЗЧИК: 3 объекта → фильтр «нужны окна» → рассылка всем поставщикам окон одной кнопкой | ПРОШЁЛ | «КРУПНЫЙ ЗАКАЗЧИК…»: broadcastRequests → 4 заявки × 2 поставщика = 8 лидов, request_batches; UI /broadcast (smoke 200) |
| РЕМОНТ: капремонт квартиры, заявки на электрику и плитку, сравнение КП | ПРОШЁЛ | «РЕМОНТ…»: 2 КП на плитку, compareOffers сортирует по ИТОГО, колонки лицензия/рейтинг/гарантия; UI /requests/:id |
| СДЕЛКА: КП → deal с commission_percent → оплата мок-провайдером идемпотентно → милстоун принят → акт сгенерирован и подписан → отзыв verified → пересчёт репутации | ПРОШЁЛ | «ЧАСТИЧНОЕ КП…» (комиссия 5 % = 36 250 ₸), «СДЕЛКА: оплата…» (повтор с тем же ключом → тот же платёж; net 688 750 ₸; акт HTML; 2 подписи → signed), «СДЕЛКА (продолжение)…» (verified=true, avg 5, AI-саммари) |
| ВХОДЯЩИЕ/ИСХОДЯЩИЕ у заказчика | ПРОШЁЛ | «ВХОДЯЩИЕ/ИСХОДЯЩИЕ…»; UI /inbox, /outbox (smoke 200) |
| ПРЕДИКТИВНЫЙ ТАЙМЛАЙН: планировщик создаёт уведомление заранее | ПРОШЁЛ | «ПРЕДИКТИВНЫЙ ТАЙМЛАЙН…»: до даты — нет, за lead_time — timeline.upcoming_need; завершение этапа сдвигает график |
| ТЕХНАДЗОР: чек-лист, фото, подпись заключения | ПРОШЁЛ | «ТЕХНАДЗОР…»: setChecklistResult от технадзора, supervisor_conclusion → signed с supervisor_signature_ref |
| ЛАБОРАТОРИЯ: lab_report на объекте | ПРОШЁЛ | «ЛАБОРАТОРИЯ…»: addLabReport, verified=false, дисклеймер; чужой → 403 |
| AI-РАЗБОР: предзаполненная форма; адрес/личные данные не попадают в промпт | ПРОШЁЛ | «AI-РАЗБОР…»: извлечены structure_type/volume/grade; ai_parses.sent_prompt не содержит адрес, телефон, e-mail, имя; 11-й разбор → 429 |
|
Как повторить: npm run scenarios (печатает таблицу и код выхода ≠ 0 при провале), затем npm run build && npm start и в другом терминале npm run smoke:http.