Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.DS_Store
|
||||
**/data
|
||||
@@ -0,0 +1,31 @@
|
||||
# Postgres
|
||||
POSTGRES_USER=leadradar
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_DB=leadradar
|
||||
POSTGRES_PORT=5433
|
||||
DATABASE_URL=postgres://leadradar:change-me@localhost:5433/leadradar
|
||||
|
||||
# API
|
||||
API_PORT=3100
|
||||
# Адрес кабинета: используется в ссылках-приглашениях и письмах
|
||||
APP_URL=http://localhost:5174
|
||||
|
||||
# Telegram-бот (токен выдаёт @BotFather). Без токена вход через Telegram выключен.
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_BOT_USERNAME=
|
||||
|
||||
# Клиентский бот: анкета соискателя. Без токена анкета выключена.
|
||||
CLIENT_BOT_TOKEN=
|
||||
CLIENT_BOT_USERNAME=
|
||||
|
||||
# Страница подборки: обрезка фото сверху (%), папка для готовых фото, ключ OpenRouter для текста (необязательно)
|
||||
CROP_TOP_PERCENT=8
|
||||
MEDIA_DIR=
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_MODEL=
|
||||
|
||||
# Источник объявлений: listam — снимки страниц list.am через ридер; feed — JSON-фид по LISTING_FEED_URL
|
||||
LISTING_SOURCE=listam
|
||||
LISTING_FEED_URL=
|
||||
LISTING_FEED_TOKEN=
|
||||
JINA_API_KEY=
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!deploy/.env.production.example
|
||||
.DS_Store
|
||||
data/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,145 @@
|
||||
# Y&P Lead Radar
|
||||
|
||||
Сервис находит новые объявления на List.am по фильтрам клиента, доставляет их агентам в Telegram
|
||||
и ведёт каждый объект как лид в CRM до сделки. Домен: `listcrm.yandpconsulting.com`.
|
||||
|
||||
Написан с нуля по ТЗ. Из прежней CRM (`../crmyerevan-…`) взят только модуль парсера —
|
||||
см. [docs/PARSER.md](docs/PARSER.md).
|
||||
|
||||
## Структура
|
||||
|
||||
| Путь | Назначение |
|
||||
| ----------------- | -------------------------------------------------------------------------- |
|
||||
| `apps/api` | Бэкенд: Fastify, Postgres (Drizzle). Один API для веб-кабинета и Mini App |
|
||||
| `apps/web` | Веб-кабинет и Mini App: React, Tailwind, тема сайта yandpconsulting.com |
|
||||
| `packages/shared` | Тарифы и лимиты, роли, воронки по умолчанию, сопоставление с фильтрами |
|
||||
|
||||
## Запуск на своём компьютере
|
||||
|
||||
Нужны Node 22+, pnpm 10, Docker.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
pnpm install
|
||||
pnpm db:up # Postgres в Docker, порт 5433
|
||||
pnpm db:migrate
|
||||
pnpm --filter @lr/api superadmin <email> "Имя" # печатает пароль суперадмина
|
||||
pnpm dev:api # http://localhost:3100
|
||||
pnpm dev:web # http://localhost:5174
|
||||
```
|
||||
|
||||
Проверки: `pnpm typecheck && pnpm test` (тестам API нужен запущенный Postgres и база
|
||||
`leadradar_test`).
|
||||
|
||||
После изменения `apps/api/src/db/schema.ts`: `pnpm db:generate && pnpm db:migrate` — появится новый
|
||||
файл миграции в `apps/api/drizzle/`; старые файлы миграций не правятся и не удаляются.
|
||||
|
||||
## Что готово
|
||||
|
||||
- Схема данных всех сущностей ТЗ (раздел 9).
|
||||
- Вход по email и паролю, через Telegram Login и Telegram Mini App; подтверждение email и
|
||||
сброс пароля (письма пока пишутся в лог API — почтовый сервис не выбран).
|
||||
- Компании, тарифы и лимит участников, роли, приглашения, удаление агента.
|
||||
- Состояние подписки: активна → просрочена (15 дней) → только чтение.
|
||||
- Суперадмин: компании, тариф, дата окончания, отсрочка, вход в кабинет, состояние парсера.
|
||||
- **Парсер → общая база → фильтры → лиды**: все категории недвижимости list.am, в базу идут
|
||||
объявления не старше 3 дней, оттуда — клиентам по общим и личным фильтрам.
|
||||
- **Бот**: уведомление о лиде с кнопкой «Беру», атомарный захват, «Взял(а) Имя» у остальных,
|
||||
привязка Telegram, /pause и /resume, тихие часы. Очередь сообщений с повторами и лимитом темпа.
|
||||
- **Пул**: возврат через 24 часа без смены статуса, предупреждение за 2 часа, повторное «Беру».
|
||||
- **Воронка объекта**: статусы, отказ с причиной, сделка с комиссией, назначение и
|
||||
переназначение, комментарии, напоминания, история, «Это агент», лиды вручную.
|
||||
- **Воронка клиента**: покупатели и арендаторы, запрос клиента, подбор подходящих объектов из CRM,
|
||||
отметки «предложен» и «показан», переназначение руководителем, напоминания в боте.
|
||||
- **Общая сделка**: объект и клиент из CRM закрываются один раз, комиссия записывается одной
|
||||
строкой из двух частей: с собственника — агенту объекта, с покупателя или арендатора — агенту
|
||||
клиента; если стороны в CRM нет, обе части получает агент второй стороны. Суммы и дату исправляет
|
||||
руководитель.
|
||||
- **Оплата агентов**: оклад, процент с части комиссии агента (отдельно для продажи и аренды) или
|
||||
оклад + процент; план на месяц (сделки, комиссия) и бонус за него — суммой или надбавкой к
|
||||
проценту. Ведомость «Кому сколько платить» за месяц; строка сотрудника раскрывает его сделки,
|
||||
начисления, ручные записи (премия, штраф, аванс) и отметку «выплачено». Выплата закрывает месяц:
|
||||
он считается по условиям и курсам на день выплаты, а изменения его сделок и недоплата переходят
|
||||
перерасчётом в ближайший месяц без выплаты. Агент видит свои условия, сделки, начисления и историю
|
||||
выплат в «Настройках».
|
||||
- **Динамика и рейтинг**: сделки и комиссия по месяцам — по компании и по каждому сотруднику
|
||||
(агент видит только свою); рейтинг месяца виден всем: место, число сделок, выполнение плана,
|
||||
без сумм. Ведомость со сделками выгружается файлом для Excel.
|
||||
- **Суперадмину в Telegram** приходит уведомление о каждой новой регистрации и о компании,
|
||||
созданной самостоятельно: имя, email, Telegram. **Приветствие бота** (ответ на /start тому, кто
|
||||
ещё не привязал Telegram): текст на трёх языках и до 10 файлов — в разделе «Сообщения». Следом бот
|
||||
спрашивает язык кнопками; выбор запоминается, на нём бот отвечает и создаётся аккаунт.
|
||||
- **Отчёты в Telegram** (включает руководитель в настройках компании): итоги недели по
|
||||
понедельникам и итоги месяца с ведомостью первого числа, в 9 утра по Еревану.
|
||||
- **Настройка воронок**: руководитель переименовывает, добавляет, удаляет и переставляет
|
||||
промежуточные статусы и причины отказа в обеих воронках.
|
||||
- **База** (Team, Agency): объекты и клиенты (покупатели, арендаторы), закрытые сделкой или отказом; у клиента — связанные
|
||||
объекты. Раз в сутки объекты из CRM проверяются на list.am: снятое объявление получает отметку
|
||||
и дату. Повторно опубликованный объект приходит новым лидом с блоком «Уже был в работе».
|
||||
- **Аналитика** (Team, Agency, руководителю): сводка, воронка, таблица по агентам (взято, скорость
|
||||
реакции, возвраты в пул, конверсия по этапам, сделки, комиссии), причины отказа, выгрузка в CSV;
|
||||
отдельно по объектам и по клиентам.
|
||||
- **Суперадмин — «Объявления»**: вся общая база объявлений с поиском, фильтрами и карточкой
|
||||
«кому ушло лидом». **Настройки парсера** в админке: включение, свежесть в днях, пауза, число
|
||||
страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса.
|
||||
- **Биллинг**: счета за установку, фикс и «%» с расшифровкой по сделкам; смена тарифа со
|
||||
следующего месяца; напоминания за 7, 3 и 1 день и при просрочке; ручная отметка оплаты
|
||||
суперадмином; контракт платёжного провайдера для онлайн-оплаты — см. [docs/BILLING.md](docs/BILLING.md).
|
||||
- **Суперадмин — «Счета» и «Сообщения»**: сводка (активные компании, выручка за месяц, просрочки),
|
||||
счета всех компаний, курсы валют; сообщения в Telegram всем, компании, руководителям или одному
|
||||
пользователю с журналом отправок.
|
||||
- **Поддержка**: всё, что человек пишет боту (текст, фото, файл), попадает в обращение. Суперадмин
|
||||
отвечает в разделе «Поддержка» — ответ уходит в тот же чат — и закрывает кнопкой «Проблема решена».
|
||||
В кабинете у пользователей есть раздел «Поддержка» с подсказкой, как написать.
|
||||
- **Характеристики объявления**: со страницы List.am берутся удобства по разделам, «можно с детьми»,
|
||||
«можно с животными», мебель, ремонт, предоплата и остальные поля. Видны в карточке объекта;
|
||||
правила про детей и животных — ещё и в уведомлении бота. Телефон автора не собирается.
|
||||
- **Кто будет жить** (карточка клиента): один, с другом, пара; дети и животные с количеством и
|
||||
возрастом; до 10 фото. Подбор не предлагает объекты, где с детьми или животными прямо нельзя.
|
||||
- **Анкета соискателя**: у каждого агента своя ссылка на клиентский бот (команда `/anketa` в основном
|
||||
боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент,
|
||||
закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?».
|
||||
- **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту,
|
||||
и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит
|
||||
создателю ссылки в бот и появляется в карточке клиента.
|
||||
- **Фото и текст на странице клиента** (логика из бота `list-am-bot`): для объектов, которые показывают
|
||||
клиенту, догружается полный набор фото объявления; фото отдаёт сам сервис, срезая сверху 8 % высоты
|
||||
(`CROP_TOP_PERCENT`); текст собирается по шаблону-карточке, а с ключом `OPENROUTER_API_KEY`
|
||||
дополняется абзацем из описания. Адреса в тексте нет.
|
||||
- **Карточка объекта**: все фото объявления (обрезанные, догружаются в фоне после первого открытия),
|
||||
«Описание для клиента» с кнопкой копирования и поле «Телефон собственника» — номер агент берёт из
|
||||
объявления сам; он сохраняется в карточке и, если в общей базе его не было, в базе объявлений.
|
||||
- **Комиссия агентства**: руководитель задаёт в настройках процент или фикс — отдельно для объектов и
|
||||
клиентов, продажи и аренды. В карточке видно условие и ожидаемую сумму; агент может поменять условие
|
||||
для своей карточки. При закрытии сделки сумма комиссии подставляется по условию.
|
||||
- **Оплата агентов**: руководитель задаёт каждому сотруднику оклад, процент с комиссии по его сделкам
|
||||
или оклад + процент («Команда»). Там же таблица «Кому сколько платить» за выбранный месяц; агент
|
||||
видит свои условия и начисления в настройках.
|
||||
- **Отделы** (раздел «Команда»): руководитель создаёт отделы, назначает и снимает сотрудников, задаёт фильтры
|
||||
отдела. У отдела свои объекты и клиенты: сотрудник видит только данные своего отдела, руководитель и
|
||||
суперадмин — все отделы (с выбором отдела в списках). Одно объявление под фильтрами двух отделов приходит
|
||||
каждому своей карточкой; уведомления — сотрудникам отдела. При смене отдела объекты и клиенты сотрудника
|
||||
переходят с ним. Удалить можно отдел без сотрудников, клиентов и объектов в работе.
|
||||
- **Рабочий список объектов** показывает только актуальное: снятые с List.am и закрытые сделкой или отказом
|
||||
объекты не попадают в список, поиск, подбор, страницу клиента и витрину — они остаются в базе и в «Базе»
|
||||
(фильтр «Сняты с List.am»).
|
||||
- **Избранное и «Не просмотренные»**: звёздочка в списке и карточке, у каждого своя; фильтры сочетаются со
|
||||
всеми остальными. **Цена в АМД или ДОЛ**: границы задаются в выбранной валюте, цены объектов в другой
|
||||
валюте пересчитываются по курсу из админки («Счета»); при смене валюты введённые границы пересчитываются.
|
||||
- **«Отправить в бот»** в карточке объекта: фото, описание и комиссия приходят основным ботом в личный чат
|
||||
того, кто нажал. Без привязанного Telegram — инструкция и кнопка привязки.
|
||||
- **Демо-компании**: `scripts/seed-demo.mjs` создаёт «Демо: Аренда» и «Демо: Продажа» на настоящих
|
||||
объявлениях из базы, `scripts/seed-demo-backdate.sql` раскладывает их историю по трём неделям.
|
||||
- **Мастер первой настройки**: первый фильтр, привязка бота, приглашение агентов.
|
||||
- **Mini App**: тот же кабинет в Telegram с входом без пароля и нижней панелью разделов.
|
||||
Тариф Solo в браузере видит только подписку и профиль — рабочие разделы открываются в Telegram.
|
||||
- Веб-кабинет: объекты (таблица и канбан), карточка, клиенты, база, аналитика, фильтры, команда,
|
||||
настройки, подписка, админка; три языка, тёмная и светлая тема.
|
||||
- **Боевая сборка**: Docker-образы, ежедневная резервная копия, скрипт выкладки —
|
||||
см. [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
|
||||
## Что дальше
|
||||
|
||||
1. Выкладка на VPS по [docs/DEPLOY.md](docs/DEPLOY.md): домен, боевой бот, первая выкладка.
|
||||
2. Ручная приёмка на боевом стенде — что осталось, перечислено в [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md).
|
||||
3. Платёжный провайдер, почтовый сервис, вычитка армянских текстов.
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
CREATE TABLE "admin_messages" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"audience" text NOT NULL,
|
||||
"company_id" integer,
|
||||
"user_id" integer,
|
||||
"recipients" integer DEFAULT 0 NOT NULL,
|
||||
"sent_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "blacklist" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"phone" text NOT NULL,
|
||||
"added_by_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "client_leads" (
|
||||
"client_id" integer NOT NULL,
|
||||
"lead_id" integer NOT NULL,
|
||||
"state" text DEFAULT 'offered' NOT NULL,
|
||||
"shown_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "clients" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"phone" text,
|
||||
"kind" text NOT NULL,
|
||||
"request" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"source" text,
|
||||
"assignee_id" integer NOT NULL,
|
||||
"status_id" integer NOT NULL,
|
||||
"reject_reason_id" integer,
|
||||
"next_action_at" timestamp with time zone,
|
||||
"next_action_note" text,
|
||||
"next_action_notified_at" timestamp with time zone,
|
||||
"status_changed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "companies" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"plan" text NOT NULL,
|
||||
"billing_model" text DEFAULT 'fixed' NOT NULL,
|
||||
"planned_plan" text,
|
||||
"planned_billing_model" text,
|
||||
"planned_from" date,
|
||||
"subscription_ends_at" timestamp with time zone,
|
||||
"grace_days" integer DEFAULT 15 NOT NULL,
|
||||
"access_enabled" boolean DEFAULT false NOT NULL,
|
||||
"setup_fee_invoiced" boolean DEFAULT false NOT NULL,
|
||||
"settings" jsonb NOT NULL,
|
||||
"last_activity_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "deals" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"lead_id" integer,
|
||||
"client_id" integer,
|
||||
"agent_id" integer NOT NULL,
|
||||
"commission" numeric(14, 2) NOT NULL,
|
||||
"currency" text NOT NULL,
|
||||
"deal_date" date NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_tokens" (
|
||||
"token_hash" text PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "events" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"lead_id" integer,
|
||||
"client_id" integer,
|
||||
"user_id" integer,
|
||||
"type" text NOT NULL,
|
||||
"old_value" text,
|
||||
"new_value" text,
|
||||
"text" text,
|
||||
"at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "filters" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"owner_user_id" integer,
|
||||
"name" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"params" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invites" (
|
||||
"token" text PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"role" text DEFAULT 'agent' NOT NULL,
|
||||
"created_by_id" integer NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invoices" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"period_start" date,
|
||||
"period_end" date,
|
||||
"amount" numeric(14, 2) NOT NULL,
|
||||
"currency" text DEFAULT 'USD' NOT NULL,
|
||||
"details" jsonb,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"paid_at" timestamp with time zone,
|
||||
"paid_method" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lead_notifications" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"lead_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"chat_id" bigint,
|
||||
"message_id" integer,
|
||||
"has_photo" boolean DEFAULT false NOT NULL,
|
||||
"sent_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "leads" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"listing_id" integer,
|
||||
"source" text NOT NULL,
|
||||
"url" text,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"deal_type" text,
|
||||
"property_type" text,
|
||||
"region" text,
|
||||
"district" text,
|
||||
"address" text,
|
||||
"price" numeric(14, 2),
|
||||
"currency" text,
|
||||
"rooms" integer,
|
||||
"area" numeric(10, 2),
|
||||
"floor" integer,
|
||||
"floors_total" integer,
|
||||
"photos" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"author_name" text,
|
||||
"phone" text,
|
||||
"assignee_id" integer,
|
||||
"status_id" integer NOT NULL,
|
||||
"reject_reason_id" integer,
|
||||
"next_action_at" timestamp with time zone,
|
||||
"next_action_note" text,
|
||||
"next_action_notified_at" timestamp with time zone,
|
||||
"taken_at" timestamp with time zone,
|
||||
"status_changed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"pool_warned_at" timestamp with time zone,
|
||||
"previous_lead_id" integer,
|
||||
"created_by_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "listings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"external_id" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"deal_type" text,
|
||||
"property_type" text,
|
||||
"region" text,
|
||||
"district" text,
|
||||
"address" text,
|
||||
"price" numeric(14, 2),
|
||||
"currency" text,
|
||||
"rooms" integer,
|
||||
"area" numeric(10, 2),
|
||||
"floor" integer,
|
||||
"floors_total" integer,
|
||||
"photos" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"author_name" text,
|
||||
"phone" text,
|
||||
"is_agency" boolean DEFAULT false NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"removed_at" timestamp with time zone,
|
||||
"last_checked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "outbox" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"lead_id" integer,
|
||||
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"run_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"done_at" timestamp with time zone,
|
||||
"error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "parser_runs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"finished_at" timestamp with time zone,
|
||||
"status" text DEFAULT 'running' NOT NULL,
|
||||
"found_new" integer DEFAULT 0 NOT NULL,
|
||||
"errors" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "parser_state" (
|
||||
"category_id" integer PRIMARY KEY NOT NULL,
|
||||
"baseline_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "payments" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"invoice_id" integer NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"external_id" text,
|
||||
"amount" numeric(14, 2) NOT NULL,
|
||||
"currency" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pipeline_statuses" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"pipeline" text NOT NULL,
|
||||
"code" text,
|
||||
"name" text NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"pool_return" boolean DEFAULT false NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reject_reasons" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"pipeline" text NOT NULL,
|
||||
"code" text,
|
||||
"name" text NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "seen_listings" (
|
||||
"external_id" text PRIMARY KEY NOT NULL,
|
||||
"seen_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"token_hash" text PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"acting_company_id" integer,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer,
|
||||
"role" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"telegram_id" bigint,
|
||||
"telegram_username" text,
|
||||
"email" text,
|
||||
"email_verified_at" timestamp with time zone,
|
||||
"password_hash" text,
|
||||
"lang" text DEFAULT 'ru' NOT NULL,
|
||||
"quiet_from" text,
|
||||
"quiet_to" text,
|
||||
"notifications_paused" boolean DEFAULT false NOT NULL,
|
||||
"telegram_link_token" text,
|
||||
"removed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "admin_messages" ADD CONSTRAINT "admin_messages_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "admin_messages" ADD CONSTRAINT "admin_messages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "blacklist" ADD CONSTRAINT "blacklist_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "blacklist" ADD CONSTRAINT "blacklist_added_by_id_users_id_fk" FOREIGN KEY ("added_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_leads" ADD CONSTRAINT "client_leads_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_leads" ADD CONSTRAINT "client_leads_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_status_id_pipeline_statuses_id_fk" FOREIGN KEY ("status_id") REFERENCES "public"."pipeline_statuses"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_reject_reason_id_reject_reasons_id_fk" FOREIGN KEY ("reject_reason_id") REFERENCES "public"."reject_reasons"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD CONSTRAINT "deals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD CONSTRAINT "deals_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD CONSTRAINT "deals_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD CONSTRAINT "deals_agent_id_users_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_tokens" ADD CONSTRAINT "email_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "filters" ADD CONSTRAINT "filters_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "filters" ADD CONSTRAINT "filters_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_notifications" ADD CONSTRAINT "lead_notifications_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_notifications" ADD CONSTRAINT "lead_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_listing_id_listings_id_fk" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_status_id_pipeline_statuses_id_fk" FOREIGN KEY ("status_id") REFERENCES "public"."pipeline_statuses"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_reject_reason_id_reject_reasons_id_fk" FOREIGN KEY ("reject_reason_id") REFERENCES "public"."reject_reasons"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "outbox" ADD CONSTRAINT "outbox_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "outbox" ADD CONSTRAINT "outbox_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD CONSTRAINT "payments_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pipeline_statuses" ADD CONSTRAINT "pipeline_statuses_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reject_reasons" ADD CONSTRAINT "reject_reasons_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_acting_company_id_companies_id_fk" FOREIGN KEY ("acting_company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "blacklist_uq" ON "blacklist" USING btree ("company_id","phone");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "client_leads_uq" ON "client_leads" USING btree ("client_id","lead_id");--> statement-breakpoint
|
||||
CREATE INDEX "clients_company_idx" ON "clients" USING btree ("company_id","status_id");--> statement-breakpoint
|
||||
CREATE INDEX "deals_company_date_idx" ON "deals" USING btree ("company_id","deal_date");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "deals_lead_uq" ON "deals" USING btree ("lead_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "deals_client_uq" ON "deals" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "events_lead_idx" ON "events" USING btree ("lead_id");--> statement-breakpoint
|
||||
CREATE INDEX "events_client_idx" ON "events" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "filters_company_idx" ON "filters" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "invoices_company_idx" ON "invoices" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "lead_notifications_lead_idx" ON "lead_notifications" USING btree ("lead_id");--> statement-breakpoint
|
||||
CREATE INDEX "leads_company_status_idx" ON "leads" USING btree ("company_id","status_id");--> statement-breakpoint
|
||||
CREATE INDEX "leads_company_assignee_idx" ON "leads" USING btree ("company_id","assignee_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "leads_company_listing_uq" ON "leads" USING btree ("company_id","listing_id") WHERE "leads"."previous_lead_id" is null and "leads"."listing_id" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "listings_external_uq" ON "listings" USING btree ("external_id");--> statement-breakpoint
|
||||
CREATE INDEX "listings_phone_idx" ON "listings" USING btree ("phone");--> statement-breakpoint
|
||||
CREATE INDEX "outbox_due_idx" ON "outbox" USING btree ("run_at") WHERE "outbox"."done_at" is null;--> statement-breakpoint
|
||||
CREATE INDEX "statuses_company_idx" ON "pipeline_statuses" USING btree ("company_id","pipeline");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_telegram_uq" ON "users" USING btree ("telegram_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_uq" ON "users" USING btree (lower("email"));--> statement-breakpoint
|
||||
CREATE INDEX "users_company_idx" ON "users" USING btree ("company_id");
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "events" ADD COLUMN "status_id" integer;--> statement-breakpoint
|
||||
UPDATE "events" e SET "status_id" = s."id"
|
||||
FROM "pipeline_statuses" s
|
||||
WHERE e."type" IN ('status', 'deal') AND s."company_id" = e."company_id" AND s."name" = e."new_value"
|
||||
AND s."pipeline" = CASE WHEN e."lead_id" IS NOT NULL THEN 'object' ELSE 'client' END;
|
||||
--> statement-breakpoint
|
||||
UPDATE "events" e SET "status_id" = s."id"
|
||||
FROM "pipeline_statuses" s
|
||||
WHERE e."type" = 'blacklisted' AND s."company_id" = e."company_id" AND s."pipeline" = 'object' AND s."code" = 'rejected';
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE "settings" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"value" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "admin_messages" ADD COLUMN "unreachable" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "percent_from" date;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "billing_notice" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "via_telegram" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "onboarded_at" timestamp with time zone;--> statement-breakpoint
|
||||
-- Уже работающим пользователям мастер настройки не показывается.
|
||||
UPDATE "users" SET "onboarded_at" = "created_at" WHERE "company_id" IS NOT NULL;--> statement-breakpoint
|
||||
UPDATE "companies" SET "percent_from" = ("created_at" AT TIME ZONE 'Asia/Yerevan')::date WHERE "billing_model" = 'percent';
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE "support_messages" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"ticket_id" integer NOT NULL,
|
||||
"from_support" boolean DEFAULT false NOT NULL,
|
||||
"author_id" integer,
|
||||
"text" text,
|
||||
"file_id" text,
|
||||
"file_kind" text,
|
||||
"file_name" text,
|
||||
"at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "support_tickets" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"chat_id" bigint NOT NULL,
|
||||
"user_id" integer,
|
||||
"tg_name" text,
|
||||
"tg_username" text,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"unread" boolean DEFAULT true NOT NULL,
|
||||
"last_message_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"closed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "outbox" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "support_messages" ADD CONSTRAINT "support_messages_ticket_id_support_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."support_tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "support_messages" ADD CONSTRAINT "support_messages_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "support_messages_ticket_idx" ON "support_messages" USING btree ("ticket_id");--> statement-breakpoint
|
||||
CREATE INDEX "support_tickets_status_idx" ON "support_tickets" USING btree ("status","last_message_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "support_tickets_open_uq" ON "support_tickets" USING btree ("chat_id") WHERE "support_tickets"."status" = 'open';
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "client_photos" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"client_id" integer NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"content_type" text NOT NULL,
|
||||
"size" integer NOT NULL,
|
||||
"data" text NOT NULL,
|
||||
"uploaded_by_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD COLUMN "household" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "features" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "listings" ADD COLUMN "features" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "client_photos" ADD CONSTRAINT "client_photos_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_photos" ADD CONSTRAINT "client_photos_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_photos" ADD CONSTRAINT "client_photos_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "client_photos_client_idx" ON "client_photos" USING btree ("client_id");
|
||||
@@ -0,0 +1,58 @@
|
||||
CREATE TABLE "client_pages" (
|
||||
"token" text PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"client_id" integer NOT NULL,
|
||||
"created_by_id" integer NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "intake_links" (
|
||||
"token" text PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "intake_sessions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"link_token" text NOT NULL,
|
||||
"chat_id" bigint NOT NULL,
|
||||
"tg_name" text,
|
||||
"tg_username" text,
|
||||
"lang" text DEFAULT 'ru' NOT NULL,
|
||||
"step" text NOT NULL,
|
||||
"answers" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"client_id" integer,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "viewings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"client_id" integer NOT NULL,
|
||||
"lead_id" integer NOT NULL,
|
||||
"day" date NOT NULL,
|
||||
"note" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "slug" text;--> statement-breakpoint
|
||||
ALTER TABLE "client_pages" ADD CONSTRAINT "client_pages_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_pages" ADD CONSTRAINT "client_pages_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_pages" ADD CONSTRAINT "client_pages_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "intake_links" ADD CONSTRAINT "intake_links_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "intake_links" ADD CONSTRAINT "intake_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "intake_sessions" ADD CONSTRAINT "intake_sessions_link_token_intake_links_token_fk" FOREIGN KEY ("link_token") REFERENCES "public"."intake_links"("token") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "intake_sessions" ADD CONSTRAINT "intake_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "viewings" ADD CONSTRAINT "viewings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "viewings" ADD CONSTRAINT "viewings_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "viewings" ADD CONSTRAINT "viewings_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "client_pages_client_uq" ON "client_pages" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "intake_links_user_uq" ON "intake_links" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "intake_sessions_chat_idx" ON "intake_sessions" USING btree ("chat_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "viewings_uq" ON "viewings" USING btree ("client_id","lead_id");--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD CONSTRAINT "companies_slug_unique" UNIQUE("slug");--> statement-breakpoint
|
||||
-- У уже созданных компаний адрес страниц — «c<номер>»; руководитель может поменять его в настройках.
|
||||
UPDATE "companies" SET "slug" = 'c' || "id" WHERE "slug" IS NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "leads" ADD COLUMN "showcase_text" text;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "gallery_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "gallery_tries" integer DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "leads" ADD COLUMN "gallery_wanted_at" timestamp with time zone;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "listing_phones" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"listing_id" integer NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"user_id" integer,
|
||||
"phone" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "listing_phones" ADD CONSTRAINT "listing_phones_listing_id_listings_id_fk" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "listing_phones" ADD CONSTRAINT "listing_phones_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "listing_phones" ADD CONSTRAINT "listing_phones_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "listing_phones_uq" ON "listing_phones" USING btree ("listing_id","company_id");--> statement-breakpoint
|
||||
CREATE INDEX "listing_phones_phone_idx" ON "listing_phones" USING btree ("listing_id","phone");--> statement-breakpoint
|
||||
-- Номера, уже внесённые агентами, становятся вариантами в общей базе.
|
||||
INSERT INTO "listing_phones" ("listing_id", "company_id", "phone")
|
||||
SELECT DISTINCT ON (l."listing_id", l."company_id") l."listing_id", l."company_id", l."phone"
|
||||
FROM "leads" l WHERE l."listing_id" IS NOT NULL AND l."phone" IS NOT NULL
|
||||
ORDER BY l."listing_id", l."company_id", l."id" DESC
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "commission" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "commission" jsonb;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "pay" jsonb;
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE "deals" ADD COLUMN "owner_commission" numeric(14, 2) DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD COLUMN "client_commission" numeric(14, 2) DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD COLUMN "owner_agent_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD COLUMN "client_agent_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD COLUMN "kind" text DEFAULT 'sale' NOT NULL;--> statement-breakpoint
|
||||
-- Прежние сделки остаются целиком за тем, кто их закрыл: сумма — на стороне объекта, если он есть, иначе на стороне клиента.
|
||||
UPDATE "deals" SET
|
||||
"owner_agent_id" = "agent_id",
|
||||
"client_agent_id" = "agent_id",
|
||||
"owner_commission" = CASE WHEN "lead_id" IS NOT NULL THEN "commission" ELSE 0 END,
|
||||
"client_commission" = CASE WHEN "lead_id" IS NOT NULL THEN 0 ELSE "commission" END;--> statement-breakpoint
|
||||
UPDATE "deals" d SET "kind" = 'rent'
|
||||
WHERE EXISTS (SELECT 1 FROM "leads" l WHERE l."id" = d."lead_id" AND l."deal_type" = 'rent')
|
||||
OR (NOT EXISTS (SELECT 1 FROM "leads" l WHERE l."id" = d."lead_id" AND l."deal_type" IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM "clients" c WHERE c."id" = d."client_id" AND c."kind" = 'tenant'));--> statement-breakpoint
|
||||
ALTER TABLE "deals" ALTER COLUMN "owner_agent_id" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ALTER COLUMN "client_agent_id" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD CONSTRAINT "deals_owner_agent_id_users_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deals" ADD CONSTRAINT "deals_client_agent_id_users_id_fk" FOREIGN KEY ("client_agent_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "pay_adjustments" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"month" text NOT NULL,
|
||||
"amount" numeric(14, 2) NOT NULL,
|
||||
"currency" text NOT NULL,
|
||||
"note" text NOT NULL,
|
||||
"created_by" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "payouts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"month" text NOT NULL,
|
||||
"amount" numeric(14, 2) NOT NULL,
|
||||
"currency" text NOT NULL,
|
||||
"paid_at" date NOT NULL,
|
||||
"note" text,
|
||||
"snapshot" jsonb NOT NULL,
|
||||
"created_by" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "pay_adjustments" ADD CONSTRAINT "pay_adjustments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pay_adjustments" ADD CONSTRAINT "pay_adjustments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pay_adjustments" ADD CONSTRAINT "pay_adjustments_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payouts" ADD CONSTRAINT "payouts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payouts" ADD CONSTRAINT "payouts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payouts" ADD CONSTRAINT "payouts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "pay_adjustments_user_month_idx" ON "pay_adjustments" USING btree ("company_id","user_id","month");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "payouts_user_month_uq" ON "payouts" USING btree ("user_id","month");--> statement-breakpoint
|
||||
CREATE INDEX "payouts_company_idx" ON "payouts" USING btree ("company_id");
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE "bot_langs" (
|
||||
"telegram_id" bigint PRIMARY KEY NOT NULL,
|
||||
"lang" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "leads" ADD COLUMN "bumped_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "listings" ADD COLUMN "bumped_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "parser_state" ADD COLUMN "top_ids" jsonb;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "telegram" text;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD COLUMN "rent_term" text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "leads" ADD COLUMN "group_posted_commission" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "group_posted_at" timestamp with time zone;
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "departments" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"company_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lead_favorites" (
|
||||
"user_id" integer NOT NULL,
|
||||
"lead_id" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "lead_favorites_user_id_lead_id_pk" PRIMARY KEY("user_id","lead_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lead_views" (
|
||||
"user_id" integer NOT NULL,
|
||||
"lead_id" integer NOT NULL,
|
||||
"viewed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "lead_views_user_id_lead_id_pk" PRIMARY KEY("user_id","lead_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "leads_company_listing_uq";--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD COLUMN "department_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "filters" ADD COLUMN "department_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "department_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "department_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "departments" ADD CONSTRAINT "departments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_favorites" ADD CONSTRAINT "lead_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_favorites" ADD CONSTRAINT "lead_favorites_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_views" ADD CONSTRAINT "lead_views_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_views" ADD CONSTRAINT "lead_views_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "departments_company_idx" ON "departments" USING btree ("company_id");--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "filters" ADD CONSTRAINT "filters_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "leads_company_dept_listing_uq" ON "leads" USING btree ("company_id",coalesce("department_id", 0),"listing_id") WHERE "leads"."previous_lead_id" is null and "leads"."listing_id" is not null;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1791474709969,
|
||||
"tag": "0000_busy_kat_farrell",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1791486109720,
|
||||
"tag": "0001_left_lady_deathstrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1791487573067,
|
||||
"tag": "0002_black_shocker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1791538527629,
|
||||
"tag": "0003_sharp_giant_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1791542601013,
|
||||
"tag": "0004_eager_mockingbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1791548601291,
|
||||
"tag": "0005_left_slapstick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1791550647233,
|
||||
"tag": "0006_thick_umar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1791552698846,
|
||||
"tag": "0007_chilly_maddog",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1791553975016,
|
||||
"tag": "0008_concerned_cardiac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1791555219811,
|
||||
"tag": "0009_bizarre_randall_flagg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1791555417590,
|
||||
"tag": "0010_wakeful_exodus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1791558446545,
|
||||
"tag": "0011_soft_edwin_jarvis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1791913393916,
|
||||
"tag": "0012_organic_thing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1791915450918,
|
||||
"tag": "0013_shallow_kabuki",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1791918705929,
|
||||
"tag": "0014_modern_gabe_jones",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1792148447759,
|
||||
"tag": "0015_supreme_clint_barton",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1792444245607,
|
||||
"tag": "0016_massive_maria_hill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1792445277037,
|
||||
"tag": "0017_open_gabe_jones",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1792954204010,
|
||||
"tag": "0018_elite_magdalene",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@lr/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=../../.env src/main.ts",
|
||||
"start": "tsx --env-file=../../.env src/main.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx --env-file=../../.env src/db/migrate.ts",
|
||||
"superadmin": "tsx --env-file=../../.env src/cli/create-superadmin.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@lr/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.5",
|
||||
"postgres": "^3.4.9",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { DEAL_TYPES, PROPERTY_TYPES } from '@lr/shared';
|
||||
import { and, desc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from './db/client.ts';
|
||||
import { companies, leads, listings, pipelineStatuses, users } from './db/schema.ts';
|
||||
import { notFound } from './lib/errors.ts';
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
/** Суперадмин: общая база объявлений сервиса — всё, что парсер собрал с list.am. */
|
||||
export function registerAdminListingRoutes(app: FastifyInstance, db: Db, superadmin: (req: FastifyRequest) => unknown) {
|
||||
app.get('/api/admin/listings', async (req) => {
|
||||
superadmin(req);
|
||||
const q = z
|
||||
.object({
|
||||
q: z.string().trim().max(100).optional(),
|
||||
dealType: z.enum(DEAL_TYPES).optional(),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional(),
|
||||
region: z.string().trim().max(60).optional(),
|
||||
/** active — на сайте, removed — сняты. */
|
||||
state: z.enum(['active', 'removed']).optional(),
|
||||
/** yes — по объявлению есть лид хотя бы у одной компании, no — ни у кого. */
|
||||
leads: z.enum(['yes', 'no']).optional(),
|
||||
author: z.enum(['owner', 'agency']).optional(),
|
||||
priceFrom: z.coerce.number().nonnegative().optional(),
|
||||
priceTo: z.coerce.number().nonnegative().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
})
|
||||
.parse(req.query);
|
||||
const leadCount = sql<number>`(select count(*)::int from leads l where l.listing_id = "listings"."id")`;
|
||||
const where: (SQL | undefined)[] = [];
|
||||
if (q.dealType) where.push(eq(listings.dealType, q.dealType));
|
||||
if (q.propertyType) where.push(eq(listings.propertyType, q.propertyType));
|
||||
if (q.region) where.push(eq(listings.region, q.region));
|
||||
if (q.state) where.push(eq(listings.active, q.state === 'active'));
|
||||
if (q.author) where.push(eq(listings.isAgency, q.author === 'agency'));
|
||||
if (q.priceFrom !== undefined) where.push(gte(listings.price, q.priceFrom));
|
||||
if (q.priceTo !== undefined) where.push(lte(listings.price, q.priceTo));
|
||||
if (q.leads) where.push(sql`${q.leads === 'yes' ? sql`` : sql`not `}exists (select 1 from leads l where l.listing_id = "listings"."id")`);
|
||||
if (q.q) {
|
||||
// Поиск по номеру объявления, названию, адресу, району и телефону.
|
||||
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
|
||||
const digits = q.q.replace(/\D/g, '');
|
||||
where.push(
|
||||
or(
|
||||
eq(listings.externalId, q.q),
|
||||
ilike(listings.title, like),
|
||||
ilike(listings.address, like),
|
||||
ilike(listings.district, like),
|
||||
digits.length >= 4 ? ilike(listings.phone, `%${digits}%`) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
const [[total], rows] = await Promise.all([
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(listings).where(and(...where)),
|
||||
db
|
||||
.select({ listing: listings, leads: leadCount })
|
||||
.from(listings)
|
||||
.where(and(...where))
|
||||
.orderBy(desc(sql`coalesce(${listings.bumpedAt}, ${listings.publishedAt}, ${listings.createdAt})`), desc(listings.id))
|
||||
.limit(PAGE)
|
||||
.offset((q.page - 1) * PAGE),
|
||||
]);
|
||||
return {
|
||||
total: total?.n ?? 0,
|
||||
pageSize: PAGE,
|
||||
items: rows.map(({ listing: l, leads: n }) => ({
|
||||
id: l.id, externalId: l.externalId, url: l.url, title: l.title, dealType: l.dealType, propertyType: l.propertyType,
|
||||
region: l.region, district: l.district, address: l.address, price: l.price, currency: l.currency, rooms: l.rooms,
|
||||
area: l.area, floor: l.floor, floorsTotal: l.floorsTotal, photo: l.photos[0] ?? null, isAgency: l.isAgency,
|
||||
phone: l.phone, publishedAt: l.publishedAt, bumpedAt: l.bumpedAt, createdAt: l.createdAt, active: l.active, removedAt: l.removedAt, leads: n,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
/** Объявление целиком и то, каким компаниям оно ушло лидом. */
|
||||
app.get('/api/admin/listings/:id', async (req) => {
|
||||
superadmin(req);
|
||||
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(req.params);
|
||||
const [listing] = await db.select().from(listings).where(eq(listings.id, id));
|
||||
if (!listing) throw notFound();
|
||||
const sent = await db
|
||||
.select({ id: leads.id, company: companies.name, status: pipelineStatuses.name, assignee: users.name, createdAt: leads.createdAt })
|
||||
.from(leads)
|
||||
.innerJoin(companies, eq(companies.id, leads.companyId))
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.where(eq(leads.listingId, id))
|
||||
.orderBy(desc(leads.id));
|
||||
return { ...listing, leads: sent };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hasModule, PIPELINES, type Plan } from '@lr/shared';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import type { Guards } from '../leads/routes.ts';
|
||||
import { badRequest, forbidden } from '../lib/errors.ts';
|
||||
import { buildAnalytics } from './service.ts';
|
||||
|
||||
export function registerAnalyticsRoutes(app: FastifyInstance, db: Db, g: Guards) {
|
||||
/** Аналитика по сотрудникам: только руководителю и только в тарифах Team и Agency. */
|
||||
app.get('/api/analytics', async (req) => {
|
||||
const ctx = g.manager(req);
|
||||
if (!hasModule(ctx.company.plan as Plan, 'analytics')) throw forbidden('MODULE_NOT_AVAILABLE');
|
||||
const q = z
|
||||
.object({ pipeline: z.enum(PIPELINES).default('object'), from: z.coerce.date(), to: z.coerce.date() })
|
||||
.parse(req.query);
|
||||
if (q.from > q.to) throw badRequest('VALIDATION');
|
||||
return buildAnalytics(db, ctx.company.id, q.pipeline, q.from, q.to);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { Pipeline } from '@lr/shared';
|
||||
import { and, asc, eq, gte, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clientLeads, clients, deals, events, leads, pipelineStatuses, rejectReasons, users } from '../db/schema.ts';
|
||||
|
||||
// Аналитика по сотрудникам (ТЗ, раздел 6). Считается отдельно по воронке объектов и по воронке
|
||||
// клиентов. Конверсия по этапам — когортная: берутся карточки, которые агент получил за период,
|
||||
// и для каждого этапа считается, сколько из них до него дошли (на сегодня).
|
||||
|
||||
export interface AgentRow {
|
||||
userId: number;
|
||||
name: string;
|
||||
/** Объекты: нажатия «Беру» и назначения. Клиенты: новые запросы. */
|
||||
taken: number;
|
||||
/** Медиана времени от уведомления до «Беру», секунды; null — нет данных. Только объекты. */
|
||||
reactionSec: number | null;
|
||||
/** Лиды, потерянные по правилу возврата в пул. Только объекты. */
|
||||
poolReturns: number;
|
||||
/** Показы объектов клиентам. Только клиенты. */
|
||||
showings: number;
|
||||
/** Карточек в когорте — знаменатель конверсии. */
|
||||
cohort: number;
|
||||
/** Сколько карточек когорты дошли до статуса: statusId → число. */
|
||||
reached: Record<number, number>;
|
||||
deals: number;
|
||||
/** Сумма комиссий по валютам. */
|
||||
commission: Record<string, number>;
|
||||
/** Отказы по причинам: reasonId → число. */
|
||||
reasons: Record<number, number>;
|
||||
}
|
||||
|
||||
export interface Analytics {
|
||||
pipeline: Pipeline;
|
||||
statuses: { id: number; code: string | null; name: string }[];
|
||||
reasons: { id: number; name: string }[];
|
||||
/** Сводка по компании: пришло (создано) за период, взято, сейчас в пуле (у клиентов — в работе). */
|
||||
summary: { created: number; taken: number; open: number; funnel: Record<number, number>; cohort: number };
|
||||
agents: AgentRow[];
|
||||
totals: AgentRow;
|
||||
}
|
||||
|
||||
const median = (values: number[]) => {
|
||||
if (!values.length) return null;
|
||||
const s = [...values].sort((a, b) => a - b);
|
||||
const mid = s.length >> 1;
|
||||
return Math.round(s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2);
|
||||
};
|
||||
|
||||
const emptyRow = (userId: number, name: string): AgentRow => ({
|
||||
userId, name, taken: 0, reactionSec: null, poolReturns: 0, showings: 0, cohort: 0, reached: {}, deals: 0, commission: {}, reasons: {},
|
||||
});
|
||||
|
||||
const bump = (map: Record<string | number, number>, key: string | number, by = 1) => {
|
||||
map[key] = (map[key] ?? 0) + by;
|
||||
};
|
||||
|
||||
/** Дата по Еревану (UTC+4) для сравнения с датой сделки. */
|
||||
const yerevanDate = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
|
||||
|
||||
export async function buildAnalytics(db: Db, companyId: number, pipeline: Pipeline, from: Date, to: Date): Promise<Analytics> {
|
||||
const [statuses, reasons, members] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(pipelineStatuses)
|
||||
.where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline), isNull(pipelineStatuses.deletedAt)))
|
||||
.orderBy(asc(pipelineStatuses.position)),
|
||||
// Удалённые причины остаются в отчёте: по ним могли быть отказы.
|
||||
db.select().from(rejectReasons).where(and(eq(rejectReasons.companyId, companyId), eq(rejectReasons.pipeline, pipeline))).orderBy(asc(rejectReasons.position)),
|
||||
db.select({ id: users.id, name: users.name, removedAt: users.removedAt }).from(users).where(eq(users.companyId, companyId)).orderBy(asc(users.id)),
|
||||
]);
|
||||
const position = new Map(statuses.map((s, i) => [s.id, i]));
|
||||
const rejectedId = statuses.find((s) => s.code === 'rejected')?.id;
|
||||
const takenId = statuses.find((s) => s.code === 'taken')?.id;
|
||||
const rows = new Map(members.map((m) => [m.id, emptyRow(m.id, m.name)]));
|
||||
const row = (userId: number) => rows.get(userId) ?? rows.set(userId, emptyRow(userId, `#${userId}`)).get(userId)!;
|
||||
const summary = { created: 0, taken: 0, open: 0, funnel: {} as Record<number, number>, cohort: 0 };
|
||||
|
||||
/**
|
||||
* Карточка «дошла до этапа», если побывала в нём или в любом более позднем рабочем этапе.
|
||||
* «Отказ» считается отдельно: до него доходят из любого этапа.
|
||||
*/
|
||||
const countReached = (target: Record<number, number>, visited: Set<number>) => {
|
||||
let max = -1;
|
||||
for (const id of visited) if (id !== rejectedId) max = Math.max(max, position.get(id) ?? -1);
|
||||
for (const s of statuses) {
|
||||
if (s.id === rejectedId ? visited.has(s.id) : position.get(s.id)! <= max) bump(target, s.id);
|
||||
}
|
||||
};
|
||||
|
||||
const inPeriod = (column: Parameters<typeof gte>[0]) => and(gte(column, from), lte(column, to));
|
||||
let allReactions: number[] = [];
|
||||
const fromDay = yerevanDate(from);
|
||||
const toDay = yerevanDate(to);
|
||||
|
||||
if (pipeline === 'object') {
|
||||
// Кто и когда получил лид за период.
|
||||
const takes = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(and(eq(events.companyId, companyId), isNotNull(events.leadId), inArray(events.type, ['taken', 'assigned']), inPeriod(events.at)));
|
||||
const [createdRows, manual] = await Promise.all([
|
||||
db.select({ id: leads.id, statusId: leads.statusId }).from(leads).where(and(eq(leads.companyId, companyId), inPeriod(leads.createdAt))),
|
||||
db.select().from(events).where(and(eq(events.companyId, companyId), isNotNull(events.leadId), eq(events.type, 'created'), eq(events.newValue, 'manual'), inPeriod(events.at))),
|
||||
]);
|
||||
const owner = (e: typeof events.$inferSelect) => (e.type === 'assigned' ? Number(e.newValue) : e.userId);
|
||||
for (const e of takes) if (owner(e)) row(owner(e)!).taken++;
|
||||
summary.created = createdRows.length;
|
||||
summary.taken = new Set(takes.map((e) => e.leadId)).size;
|
||||
|
||||
// История когортных лидов: по ней восстанавливаем, при каком агенте лид дошёл до этапа.
|
||||
const cohortIds = [...new Set([...takes, ...manual].map((e) => e.leadId!).concat(createdRows.map((l) => l.id)))];
|
||||
const history = cohortIds.length
|
||||
? await db.select().from(events).where(inArray(events.leadId, cohortIds)).orderBy(asc(events.at), asc(events.id))
|
||||
: [];
|
||||
const starts = new Set([...takes, ...manual].map((e) => e.id));
|
||||
/** leadId → (агент → этапы, пройденные при нём); в когорту агента лид входит, если получен за период. */
|
||||
const perLead = new Map<number, { all: Set<number>; byAgent: Map<number, { visited: Set<number>; inCohort: boolean }> }>();
|
||||
const current = new Map<number, number | null>();
|
||||
for (const e of history) {
|
||||
const lead = perLead.get(e.leadId!) ?? perLead.set(e.leadId!, { all: new Set(), byAgent: new Map() }).get(e.leadId!)!;
|
||||
const seg = (userId: number) => lead.byAgent.get(userId) ?? lead.byAgent.set(userId, { visited: new Set(), inCohort: false }).get(userId)!;
|
||||
const visit = (statusId: number | null | undefined) => {
|
||||
if (!statusId) return;
|
||||
lead.all.add(statusId);
|
||||
const who = current.get(e.leadId!);
|
||||
if (who) seg(who).visited.add(statusId);
|
||||
};
|
||||
if (e.type === 'taken' || e.type === 'assigned' || (e.type === 'created' && e.newValue === 'manual')) {
|
||||
const who = owner(e);
|
||||
if (!who) continue;
|
||||
current.set(e.leadId!, who);
|
||||
if (starts.has(e.id)) seg(who).inCohort = true;
|
||||
visit(takenId);
|
||||
} else if (e.type === 'pool_return') {
|
||||
current.set(e.leadId!, null);
|
||||
} else if (e.type === 'blacklisted') {
|
||||
// «Это агент» из пула закрепляет лид за нажавшим.
|
||||
if (!current.get(e.leadId!) && e.userId) current.set(e.leadId!, e.userId);
|
||||
visit(e.statusId ?? rejectedId);
|
||||
} else if (e.type === 'status' || e.type === 'deal') {
|
||||
visit(e.statusId);
|
||||
}
|
||||
}
|
||||
for (const lead of perLead.values()) {
|
||||
for (const [userId, seg] of lead.byAgent) {
|
||||
if (!seg.inCohort) continue;
|
||||
const r = row(userId);
|
||||
r.cohort++;
|
||||
countReached(r.reached, seg.visited);
|
||||
}
|
||||
}
|
||||
// Воронка целиком — по лидам, пришедшим за период; «Новый» прошли все.
|
||||
const newId = statuses.find((s) => s.code === 'new')?.id;
|
||||
for (const l of createdRows) {
|
||||
const visited = new Set(perLead.get(l.id)?.all ?? []);
|
||||
visited.add(l.statusId);
|
||||
if (newId) visited.add(newId);
|
||||
countReached(summary.funnel, visited);
|
||||
}
|
||||
summary.cohort = createdRows.length;
|
||||
|
||||
const [pool, reactions, returns, rejected] = await Promise.all([
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(leads).where(and(eq(leads.companyId, companyId), isNull(leads.assigneeId), newId ? eq(leads.statusId, newId) : undefined)),
|
||||
db
|
||||
.select({
|
||||
userId: events.userId,
|
||||
// Имена колонок внешнего запроса указаны с таблицей: иначе внутри подзапроса они относятся к n.
|
||||
sec: sql<number | null>`extract(epoch from "events"."at" - (select max(n.sent_at) from lead_notifications n where n.lead_id = "events"."lead_id" and n.user_id = "events"."user_id" and n.sent_at <= "events"."at"))::float`,
|
||||
})
|
||||
.from(events)
|
||||
.where(and(eq(events.companyId, companyId), eq(events.type, 'taken'), isNotNull(events.leadId), inPeriod(events.at))),
|
||||
db.select().from(events).where(and(eq(events.companyId, companyId), eq(events.type, 'pool_return'), isNull(events.userId), isNotNull(events.oldValue), inPeriod(events.at))),
|
||||
rejectedId
|
||||
? db
|
||||
.select({ userId: leads.assigneeId, reasonId: leads.rejectReasonId })
|
||||
.from(leads)
|
||||
.where(and(eq(leads.companyId, companyId), eq(leads.statusId, rejectedId), inPeriod(leads.statusChangedAt)))
|
||||
: [],
|
||||
]);
|
||||
summary.open = pool[0]?.n ?? 0;
|
||||
const bySpeed = new Map<number, number[]>();
|
||||
for (const r of reactions) if (r.userId && r.sec != null) bySpeed.set(r.userId, [...(bySpeed.get(r.userId) ?? []), r.sec]);
|
||||
for (const [userId, list] of bySpeed) row(userId).reactionSec = median(list);
|
||||
for (const e of returns) row(Number(e.oldValue)).poolReturns++;
|
||||
for (const r of rejected) if (r.userId && r.reasonId) bump(row(r.userId).reasons, r.reasonId);
|
||||
allReactions = reactions.flatMap((r) => (r.sec != null ? [r.sec] : []));
|
||||
} else {
|
||||
const created = await db
|
||||
.select({ id: clients.id, statusId: clients.statusId, assigneeId: clients.assigneeId })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.companyId, companyId), inPeriod(clients.createdAt)));
|
||||
const ids = created.map((c) => c.id);
|
||||
const history = ids.length
|
||||
? await db.select({ clientId: events.clientId, statusId: events.statusId }).from(events).where(and(inArray(events.clientId, ids), isNotNull(events.statusId)))
|
||||
: [];
|
||||
const newId = statuses.find((s) => s.code === 'new')?.id;
|
||||
for (const c of created) {
|
||||
const visited = new Set(history.filter((e) => e.clientId === c.id).map((e) => e.statusId!));
|
||||
visited.add(c.statusId);
|
||||
if (newId) visited.add(newId);
|
||||
const r = row(c.assigneeId);
|
||||
r.taken++;
|
||||
r.cohort++;
|
||||
countReached(r.reached, visited);
|
||||
countReached(summary.funnel, visited);
|
||||
}
|
||||
summary.created = summary.taken = summary.cohort = created.length;
|
||||
const [open, shown, rejected] = await Promise.all([
|
||||
db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(clients)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId))
|
||||
.where(and(eq(clients.companyId, companyId), sql`${pipelineStatuses.code} is distinct from 'deal' and ${pipelineStatuses.code} is distinct from 'rejected'`)),
|
||||
db
|
||||
.select({ userId: clients.assigneeId })
|
||||
.from(clientLeads)
|
||||
.innerJoin(clients, eq(clients.id, clientLeads.clientId))
|
||||
.where(and(eq(clients.companyId, companyId), inPeriod(clientLeads.shownAt))),
|
||||
rejectedId
|
||||
? db
|
||||
.select({ userId: clients.assigneeId, reasonId: clients.rejectReasonId })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.companyId, companyId), eq(clients.statusId, rejectedId), inPeriod(clients.statusChangedAt)))
|
||||
: [],
|
||||
]);
|
||||
summary.open = open[0]?.n ?? 0;
|
||||
for (const s of shown) row(s.userId).showings++;
|
||||
for (const r of rejected) if (r.reasonId) bump(row(r.userId).reasons, r.reasonId);
|
||||
}
|
||||
|
||||
// Сделки за период — по дате сделки. Парная сделка одна, но видна в обеих воронках.
|
||||
const closed = await db
|
||||
.select()
|
||||
.from(deals)
|
||||
.where(
|
||||
and(
|
||||
eq(deals.companyId, companyId),
|
||||
isNotNull(pipeline === 'object' ? deals.leadId : deals.clientId),
|
||||
gte(deals.dealDate, fromDay),
|
||||
lte(deals.dealDate, toDay),
|
||||
),
|
||||
);
|
||||
// Каждому агенту — его часть комиссии: с собственника — агенту объекта, с клиента — агенту клиента.
|
||||
for (const d of closed) {
|
||||
for (const agentId of new Set([d.ownerAgentId, d.clientAgentId])) row(agentId).deals++;
|
||||
if (d.ownerCommission) bump(row(d.ownerAgentId).commission, d.currency, d.ownerCommission);
|
||||
if (d.clientCommission) bump(row(d.clientAgentId).commission, d.currency, d.clientCommission);
|
||||
}
|
||||
|
||||
// Суперадмин, работавший в кабинете компании, в её участниках не числится — имя берём отдельно.
|
||||
const outside = [...rows.keys()].filter((id) => !members.some((m) => m.id === id));
|
||||
if (outside.length) {
|
||||
for (const u of await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, outside))) rows.get(u.id)!.name = u.name;
|
||||
}
|
||||
const removed = new Set(members.filter((m) => m.removedAt).map((m) => m.id));
|
||||
const isEmpty = (r: AgentRow) => !r.taken && !r.cohort && !r.deals && !r.poolReturns && !r.showings && !Object.keys(r.reasons).length;
|
||||
// Удалённые агенты остаются в отчёте, только если за период по ним есть данные.
|
||||
const agents = [...rows.values()].filter((r) => !(isEmpty(r) && (removed.has(r.userId) || !members.some((m) => m.id === r.userId))));
|
||||
const totals = emptyRow(0, '');
|
||||
for (const r of agents) {
|
||||
totals.taken += r.taken;
|
||||
totals.poolReturns += r.poolReturns;
|
||||
totals.showings += r.showings;
|
||||
totals.cohort += r.cohort;
|
||||
for (const [k, v] of Object.entries(r.reached)) bump(totals.reached, k, v);
|
||||
for (const [k, v] of Object.entries(r.commission)) bump(totals.commission, k, v);
|
||||
for (const [k, v] of Object.entries(r.reasons)) bump(totals.reasons, k, v);
|
||||
}
|
||||
// Сделку двух агентов видно у обоих, в итоге она одна.
|
||||
totals.deals = closed.length;
|
||||
totals.reactionSec = median(allReactions);
|
||||
|
||||
return {
|
||||
pipeline,
|
||||
statuses: statuses.map((s) => ({ id: s.id, code: s.code, name: s.name })),
|
||||
reasons: reasons.filter((r) => !r.deletedAt || agents.some((a) => a.reasons[r.id])).map((r) => ({ id: r.id, name: r.name })),
|
||||
summary,
|
||||
agents,
|
||||
totals,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import { companies, pipelineStatuses, users } from './db/schema.ts';
|
||||
import { TEST_BOT_TOKEN, testApp } from './testing.ts';
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
beforeAll(async () => { t = await testApp(); });
|
||||
afterAll(() => t.close());
|
||||
|
||||
const superadmin = async () => {
|
||||
const [u] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Date.now()}${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
|
||||
.returning();
|
||||
return t.client(await createSession(t.db, u!.id));
|
||||
};
|
||||
|
||||
/** Компания, включённая суперадмином, и её руководитель. */
|
||||
const company = async (plan: 'solo' | 'team' | 'agency' = 'team') => {
|
||||
const sa = await superadmin();
|
||||
const created = await sa.post('/api/admin/companies', {
|
||||
name: `Агентство ${plan}`, plan, billingModel: 'fixed',
|
||||
subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const boss = await t.register('Руководитель');
|
||||
const token = (created.body.managerUrl as string).split('/invite/')[1]!;
|
||||
expect((await boss.post(`/api/invites/${token}/accept`)).status).toBe(200);
|
||||
return { sa, boss, id: created.body.id as number, managerToken: token };
|
||||
};
|
||||
|
||||
describe('свой домен компании', () => {
|
||||
const HOST = 'arev.yandp.tech';
|
||||
const loginOn = async (host: string | undefined, email: string) =>
|
||||
t.client(undefined, host).post('/api/auth/login', { email, password: 'password-123' });
|
||||
|
||||
it('на домене компании входят только её сотрудники и суперадмин; на основном — все', async () => {
|
||||
const own = await company();
|
||||
const other = await company();
|
||||
const [co] = await t.db.select().from(companies).where(eq(companies.id, own.id));
|
||||
await t.db.update(companies).set({ settings: { ...co!.settings, clientHost: HOST } }).where(eq(companies.id, own.id));
|
||||
const ownEmail = (await own.boss.get('/api/me')).body.user.email as string;
|
||||
const otherEmail = (await other.boss.get('/api/me')).body.user.email as string;
|
||||
|
||||
// Конфиг подсказывает странице входа, чей это домен.
|
||||
expect((await t.client(undefined, HOST).get('/api/config')).body.domain).toEqual({ company: co!.name, appUrl: 'https://app.test' });
|
||||
expect((await t.client().get('/api/config')).body.domain).toBeNull();
|
||||
|
||||
// Сотрудник компании входит и работает на её домене.
|
||||
const ok = await loginOn(HOST, ownEmail);
|
||||
expect(ok.status).toBe(200);
|
||||
expect((await t.client(ok.body.token, HOST).get('/api/me')).body.company.id).toBe(own.id);
|
||||
|
||||
// Чужой — отказ, сессия не создаётся; на основном домене он входит как обычно.
|
||||
const denied = await loginOn(HOST, otherEmail);
|
||||
expect(denied.body.code).toBe('WRONG_DOMAIN');
|
||||
expect(denied.body.token).toBeUndefined();
|
||||
const elsewhere = await loginOn(undefined, otherEmail);
|
||||
expect(elsewhere.status).toBe(200);
|
||||
// Его сессия, принесённая на чужой домен, не действует.
|
||||
expect((await t.client(elsewhere.body.token, HOST).get('/api/me')).status).toBe(401);
|
||||
expect((await t.client(elsewhere.body.token).get('/api/me')).status).toBe(200);
|
||||
|
||||
// Суперадмин проходит.
|
||||
const [sa] = await t.db.select().from(users).where(eq(users.role, 'superadmin'));
|
||||
expect((await t.client(await createSession(t.db, sa!.id), HOST).get('/api/me')).body.user.isSuperadmin).toBe(true);
|
||||
|
||||
// Регистрация на домене компании закрыта.
|
||||
const reg = await t.client(undefined, HOST).post('/api/auth/register', { name: 'Новый', email: 'new-on-arev@test.am', password: 'password-123' });
|
||||
expect(reg.body.code).toBe('WRONG_DOMAIN');
|
||||
|
||||
// Телеграм-вход: чужой пользователь получает отказ, новый не создаётся.
|
||||
const tgUser = (id: number) => {
|
||||
const data: Record<string, string> = { id: String(id), first_name: 'Тест', auth_date: String(Math.floor(Date.now() / 1000)) };
|
||||
const check = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n');
|
||||
const secret = createHash('sha256').update(TEST_BOT_TOKEN).digest();
|
||||
return { ...data, hash: createHmac('sha256', secret).update(check).digest('hex') };
|
||||
};
|
||||
expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_001))).body.code).toBe('WRONG_DOMAIN');
|
||||
expect(await t.db.select().from(users).where(eq(users.telegramId, 9_001))).toHaveLength(0);
|
||||
await t.db.update(users).set({ telegramId: 9_002 }).where(eq(users.email, otherEmail));
|
||||
expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_002))).body.code).toBe('WRONG_DOMAIN');
|
||||
await t.db.update(users).set({ telegramId: 9_003 }).where(eq(users.email, ownEmail));
|
||||
expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_003))).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('вход', () => {
|
||||
it('регистрация, вход, неверный пароль, выход', async () => {
|
||||
const reg = await t.client().post('/api/auth/register', { name: 'Арам', email: 'Aram@Test.am', password: 'password-123' });
|
||||
expect(reg.status).toBe(200);
|
||||
const dup = await t.client().post('/api/auth/register', { name: 'Арам', email: 'aram@test.am', password: 'password-123' });
|
||||
expect(dup.body.code).toBe('EMAIL_TAKEN');
|
||||
expect((await t.client().post('/api/auth/login', { email: 'aram@test.am', password: 'wrong-password' })).status).toBe(401);
|
||||
const login = await t.client().post('/api/auth/login', { email: 'aram@test.am', password: 'password-123' });
|
||||
const c = t.client(login.body.token);
|
||||
const me = await c.get('/api/me');
|
||||
expect(me.body.user).toMatchObject({ name: 'Арам', emailVerified: false, telegramLinked: false });
|
||||
expect(me.body.company).toBeNull();
|
||||
await c.post('/api/auth/logout');
|
||||
expect((await c.get('/api/me')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('подтверждение email и сброс пароля по ссылке из письма', async () => {
|
||||
t.mails.length = 0;
|
||||
await t.client().post('/api/auth/register', { name: 'Ани', email: 'ani@test.am', password: 'password-123' });
|
||||
const verify = new URL(t.mails[0]!.text).searchParams.get('token');
|
||||
expect((await t.client().post('/api/auth/verify-email', { token: verify })).status).toBe(200);
|
||||
expect((await t.client().post('/api/auth/verify-email', { token: verify })).body.code).toBe('TOKEN_INVALID');
|
||||
|
||||
await t.client().post('/api/auth/forgot-password', { email: 'ani@test.am' });
|
||||
const reset = new URL(t.mails[1]!.text).searchParams.get('token');
|
||||
await t.client().post('/api/auth/reset-password', { token: reset, password: 'new-password-1' });
|
||||
expect((await t.client().post('/api/auth/login', { email: 'ani@test.am', password: 'password-123' })).status).toBe(401);
|
||||
expect((await t.client().post('/api/auth/login', { email: 'ani@test.am', password: 'new-password-1' })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('Telegram Mini App: верная подпись создаёт пользователя, подделка отклоняется', async () => {
|
||||
const sign = (userId: number, token = TEST_BOT_TOKEN) => {
|
||||
const p = new URLSearchParams({
|
||||
auth_date: String(Math.floor(Date.now() / 1000)),
|
||||
user: JSON.stringify({ id: userId, first_name: 'Гор', language_code: 'hy' }),
|
||||
});
|
||||
const check = [...p.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join('\n');
|
||||
const secret = createHmac('sha256', 'WebAppData').update(token).digest();
|
||||
p.set('hash', createHmac('sha256', secret).update(check).digest('hex'));
|
||||
return p.toString();
|
||||
};
|
||||
const ok = await t.client().post('/api/auth/telegram/webapp', { initData: sign(777) });
|
||||
expect(ok.status).toBe(200);
|
||||
const me = await t.client(ok.body.token).get('/api/me');
|
||||
expect(me.body.user).toMatchObject({ name: 'Гор', lang: 'hy', telegramLinked: true });
|
||||
expect((await t.client().post('/api/auth/telegram/webapp', { initData: sign(778, 'other:token') })).status).toBe(401);
|
||||
|
||||
// Mini App открыл владелец Telegram, у которого уже есть аккаунт: входит он, а не тот, чья сессия осталась в браузере.
|
||||
const emailUser = await t.register();
|
||||
const own = await emailUser.post('/api/auth/telegram/webapp', { initData: sign(777) });
|
||||
expect((await t.client(own.body.token).get('/api/me')).body.user.id).toBe(me.body.user.id);
|
||||
// Новый Telegram привязывается к аккаунту с email.
|
||||
expect((await emailUser.post('/api/auth/telegram/webapp', { initData: sign(779) })).status).toBe(200);
|
||||
expect((await emailUser.get('/api/me')).body.user.telegramLinked).toBe(true);
|
||||
});
|
||||
|
||||
it('Telegram Login в веб-кабинете', async () => {
|
||||
const data: Record<string, string | number> = { id: 901, first_name: 'Лилит', auth_date: Math.floor(Date.now() / 1000) };
|
||||
const check = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n');
|
||||
const hash = createHmac('sha256', createHash('sha256').update(TEST_BOT_TOKEN).digest()).update(check).digest('hex');
|
||||
expect((await t.client().post('/api/auth/telegram', { ...data, hash })).status).toBe(200);
|
||||
expect((await t.client().post('/api/auth/telegram', { ...data, id: 902, hash })).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('компании и тарифы', () => {
|
||||
it('самостоятельная регистрация: компания создана, но доступ закрыт до оплаты', async () => {
|
||||
const u = await t.register();
|
||||
expect((await u.post('/api/company', { name: 'Соло', plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED');
|
||||
expect((await u.post('/api/company', { name: 'Соло', plan: 'solo', billingModel: 'fixed' })).status).toBe(200);
|
||||
const me = (await u.get('/api/me')).body;
|
||||
expect(me.user.role).toBe('manager');
|
||||
expect(me.company).toMatchObject({ plan: 'solo', access: 'disabled', modules: [], maxMembers: 1, members: 1 });
|
||||
// Solo работает в Telegram: из браузера рабочие разделы закрыты, из Mini App — открыты.
|
||||
expect(me.webBlocked).toBe(true);
|
||||
expect((await u.get('/api/leads')).body.code).toBe('WEB_NOT_AVAILABLE');
|
||||
expect((await u.get('/api/billing')).status).toBe(200);
|
||||
const mini = t.client(await createSession(t.db, me.user.id, true));
|
||||
expect((await mini.get('/api/me')).body.webBlocked).toBe(false);
|
||||
expect((await mini.get('/api/leads')).status).toBe(200);
|
||||
expect((await mini.post('/api/invites')).body.code).toBe('SUBSCRIPTION_REQUIRED');
|
||||
});
|
||||
|
||||
it('компания получает обе воронки с системными статусами', async () => {
|
||||
const { id } = await company();
|
||||
const rows = await t.db.select().from(pipelineStatuses).where(eq(pipelineStatuses.companyId, id));
|
||||
const codes = (p: string) => rows.filter((r) => r.pipeline === p && r.code).map((r) => r.code);
|
||||
expect(rows.filter((r) => r.pipeline === 'object')).toHaveLength(8);
|
||||
expect(rows.filter((r) => r.pipeline === 'client')).toHaveLength(7);
|
||||
expect(codes('object')).toEqual(['new', 'taken', 'deal', 'rejected']);
|
||||
expect(codes('client')).toEqual(['new', 'deal', 'rejected']);
|
||||
});
|
||||
|
||||
it('в Team нельзя добавить одиннадцатого участника, даже одновременно', async () => {
|
||||
const { boss } = await company('team');
|
||||
const invite = (await boss.post('/api/invites')).body.token as string;
|
||||
expect((await t.client().get(`/api/invites/${invite}`)).body.companyName).toBe('Агентство team');
|
||||
const agents = await Promise.all(Array.from({ length: 11 }, () => t.register('Агент')));
|
||||
const results = await Promise.all(agents.map((a) => a.post(`/api/invites/${invite}/accept`)));
|
||||
expect(results.filter((r) => r.status === 200)).toHaveLength(9);
|
||||
expect(results.filter((r) => r.body.code === 'MEMBER_LIMIT_REACHED')).toHaveLength(2);
|
||||
expect(results.filter((r) => r.body.limitReached)).toHaveLength(1);
|
||||
expect((await boss.get('/api/me')).body.company.members).toBe(10);
|
||||
});
|
||||
|
||||
it('ссылка руководителя одноразовая, отозванное приглашение не работает', async () => {
|
||||
const { boss, managerToken } = await company();
|
||||
expect((await (await t.register()).post(`/api/invites/${managerToken}/accept`)).body.code).toBe('INVITE_NOT_FOUND');
|
||||
const invite = (await boss.post('/api/invites')).body.token as string;
|
||||
await boss.del(`/api/invites/${invite}`);
|
||||
expect((await (await t.register()).post(`/api/invites/${invite}/accept`)).body.code).toBe('INVITE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('агент не управляет компанией и не видит чужую; удалённый агент теряет доступ', async () => {
|
||||
const a = await company();
|
||||
const b = await company();
|
||||
const invite = (await a.boss.post('/api/invites')).body.token as string;
|
||||
const agent = await t.register('Агент');
|
||||
await agent.post(`/api/invites/${invite}/accept`);
|
||||
expect((await agent.post('/api/invites')).status).toBe(403);
|
||||
expect((await agent.patch('/api/company', { agentsSeeAll: true })).status).toBe(403);
|
||||
expect((await agent.get('/api/admin/companies')).status).toBe(403);
|
||||
|
||||
const members = (await a.boss.get('/api/members')).body as { id: number; role: string }[];
|
||||
const agentId = members.find((m) => m.role === 'agent')!.id;
|
||||
expect((await b.boss.del(`/api/members/${agentId}`)).status).toBe(404);
|
||||
expect((await a.boss.del(`/api/members/${agentId}`)).status).toBe(200);
|
||||
expect((await agent.get('/api/me')).status).toBe(401);
|
||||
expect((await a.boss.get('/api/members')).body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('просрочка: 15 дней сервис работает, на 16-й — только чтение; суперадмин возвращает доступ', async () => {
|
||||
const { sa, boss, id } = await company();
|
||||
const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000);
|
||||
await t.db.update(companies).set({ subscriptionEndsAt: daysAgo(14) }).where(eq(companies.id, id));
|
||||
expect((await boss.get('/api/me')).body.company.access).toBe('overdue');
|
||||
expect((await boss.patch('/api/company', { agentsSeeAll: true })).status).toBe(200);
|
||||
|
||||
await t.db.update(companies).set({ subscriptionEndsAt: daysAgo(16) }).where(eq(companies.id, id));
|
||||
expect((await boss.get('/api/me')).body.company.access).toBe('restricted');
|
||||
expect((await boss.patch('/api/company', { agentsSeeAll: false })).body.code).toBe('SUBSCRIPTION_REQUIRED');
|
||||
expect((await boss.get('/api/members')).status).toBe(200);
|
||||
|
||||
await sa.patch(`/api/admin/companies/${id}`, { subscriptionEndsAt: new Date(Date.now() + 86_400_000).toISOString() });
|
||||
expect((await boss.get('/api/me')).body.company.access).toBe('active');
|
||||
expect((await boss.get('/api/me')).body.company.settings.agentsSeeAll).toBe(true);
|
||||
});
|
||||
|
||||
it('суперадмин: список компаний, вход в кабинет для поддержки, запрет «%» на Solo', async () => {
|
||||
const { sa, id } = await company('agency');
|
||||
const list = (await sa.get('/api/admin/companies')).body as { id: number; members: number; maxMembers: number }[];
|
||||
expect(list.find((c) => c.id === id)).toMatchObject({ members: 1, maxMembers: 35, access: 'active' });
|
||||
expect((await sa.patch(`/api/admin/companies/${id}`, { plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED');
|
||||
|
||||
expect((await sa.get('/api/members')).body.code).toBe('NO_COMPANY');
|
||||
await sa.post('/api/admin/impersonate', { companyId: id });
|
||||
const me = (await sa.get('/api/me')).body;
|
||||
expect(me).toMatchObject({ impersonating: true, company: { id }, user: { role: 'manager', isSuperadmin: true } });
|
||||
expect((await sa.get('/api/members')).body).toHaveLength(1);
|
||||
await sa.post('/api/admin/impersonate', { companyId: null });
|
||||
expect((await sa.get('/api/me')).body.company).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,871 @@
|
||||
import cookie from '@fastify/cookie';
|
||||
import {
|
||||
accessState,
|
||||
BILLING_MODELS,
|
||||
canWrite,
|
||||
CommissionSettings,
|
||||
hasModule,
|
||||
LANGS,
|
||||
PLAN_SPECS,
|
||||
PLANS,
|
||||
type AccessState,
|
||||
type AgentPay,
|
||||
type BillingModel,
|
||||
type Lang,
|
||||
type Plan,
|
||||
type Role,
|
||||
} from '@lr/shared';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hashPassword, verifyPassword } from './auth/password.ts';
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
findSession,
|
||||
hashToken,
|
||||
newToken,
|
||||
SESSION_COOKIE,
|
||||
SESSION_DAYS,
|
||||
} from './auth/sessions.ts';
|
||||
import { verifyLoginWidget, verifyWebAppInitData, type TelegramUser } from './auth/telegram.ts';
|
||||
import {
|
||||
acceptInvite,
|
||||
createCompany,
|
||||
createInvite,
|
||||
memberCount,
|
||||
} from './companies/companies.service.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
import {
|
||||
clients,
|
||||
botLangs,
|
||||
companies,
|
||||
departments,
|
||||
emailTokens,
|
||||
invites,
|
||||
leads,
|
||||
listings,
|
||||
parserRuns,
|
||||
pipelineStatuses,
|
||||
sessions,
|
||||
users,
|
||||
} from './db/schema.ts';
|
||||
import type { Config } from './lib/config.ts';
|
||||
import { AppError, badRequest, conflict, forbidden, notFound } from './lib/errors.ts';
|
||||
import { onlySent } from './lib/patch.ts';
|
||||
import { consoleMailer, type Mailer } from './lib/mailer.ts';
|
||||
import { registerAdminListingRoutes } from './admin-listings.ts';
|
||||
import { registerAnalyticsRoutes } from './analytics/routes.ts';
|
||||
import type { PaymentProvider } from './billing/provider.ts';
|
||||
import { registerBillingRoutes } from './billing/routes.ts';
|
||||
import { applyPlan, issueInitialInvoices, issueSetupIfNeeded } from './billing/service.ts';
|
||||
import { registerArchiveRoutes } from './archive/routes.ts';
|
||||
import { registerClientRoutes } from './clients/routes.ts';
|
||||
import { registerDepartmentRoutes } from './departments/routes.ts';
|
||||
import type { GroupSender } from './group/service.ts';
|
||||
import { registerLeadRoutes } from './leads/routes.ts';
|
||||
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
|
||||
import { loadParserSettings, ParserSettings, saveParserSettings } from './parser/settings.ts';
|
||||
import { registerPipelineRoutes } from './pipelines/routes.ts';
|
||||
import { runIngest, type PagedSource } from './ingest/service.ts';
|
||||
import type { ImageFetcher } from './showcase/media.ts';
|
||||
import { registerPayrollRoutes } from './payroll.ts';
|
||||
import { registerShowcaseRoutes } from './showcase/routes.ts';
|
||||
import { registerBarevRoutes } from './barev/routes.ts';
|
||||
import { validSlug } from './showcase/service.ts';
|
||||
import { registerSupportRoutes } from './support/routes.ts';
|
||||
import { telegramFileSender, type FileSender } from './support/service.ts';
|
||||
import { contactOf, enqueueForSuperadmins } from './telegram/outbox.ts';
|
||||
import { registerBotStartRoutes } from './telegram/start.ts';
|
||||
import { enqueueText } from './telegram/outbox.ts';
|
||||
|
||||
type User = typeof users.$inferSelect;
|
||||
type Company = typeof companies.$inferSelect;
|
||||
|
||||
export interface Ctx {
|
||||
user: User;
|
||||
/** Роль в текущем кабинете: суперадмин, вошедший в компанию для поддержки, действует как руководитель. */
|
||||
role: Role;
|
||||
company: Company | null;
|
||||
access: AccessState | null;
|
||||
/** Суперадмин работает в кабинете компании. */
|
||||
impersonating: boolean;
|
||||
/** Тариф без веб-кабинета, а сессия открыта не из Telegram Mini App: рабочие разделы закрыты. */
|
||||
webBlocked: boolean;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
ctx: Ctx | null;
|
||||
sessionToken: string | null;
|
||||
/** Компания, за которой закреплён домен запроса; на основном домене — null. */
|
||||
domain: Company | null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppDeps {
|
||||
db: Db;
|
||||
config: Config;
|
||||
mailer?: Mailer;
|
||||
/** Источник объявлений — для ручного запуска парсера суперадмином. */
|
||||
source?: PagedSource | null;
|
||||
/** Платёжный провайдер. Пока не выбран — онлайн-оплата выключена, счета отмечает суперадмин. */
|
||||
payments?: PaymentProvider | null;
|
||||
/** Отправка файлов поддержки в Telegram; в тестах подменяется заглушкой. */
|
||||
sendFile?: FileSender;
|
||||
/** Скачивание фото объявлений для страницы клиента; в тестах подменяется. */
|
||||
fetchImage?: ImageFetcher;
|
||||
/** Отправка в группу компании в Telegram; в тестах подменяется. */
|
||||
groupSender?: GroupSender;
|
||||
}
|
||||
|
||||
const Email = z.string().trim().toLowerCase().pipe(z.email());
|
||||
const Password = z.string().min(8).max(200);
|
||||
const Name = z.string().trim().min(1).max(100);
|
||||
const Lang_ = z.enum(LANGS);
|
||||
|
||||
const langOf = (code: string | undefined): Lang =>
|
||||
(LANGS as readonly string[]).includes(code ?? '') ? (code as Lang) : code === 'am' ? 'hy' : 'ru';
|
||||
|
||||
export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender }: AppDeps) {
|
||||
const app = Fastify({ logger: false });
|
||||
void app.register(cookie);
|
||||
// Файлы (вложения поддержки, фото клиентов) приходят телом запроса как есть; предел размера задаёт каждый маршрут.
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer', bodyLimit: 20 * 1024 * 1024 }, (_req, body, done) => done(null, body));
|
||||
|
||||
app.setErrorHandler((error, _req, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.status).send({ code: error.code });
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.status(400).send({ code: 'VALIDATION', issues: error.issues });
|
||||
}
|
||||
const status = (error as { statusCode?: number }).statusCode;
|
||||
if (status && status < 500) return reply.status(status).send({ code: 'BAD_REQUEST' });
|
||||
console.error(error);
|
||||
return reply.status(500).send({ code: 'INTERNAL' });
|
||||
});
|
||||
|
||||
// --- контекст запроса: пользователь, компания, состояние подписки ---
|
||||
|
||||
// Свой домен компании (settings.clientHost, например arev.yandp.tech): ссылки её клиентам и вход
|
||||
// только для её сотрудников и суперадмина. Сессия чужого пользователя на таком домене не действует.
|
||||
const mainHost = new URL(config.appUrl).hostname;
|
||||
const domainOf = async (req: FastifyRequest) => {
|
||||
const host = req.hostname.toLowerCase();
|
||||
if (host === mainHost) return null;
|
||||
const [company] = await db.select().from(companies).where(sql`${companies.settings}->>'clientHost' = ${host}`);
|
||||
return company ?? null;
|
||||
};
|
||||
const allowedOn = (domain: Company | null, user: User) =>
|
||||
!domain || user.role === 'superadmin' || user.companyId === domain.id;
|
||||
const checkDomain = (req: FastifyRequest, user: User) => {
|
||||
if (!allowedOn(req.domain, user)) throw forbidden('WRONG_DOMAIN');
|
||||
};
|
||||
|
||||
app.decorateRequest('ctx', null);
|
||||
app.decorateRequest('sessionToken', null);
|
||||
app.decorateRequest('domain', null);
|
||||
app.addHook('preHandler', async (req) => {
|
||||
req.domain = await domainOf(req);
|
||||
const bearer = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1];
|
||||
const token = bearer ?? req.cookies[SESSION_COOKIE];
|
||||
if (!token) return;
|
||||
const session = await findSession(db, token);
|
||||
if (!session || session.user.removedAt || !allowedOn(req.domain, session.user)) return;
|
||||
const { user } = session;
|
||||
const impersonating = user.role === 'superadmin' && session.actingCompanyId !== null;
|
||||
const companyId = impersonating ? session.actingCompanyId : user.companyId;
|
||||
const [company] = companyId
|
||||
? await db.select().from(companies).where(eq(companies.id, companyId))
|
||||
: [];
|
||||
req.sessionToken = token;
|
||||
req.ctx = {
|
||||
user,
|
||||
role: impersonating ? 'manager' : (user.role as Role),
|
||||
company: company ?? null,
|
||||
access: company ? accessState(company) : null,
|
||||
impersonating,
|
||||
webBlocked: Boolean(company) && !impersonating && !session.viaTelegram && !hasModule(company!.plan as Plan, 'web'),
|
||||
};
|
||||
});
|
||||
|
||||
const auth = (req: FastifyRequest): Ctx => {
|
||||
if (!req.ctx) throw new AppError(401, 'UNAUTHORIZED');
|
||||
return req.ctx;
|
||||
};
|
||||
/** Пользователь внутри компании. Все бизнес-запросы идут через эту проверку. */
|
||||
const account = (req: FastifyRequest) => {
|
||||
const ctx = auth(req);
|
||||
if (!ctx.company) throw forbidden('NO_COMPANY');
|
||||
return ctx as Ctx & { company: Company; access: AccessState };
|
||||
};
|
||||
const member = (req: FastifyRequest) => {
|
||||
const ctx = account(req);
|
||||
if (ctx.webBlocked) throw forbidden('WEB_NOT_AVAILABLE');
|
||||
return ctx;
|
||||
};
|
||||
/** Руководитель в разделе «Подписка»: доступен с любого интерфейса и при ограниченном доступе. */
|
||||
const owner = (req: FastifyRequest) => {
|
||||
const ctx = account(req);
|
||||
if (ctx.role !== 'manager') throw forbidden();
|
||||
return ctx;
|
||||
};
|
||||
const manager = (req: FastifyRequest) => {
|
||||
const ctx = member(req);
|
||||
if (ctx.role !== 'manager') throw forbidden();
|
||||
return ctx;
|
||||
};
|
||||
/** Изменения запрещены, когда отсрочка истекла или доступ выключен (CRM только на чтение). */
|
||||
const writable = <T extends { access: AccessState }>(ctx: T): T => {
|
||||
if (!canWrite(ctx.access)) throw forbidden('SUBSCRIPTION_REQUIRED');
|
||||
return ctx;
|
||||
};
|
||||
const superadmin = (req: FastifyRequest) => {
|
||||
const ctx = auth(req);
|
||||
if (ctx.user.role !== 'superadmin') throw forbidden();
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const startSession = async (reply: FastifyReply, userId: number, viaTelegram = false) => {
|
||||
const token = await createSession(db, userId, viaTelegram);
|
||||
reply.setCookie(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: config.production,
|
||||
maxAge: SESSION_DAYS * 86_400,
|
||||
});
|
||||
return token;
|
||||
};
|
||||
|
||||
const sendEmailToken = async (user: User, kind: 'verify' | 'reset') => {
|
||||
if (!user.email) return;
|
||||
const token = newToken();
|
||||
await db.insert(emailTokens).values({
|
||||
tokenHash: hashToken(token),
|
||||
userId: user.id,
|
||||
kind,
|
||||
expiresAt: new Date(Date.now() + (kind === 'verify' ? 72 : 2) * 3_600_000),
|
||||
});
|
||||
const link = `${config.appUrl}/${kind === 'verify' ? 'verify-email' : 'reset-password'}?token=${token}`;
|
||||
await mailer.send({
|
||||
to: user.email,
|
||||
subject: kind === 'verify' ? 'Y&P Lead Radar: подтвердите email' : 'Y&P Lead Radar: сброс пароля',
|
||||
text: link,
|
||||
});
|
||||
};
|
||||
|
||||
const takeEmailToken = async (token: string, kind: 'verify' | 'reset') => {
|
||||
const [row] = await db
|
||||
.delete(emailTokens)
|
||||
.where(and(eq(emailTokens.tokenHash, hashToken(token)), eq(emailTokens.kind, kind)))
|
||||
.returning();
|
||||
if (!row || row.expiresAt < new Date()) throw badRequest('TOKEN_INVALID');
|
||||
return row.userId;
|
||||
};
|
||||
|
||||
const me = async (ctx: Ctx) => {
|
||||
const { user, company } = ctx;
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: Boolean(user.emailVerifiedAt),
|
||||
telegramLinked: Boolean(user.telegramId),
|
||||
hasPassword: Boolean(user.passwordHash),
|
||||
lang: user.lang,
|
||||
role: ctx.role,
|
||||
isSuperadmin: user.role === 'superadmin',
|
||||
quietFrom: user.quietFrom,
|
||||
quietTo: user.quietTo,
|
||||
notificationsPaused: user.notificationsPaused,
|
||||
onboarded: Boolean(user.onboardedAt),
|
||||
/** Отдел сотрудника: он видит только объекты и клиентов этого отдела. */
|
||||
departmentId: user.departmentId,
|
||||
departmentName: user.departmentId
|
||||
? ((await db.select({ name: departments.name }).from(departments).where(eq(departments.id, user.departmentId)))[0]?.name ?? null)
|
||||
: null,
|
||||
},
|
||||
impersonating: ctx.impersonating,
|
||||
webBlocked: ctx.webBlocked,
|
||||
company: company && {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
slug: company.slug,
|
||||
plan: company.plan,
|
||||
billingModel: company.billingModel,
|
||||
planned:
|
||||
company.plannedFrom && company.plannedPlan
|
||||
? {
|
||||
plan: company.plannedPlan,
|
||||
billingModel: company.plannedBillingModel,
|
||||
from: company.plannedFrom,
|
||||
}
|
||||
: null,
|
||||
subscriptionEndsAt: company.subscriptionEndsAt,
|
||||
graceDays: company.graceDays,
|
||||
access: ctx.access,
|
||||
modules: PLAN_SPECS[company.plan as Plan].modules,
|
||||
maxMembers: PLAN_SPECS[company.plan as Plan].maxMembers,
|
||||
members: await memberCount(db, company.id),
|
||||
// Токен бота группы и ключ сайта BarevClub — секреты: в кабинет они не уходят.
|
||||
settings: { ...company.settings, groupBot: undefined, barevClub: undefined },
|
||||
/** У компании настроен бот группы: в карточке объекта работает «Отправить в Telegram». */
|
||||
groupBot: Boolean(company.settings.groupBot?.chatId),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// --- вход и регистрация ---
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
await db.execute(sql`select 1`);
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
app.get('/api/config', async (req) => ({
|
||||
telegramBotUsername: config.telegramBotToken ? (config.telegramBotUsername ?? null) : null,
|
||||
onlinePayments: Boolean(payments),
|
||||
/** На своём домене компании: её название и основной адрес сервиса для остальных. */
|
||||
domain: req.domain && { company: req.domain.name, appUrl: config.appUrl },
|
||||
}));
|
||||
|
||||
app.post('/api/auth/register', async (req, reply) => {
|
||||
const body = z
|
||||
.object({ name: Name, email: Email, password: Password, lang: Lang_.default('ru') })
|
||||
.parse(req.body);
|
||||
// На своём домене компании регистрации нет: сотрудников туда приглашают с основного адреса.
|
||||
if (req.domain) throw forbidden('WRONG_DOMAIN');
|
||||
const [existing] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(sql`lower(${users.email}) = ${body.email}`);
|
||||
if (existing) throw conflict('EMAIL_TAKEN');
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
passwordHash: await hashPassword(body.password),
|
||||
lang: body.lang,
|
||||
// Роль уточняется при создании компании (manager) или принятии приглашения (agent).
|
||||
role: 'agent',
|
||||
})
|
||||
.returning();
|
||||
await sendEmailToken(user!, 'verify');
|
||||
await enqueueForSuperadmins(db, 'signupNew', [user!.name, contactOf(user!)]);
|
||||
const token = await startSession(reply, user!.id);
|
||||
return { token };
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const body = z.object({ email: Email, password: z.string().max(200) }).parse(req.body);
|
||||
const [user] = await db.select().from(users).where(sql`lower(${users.email}) = ${body.email}`);
|
||||
// Пароль проверяется и для несуществующего пользователя — чтобы ответ не выдавал, есть ли такой email.
|
||||
const ok = await verifyPassword(body.password, user?.passwordHash ?? null);
|
||||
if (!user || !ok || user.removedAt) throw new AppError(401, 'BAD_CREDENTIALS');
|
||||
checkDomain(req, user);
|
||||
const token = await startSession(reply, user.id);
|
||||
return { token };
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
if (req.sessionToken) await deleteSession(db, req.sessionToken);
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/auth/verify-email', async (req) => {
|
||||
const { token } = z.object({ token: z.string().min(1) }).parse(req.body);
|
||||
const userId = await takeEmailToken(token, 'verify');
|
||||
await db.update(users).set({ emailVerifiedAt: new Date() }).where(eq(users.id, userId));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/auth/forgot-password', async (req) => {
|
||||
const { email } = z.object({ email: Email }).parse(req.body);
|
||||
const [user] = await db.select().from(users).where(sql`lower(${users.email}) = ${email}`);
|
||||
if (user && !user.removedAt) await sendEmailToken(user, 'reset');
|
||||
// Ответ одинаковый, есть такой email или нет.
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset-password', async (req) => {
|
||||
const body = z.object({ token: z.string().min(1), password: Password }).parse(req.body);
|
||||
const userId = await takeEmailToken(body.token, 'reset');
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: await hashPassword(body.password), emailVerifiedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
// Старые сессии закрываются: пароль мог сбросить не тот, кто сейчас вошёл.
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Вход через Telegram. Если запрос пришёл от уже вошедшего пользователя,
|
||||
* Telegram привязывается к его аккаунту; иначе — вход или регистрация.
|
||||
*/
|
||||
const telegramAuth = async (req: FastifyRequest, reply: FastifyReply, tg: TelegramUser | null, viaTelegram = false) => {
|
||||
if (!tg) throw new AppError(401, 'TELEGRAM_AUTH_FAILED');
|
||||
const [existing] = await db.select().from(users).where(eq(users.telegramId, tg.id));
|
||||
// Mini App открыл владелец этого Telegram: его аккаунт важнее сессии, оставшейся в браузере Telegram.
|
||||
if (existing && !existing.removedAt) checkDomain(req, existing);
|
||||
else if (!existing && req.domain && !req.ctx) throw forbidden('WRONG_DOMAIN');
|
||||
if (viaTelegram && existing && !existing.removedAt) {
|
||||
return { token: await startSession(reply, existing.id, true) };
|
||||
}
|
||||
if (req.ctx) {
|
||||
if (existing && existing.id !== req.ctx.user.id) throw conflict('TELEGRAM_TAKEN');
|
||||
await db
|
||||
.update(users)
|
||||
.set({ telegramId: tg.id, telegramUsername: tg.username ?? null })
|
||||
.where(eq(users.id, req.ctx.user.id));
|
||||
if (viaTelegram) {
|
||||
await db.update(sessions).set({ viaTelegram: true }).where(eq(sessions.tokenHash, hashToken(req.sessionToken!)));
|
||||
}
|
||||
return { token: req.sessionToken };
|
||||
}
|
||||
if (existing?.removedAt) throw new AppError(401, 'TELEGRAM_AUTH_FAILED');
|
||||
const fresh = !existing;
|
||||
// Язык, выбранный в боте до регистрации, важнее языка интерфейса Telegram.
|
||||
const [chosen] = fresh ? await db.select().from(botLangs).where(eq(botLangs.telegramId, tg.id)) : [];
|
||||
const user =
|
||||
existing ??
|
||||
(
|
||||
await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: [tg.firstName, tg.lastName].filter(Boolean).join(' ') || tg.username || 'Telegram',
|
||||
telegramId: tg.id,
|
||||
telegramUsername: tg.username ?? null,
|
||||
lang: (chosen?.lang as Lang | undefined) ?? langOf(tg.languageCode),
|
||||
role: 'agent',
|
||||
})
|
||||
.returning()
|
||||
)[0]!;
|
||||
if (fresh) await enqueueForSuperadmins(db, 'signupNew', [user.name, contactOf(user)]);
|
||||
const token = await startSession(reply, user.id, viaTelegram);
|
||||
return { token };
|
||||
};
|
||||
|
||||
app.post('/api/auth/telegram', async (req, reply) => {
|
||||
if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED');
|
||||
const data = z.record(z.string(), z.union([z.string(), z.number()])).parse(req.body);
|
||||
return telegramAuth(req, reply, verifyLoginWidget(data, config.telegramBotToken));
|
||||
});
|
||||
|
||||
app.post('/api/auth/telegram/webapp', async (req, reply) => {
|
||||
if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED');
|
||||
const { initData } = z.object({ initData: z.string().min(1) }).parse(req.body);
|
||||
return telegramAuth(req, reply, verifyWebAppInitData(initData, config.telegramBotToken), true);
|
||||
});
|
||||
|
||||
// --- профиль ---
|
||||
|
||||
app.get('/api/me', async (req) => me(auth(req)));
|
||||
|
||||
app.patch('/api/me', async (req) => {
|
||||
const ctx = auth(req);
|
||||
const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).nullable();
|
||||
const body = z
|
||||
.object({
|
||||
name: Name,
|
||||
lang: Lang_,
|
||||
quietFrom: time,
|
||||
quietTo: time,
|
||||
notificationsPaused: z.boolean(),
|
||||
/** Мастер первой настройки пройден или пропущен. */
|
||||
onboarded: z.literal(true),
|
||||
})
|
||||
.partial()
|
||||
.parse(req.body);
|
||||
const { onboarded, ...fields } = body;
|
||||
const patch = { ...fields, ...(onboarded ? { onboardedAt: new Date() } : {}) };
|
||||
if (Object.keys(patch).length) await db.update(users).set(patch).where(eq(users.id, ctx.user.id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- компания ---
|
||||
|
||||
/** Самостоятельное подключение: доступ откроется после оплаты или включения суперадмином. */
|
||||
app.post('/api/company', async (req) => {
|
||||
const ctx = auth(req);
|
||||
if (ctx.company || ctx.user.role === 'superadmin') throw conflict('ALREADY_IN_COMPANY');
|
||||
const body = z
|
||||
.object({ name: Name, plan: z.enum(PLANS), billingModel: z.enum(BILLING_MODELS) })
|
||||
.parse(req.body);
|
||||
const company = await db.transaction(async (tx) => {
|
||||
const created = await createCompany(tx, { ...body, lang: ctx.user.lang as Lang });
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ companyId: created.id, role: 'manager', removedAt: null })
|
||||
.where(eq(users.id, ctx.user.id));
|
||||
await issueInitialInvoices(tx, created);
|
||||
await enqueueForSuperadmins(tx, 'signupCompany', [created.name, created.plan, ctx.user.name, contactOf(ctx.user)]);
|
||||
return created;
|
||||
});
|
||||
return { id: company.id };
|
||||
});
|
||||
|
||||
app.patch('/api/company', async (req) => {
|
||||
const ctx = writable(manager(req));
|
||||
const body = z
|
||||
.object({
|
||||
name: Name,
|
||||
/** Имя компании в адресе страниц для клиентов. */
|
||||
slug: z.string().trim().toLowerCase(),
|
||||
agentsSeeAll: z.boolean(),
|
||||
autoRejectRemoved: z.boolean(),
|
||||
poolReturnHours: z.number().int().min(1).max(720),
|
||||
useAgencyBadge: z.boolean(),
|
||||
phoneThreshold: z.number().int().min(1).max(100),
|
||||
/** Комиссия агентства по умолчанию. */
|
||||
commission: CommissionSettings,
|
||||
/** Отчёты руководителю в Telegram. */
|
||||
reportWeekly: z.boolean(),
|
||||
reportMonthly: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
.parse(req.body);
|
||||
const s = ctx.company.settings;
|
||||
if (body.slug !== undefined && body.slug !== ctx.company.slug) {
|
||||
if (!validSlug(body.slug)) throw badRequest('BAD_SLUG');
|
||||
const [taken] = await db.select({ id: companies.id }).from(companies).where(eq(companies.slug, body.slug));
|
||||
if (taken) throw conflict('SLUG_TAKEN');
|
||||
}
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
name: body.name ?? ctx.company.name,
|
||||
slug: body.slug ?? ctx.company.slug,
|
||||
settings: {
|
||||
agentsSeeAll: body.agentsSeeAll ?? s.agentsSeeAll,
|
||||
autoRejectRemoved: body.autoRejectRemoved ?? s.autoRejectRemoved ?? false,
|
||||
poolReturnHours: body.poolReturnHours ?? s.poolReturnHours,
|
||||
agentRules: {
|
||||
useAgencyBadge: body.useAgencyBadge ?? s.agentRules.useAgencyBadge,
|
||||
phoneThreshold: body.phoneThreshold ?? s.agentRules.phoneThreshold,
|
||||
},
|
||||
commission: body.commission ?? s.commission,
|
||||
reports: { ...s.reports, weekly: body.reportWeekly ?? s.reports?.weekly ?? false, monthly: body.reportMonthly ?? s.reports?.monthly ?? false },
|
||||
clientHost: s.clientHost,
|
||||
groupBot: s.groupBot,
|
||||
barevClub: s.barevClub,
|
||||
},
|
||||
})
|
||||
.where(eq(companies.id, ctx.company.id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- участники и приглашения ---
|
||||
|
||||
app.get('/api/members', async (req) => {
|
||||
const ctx = member(req);
|
||||
return db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
email: users.email,
|
||||
telegramUsername: users.telegramUsername,
|
||||
telegramLinked: sql<boolean>`${users.telegramId} is not null`,
|
||||
departmentId: users.departmentId,
|
||||
// Условия оплаты видит руководитель; агент — только свои.
|
||||
pay: ctx.role === 'manager' ? users.pay : sql<AgentPay | null>`case when ${users.id} = ${ctx.user.id} then ${users.pay} end`,
|
||||
})
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, ctx.company.id), isNull(users.removedAt)))
|
||||
.orderBy(users.id);
|
||||
});
|
||||
|
||||
/** Удаление агента: его лиды уходят выбранному агенту либо возвращаются в пул. История сохраняется. */
|
||||
app.delete('/api/members/:id', async (req) => {
|
||||
const ctx = writable(manager(req));
|
||||
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
|
||||
const { reassignTo } = z
|
||||
.object({ reassignTo: z.coerce.number().int().optional() })
|
||||
.parse(req.query);
|
||||
if (id === ctx.user.id) throw badRequest('CANNOT_REMOVE_SELF');
|
||||
await db.transaction(async (tx) => {
|
||||
const inCompany = and(eq(users.companyId, ctx.company.id), isNull(users.removedAt));
|
||||
const [target] = await tx.select().from(users).where(and(eq(users.id, id), inCompany));
|
||||
if (!target || target.role !== 'agent') throw notFound();
|
||||
if (reassignTo !== undefined) {
|
||||
const [heir] = await tx.select().from(users).where(and(eq(users.id, reassignTo), inCompany));
|
||||
if (!heir || heir.id === id) throw badRequest('BAD_REASSIGN_TARGET');
|
||||
}
|
||||
// Без наследника лиды возвращаются в пул в статусе «Новый».
|
||||
const [fresh] = await tx
|
||||
.select({ id: pipelineStatuses.id })
|
||||
.from(pipelineStatuses)
|
||||
.where(
|
||||
and(
|
||||
eq(pipelineStatuses.companyId, ctx.company.id),
|
||||
eq(pipelineStatuses.pipeline, 'object'),
|
||||
eq(pipelineStatuses.code, 'new'),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(leads)
|
||||
.set(
|
||||
reassignTo !== undefined
|
||||
? { assigneeId: reassignTo }
|
||||
: { assigneeId: null, statusId: fresh!.id, statusChangedAt: new Date(), takenAt: null },
|
||||
)
|
||||
.where(and(eq(leads.companyId, ctx.company.id), eq(leads.assigneeId, id)));
|
||||
// Клиенты всегда закреплены за агентом: без наследника их забирает руководитель.
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ assigneeId: reassignTo ?? ctx.user.id })
|
||||
.where(and(eq(clients.companyId, ctx.company.id), eq(clients.assigneeId, id)));
|
||||
await tx.update(users).set({ removedAt: new Date() }).where(eq(users.id, id));
|
||||
await tx.delete(sessions).where(eq(sessions.userId, id));
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/invites', async (req) => {
|
||||
const ctx = manager(req);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(and(eq(invites.companyId, ctx.company.id), isNull(invites.revokedAt), eq(invites.role, 'agent')))
|
||||
.orderBy(desc(invites.createdAt));
|
||||
return rows.map((i) => ({
|
||||
token: i.token,
|
||||
url: `${config.appUrl}/invite/${i.token}`,
|
||||
createdAt: i.createdAt,
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/api/invites', async (req) => {
|
||||
const ctx = writable(manager(req));
|
||||
const invite = await createInvite(db, ctx.company.id, ctx.user.id);
|
||||
return { token: invite.token, url: `${config.appUrl}/invite/${invite.token}` };
|
||||
});
|
||||
|
||||
app.delete('/api/invites/:token', async (req) => {
|
||||
const ctx = manager(req);
|
||||
const { token } = z.object({ token: z.string() }).parse(req.params);
|
||||
await db
|
||||
.update(invites)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(invites.token, token), eq(invites.companyId, ctx.company.id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Что за приглашение — показывается до входа. */
|
||||
app.get('/api/invites/:token', async (req) => {
|
||||
const { token } = z.object({ token: z.string() }).parse(req.params);
|
||||
const [row] = await db
|
||||
.select({ name: companies.name, revokedAt: invites.revokedAt })
|
||||
.from(invites)
|
||||
.innerJoin(companies, eq(companies.id, invites.companyId))
|
||||
.where(eq(invites.token, token));
|
||||
if (!row || row.revokedAt) throw notFound('INVITE_NOT_FOUND');
|
||||
return { companyName: row.name };
|
||||
});
|
||||
|
||||
app.post('/api/invites/:token/accept', async (req) => {
|
||||
const ctx = auth(req);
|
||||
const { token } = z.object({ token: z.string() }).parse(req.params);
|
||||
const result = await acceptInvite(db, token, ctx.user.id);
|
||||
// Руководителям в бот: агент присоединился; достигнут лимит участников.
|
||||
const managers = (
|
||||
await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, result.companyId), eq(users.role, 'manager'), isNull(users.removedAt)))
|
||||
)
|
||||
.map((m) => m.id)
|
||||
.filter((id) => id !== ctx.user.id);
|
||||
await enqueueText(db, managers, 'joined', [ctx.user.name]);
|
||||
if (result.limitReached) await enqueueText(db, managers, 'limitReached');
|
||||
return result;
|
||||
});
|
||||
|
||||
// --- суперадмин ---
|
||||
|
||||
app.get('/api/admin/companies', async (req) => {
|
||||
superadmin(req);
|
||||
const rows = await db
|
||||
.select({
|
||||
company: companies,
|
||||
members: sql<number>`(select count(*)::int from users u where u.company_id = "companies"."id" and u.removed_at is null)`,
|
||||
})
|
||||
.from(companies)
|
||||
.orderBy(desc(companies.id));
|
||||
return rows.map(({ company: c, members }) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
plan: c.plan,
|
||||
billingModel: c.billingModel,
|
||||
members,
|
||||
maxMembers: PLAN_SPECS[c.plan as Plan].maxMembers,
|
||||
access: accessState(c),
|
||||
accessEnabled: c.accessEnabled,
|
||||
subscriptionEndsAt: c.subscriptionEndsAt,
|
||||
graceDays: c.graceDays,
|
||||
lastActivityAt: c.lastActivityAt,
|
||||
createdAt: c.createdAt,
|
||||
}));
|
||||
});
|
||||
|
||||
/** Ручное подключение: компания создаётся включённой, руководитель входит по ссылке-приглашению. */
|
||||
app.post('/api/admin/companies', async (req) => {
|
||||
const ctx = superadmin(req);
|
||||
const body = z
|
||||
.object({
|
||||
name: Name,
|
||||
plan: z.enum(PLANS),
|
||||
billingModel: z.enum(BILLING_MODELS),
|
||||
subscriptionEndsAt: z.coerce.date(),
|
||||
lang: Lang_.default('ru'),
|
||||
})
|
||||
.parse(req.body);
|
||||
return db.transaction(async (tx) => {
|
||||
const company = await createCompany(tx, { ...body, accessEnabled: true });
|
||||
await issueSetupIfNeeded(tx, company, false);
|
||||
const [invite] = await tx
|
||||
.insert(invites)
|
||||
.values({ token: newToken(), role: 'manager', companyId: company.id, createdById: ctx.user.id })
|
||||
.returning();
|
||||
return { id: company.id, managerUrl: `${config.appUrl}/invite/${invite!.token}` };
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/api/admin/companies/:id', async (req) => {
|
||||
superadmin(req);
|
||||
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
|
||||
const body = z
|
||||
.object({
|
||||
plan: z.enum(PLANS),
|
||||
billingModel: z.enum(BILLING_MODELS),
|
||||
subscriptionEndsAt: z.coerce.date().nullable(),
|
||||
graceDays: z.number().int().min(0).max(365),
|
||||
accessEnabled: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
.parse(req.body);
|
||||
const [company] = await db.select().from(companies).where(eq(companies.id, id));
|
||||
if (!company) throw notFound();
|
||||
const plan = body.plan ?? (company.plan as Plan);
|
||||
const model = body.billingModel ?? company.billingModel;
|
||||
if (model === 'percent' && PLAN_SPECS[plan].commissionPercent === null) {
|
||||
throw badRequest('BILLING_MODEL_NOT_ALLOWED');
|
||||
}
|
||||
if (body.plan && (await memberCount(db, id)) > PLAN_SPECS[plan].maxMembers) {
|
||||
throw conflict('MEMBER_LIMIT_REACHED');
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const { plan: _plan, billingModel: _model, ...rest } = body;
|
||||
if (Object.keys(rest).length) await tx.update(companies).set(rest).where(eq(companies.id, id));
|
||||
// Суперадмин меняет тариф сразу, без ожидания следующего месяца.
|
||||
if (plan !== company.plan || model !== company.billingModel) {
|
||||
const [fresh] = await tx.select().from(companies).where(eq(companies.id, id));
|
||||
await applyPlan(tx, fresh!, plan, model as BillingModel);
|
||||
}
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Вход в кабинет компании для поддержки (id = null — выйти обратно в админку). */
|
||||
app.post('/api/admin/impersonate', async (req) => {
|
||||
superadmin(req);
|
||||
const { companyId } = z.object({ companyId: z.number().int().nullable() }).parse(req.body);
|
||||
if (companyId !== null) {
|
||||
const [company] = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId));
|
||||
if (!company) throw notFound();
|
||||
}
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ actingCompanyId: companyId })
|
||||
.where(eq(sessions.tokenHash, hashToken(req.sessionToken!)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Ссылка привязки Telegram: пользователь открывает бота, тот запоминает его чат. */
|
||||
app.post('/api/me/telegram-link', async (req) => {
|
||||
const ctx = auth(req);
|
||||
if (!config.telegramBotToken || !config.telegramBotUsername) throw badRequest('TELEGRAM_DISABLED');
|
||||
const code = newToken().slice(0, 32);
|
||||
await db.update(users).set({ telegramLinkToken: code }).where(eq(users.id, ctx.user.id));
|
||||
return { url: `https://t.me/${config.telegramBotUsername}?start=${code}` };
|
||||
});
|
||||
|
||||
// --- суперадмин: парсер ---
|
||||
|
||||
app.get('/api/admin/parser', async (req) => {
|
||||
superadmin(req);
|
||||
const [runs, [stats]] = await Promise.all([
|
||||
db.select().from(parserRuns).orderBy(desc(parserRuns.id)).limit(20),
|
||||
db
|
||||
.select({
|
||||
lastOk: sql<Date | null>`(select max(finished_at) from parser_runs where status = 'ok')`,
|
||||
listings24h: sql<number>`(select count(*)::int from ${listings} where created_at > now() - interval '24 hours')`,
|
||||
listingsTotal: sql<number>`(select count(*)::int from ${listings})`,
|
||||
// Сколько длится полный круг парсера: проход плюс пауза до следующего.
|
||||
cycleSec: sql<number | null>`(select round(avg(sec))::int from (select extract(epoch from started_at - lag(started_at) over (order by id)) as sec from parser_runs order by id desc limit 20) x)`,
|
||||
// Задержка «размещено на list.am → лид создан» за сутки. Считаются только объявления,
|
||||
// пойманные в день размещения, и только если известно точное время размещения.
|
||||
delayMedianSec: sql<number | null>`(select round(percentile_cont(0.5) within group (order by extract(epoch from d.created_at - l.published_at)))::int from ${leads} d join ${listings} l on l.id = d.listing_id where d.created_at > now() - interval '24 hours' and d.created_at - l.published_at < interval '6 hours' and d.created_at - l.created_at < interval '1 minute' and extract(second from l.published_at) + extract(minute from l.published_at) > 0)`,
|
||||
delayCount: sql<number>`(select count(*)::int from ${leads} d join ${listings} l on l.id = d.listing_id where d.created_at > now() - interval '24 hours' and d.created_at - l.published_at < interval '6 hours' and d.created_at - l.created_at < interval '1 minute' and extract(second from l.published_at) + extract(minute from l.published_at) > 0)`,
|
||||
})
|
||||
.from(sql`(select 1) as one`),
|
||||
]);
|
||||
return { enabled: Boolean(source), source: source?.name ?? null, ...stats, runs };
|
||||
});
|
||||
|
||||
/** Настройки парсера. Ключ ридера наружу не отдаётся — только признак и последние символы. */
|
||||
const parserSettingsView = async () => {
|
||||
const { jinaApiKey, ...rest } = await loadParserSettings(db);
|
||||
const key = jinaApiKey || config.jinaApiKey || '';
|
||||
return {
|
||||
...rest,
|
||||
jinaKey: { source: jinaApiKey ? 'settings' : config.jinaApiKey ? 'env' : 'none', tail: key ? key.slice(-4) : null },
|
||||
allCategories: LISTAM_CATEGORIES,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/admin/parser/settings', async (req) => {
|
||||
superadmin(req);
|
||||
return parserSettingsView();
|
||||
});
|
||||
|
||||
app.patch('/api/admin/parser/settings', async (req) => {
|
||||
superadmin(req);
|
||||
const patch = onlySent(req.body, ParserSettings.partial().parse(req.body));
|
||||
if (patch.categories) {
|
||||
const known = new Set(LISTAM_CATEGORIES.map((c) => c.id));
|
||||
patch.categories = [...new Set(patch.categories)].filter((id) => known.has(id));
|
||||
}
|
||||
await saveParserSettings(db, patch);
|
||||
return parserSettingsView();
|
||||
});
|
||||
|
||||
app.post('/api/admin/parser/run', async (req) => {
|
||||
superadmin(req);
|
||||
if (!source) throw badRequest('PARSER_DISABLED');
|
||||
return runIngest({ db, source });
|
||||
});
|
||||
|
||||
registerLeadRoutes(app, db, { member, manager, writable }, config, fetchImage, source, groupSender);
|
||||
registerClientRoutes(app, db, { member, manager, writable }, config);
|
||||
registerDepartmentRoutes(app, db, { member, manager, writable });
|
||||
registerPipelineRoutes(app, db, { member, manager, writable });
|
||||
registerArchiveRoutes(app, db, { member, manager, writable });
|
||||
registerAnalyticsRoutes(app, db, { member, manager, writable });
|
||||
registerAdminListingRoutes(app, db, superadmin);
|
||||
registerBillingRoutes(app, db, { owner, superadmin }, config, payments);
|
||||
registerPayrollRoutes(app, db, { member, manager, writable });
|
||||
registerShowcaseRoutes(app, db, { member, manager, writable }, config, fetchImage);
|
||||
registerBarevRoutes(app, db, config, fetchImage, groupSender);
|
||||
const fileSender = sendFile ?? (config.telegramBotToken ? telegramFileSender(config.telegramBotToken) : null);
|
||||
registerSupportRoutes(app, db, superadmin, config, fileSender);
|
||||
registerBotStartRoutes(app, db, superadmin, fileSender);
|
||||
|
||||
return Object.assign(app, { guards: { auth, member, manager, writable, superadmin, hasModule } });
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import { events, leadNotifications, leads, listings, sessions, users } from './db/schema.ts';
|
||||
import { runIngest, runRemovalCheck, type PagedSource } from './ingest/service.ts';
|
||||
import { runLeadJobs } from './leads/service.ts';
|
||||
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
|
||||
import type { SourcedListing } from './parser/source.ts';
|
||||
import { processOutbox } from './telegram/outbox.ts';
|
||||
import { fakeTelegram, testApp } from './testing.ts';
|
||||
|
||||
// База бывших клиентов и объектов, отметка «Снято с List.am», повторная публикация, аналитика.
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
let tgId = 9000;
|
||||
let extId = 2_000_000;
|
||||
const pages = new Map<number, SourcedListing[]>();
|
||||
/** Что «ответит» list.am на проверку объявления: false — снято, undefined — понять не удалось. */
|
||||
const alive = new Map<string, boolean | undefined>();
|
||||
const source: PagedSource = {
|
||||
name: 'fake',
|
||||
listPage: async (category, page = 1) => (page === 1 ? (pages.get(category.id) ?? []) : []),
|
||||
inspect: async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) }),
|
||||
isAlive: async (id) => (alive.has(id) ? alive.get(id) : true),
|
||||
};
|
||||
const SALE = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const listing = (over: Partial<SourcedListing> = {}): SourcedListing => {
|
||||
const id = String(++extId);
|
||||
return {
|
||||
externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '3-комн. квартира', dealType: 'sale',
|
||||
propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', address: `ул. Абовяна ${id}`, price: 150_000, currency: 'USD',
|
||||
rooms: 3, area: 80, floor: 4, floorsTotal: 9, isAgency: false, photoUrls: [], ...over,
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await testApp('archive', source);
|
||||
// Категория должна быть непустой, чтобы первый проход по ней состоялся.
|
||||
pages.set(SALE.id, [listing({ district: 'Нубарашен' })]);
|
||||
});
|
||||
afterAll(() => t.close());
|
||||
|
||||
const tg = fakeTelegram();
|
||||
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
|
||||
const publish = async (...items: SourcedListing[]) => {
|
||||
pages.set(SALE.id, [...items, ...(pages.get(SALE.id) ?? [])]);
|
||||
const report = await runIngest({ db: t.db, source });
|
||||
await flush();
|
||||
return report;
|
||||
};
|
||||
|
||||
/** Объявление сняли: его нет ни на странице объявления, ни в списке категории. */
|
||||
const unpublish = (item: SourcedListing) => {
|
||||
alive.set(item.externalId, false);
|
||||
pages.set(SALE.id, (pages.get(SALE.id) ?? []).filter((x) => x.externalId !== item.externalId));
|
||||
};
|
||||
|
||||
interface Member { api: ReturnType<typeof t.client>; id: number; chat: number }
|
||||
|
||||
const company = async (agents = 1, plan: 'solo' | 'team' = 'team') => {
|
||||
// Объявления прошлых сценариев «состариваем», чтобы новый фильтр не забрал их из базы.
|
||||
await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null });
|
||||
const [sa] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
|
||||
.returning();
|
||||
const admin = t.client(await createSession(t.db, sa!.id));
|
||||
const created = await admin.post('/api/admin/companies', {
|
||||
name: 'Агентство', plan, billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
const join = async (name: string, token: string): Promise<Member> => {
|
||||
const api = await t.register(name);
|
||||
await api.post(`/api/invites/${token}/accept`);
|
||||
const id = (await api.get('/api/me')).body.user.id as number;
|
||||
const chat = ++tgId;
|
||||
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
|
||||
return { api, id, chat };
|
||||
};
|
||||
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
|
||||
// Solo работает только из Mini App: помечаем сессию как открытую в Telegram.
|
||||
if (plan === 'solo') await t.db.update(sessions).set({ viaTelegram: true }).where(eq(sessions.userId, boss.id));
|
||||
const team: Member[] = [];
|
||||
if (agents) {
|
||||
const invite = (await boss.api.post('/api/invites')).body.token as string;
|
||||
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite));
|
||||
}
|
||||
await boss.api.post('/api/filters', { name: 'Кентрон', scope: 'company', params: { districts: ['Кентрон'] } });
|
||||
await runIngest({ db: t.db, source });
|
||||
await flush();
|
||||
const p = async (name: 'object' | 'client') => {
|
||||
const info = (await boss.api.get(`/api/pipelines/${name}`)).body;
|
||||
return { ...info, status: (code: string) => info.statuses.find((s: any) => s.code === code).id as number };
|
||||
};
|
||||
return { boss, agents: team, id: created.body.id as number, object: await p('object'), client: await p('client') };
|
||||
};
|
||||
|
||||
const poolOf = async (m: Member) => (await m.api.get('/api/leads?tab=pool')).body as any[];
|
||||
const deal = { commission: 3000, currency: 'USD', date: new Date().toISOString().slice(0, 10) };
|
||||
const day = 86_400_000;
|
||||
|
||||
describe('снятые и повторно опубликованные объявления', () => {
|
||||
it('раз в сутки объект из CRM проверяется на list.am: снятый получает отметку и дату', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const item = listing();
|
||||
await publish(item);
|
||||
const [lead] = await poolOf(a);
|
||||
await a.api.post(`/api/leads/${lead.id}/take`);
|
||||
|
||||
// Свежее объявление пока не проверяется.
|
||||
expect(await runRemovalCheck({ db: t.db, source })).toEqual({ checked: 0, removed: 0 });
|
||||
const tomorrow = new Date(Date.now() + day + 60_000);
|
||||
alive.set(item.externalId, undefined);
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: tomorrow })).toMatchObject({ removed: 0 });
|
||||
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull();
|
||||
// Понять не удалось — повтор через час, а не через сутки.
|
||||
unpublish(item);
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: new Date(tomorrow.getTime() + 30 * 60_000) })).toMatchObject({ removed: 0 });
|
||||
const later = new Date(tomorrow.getTime() + 61 * 60_000);
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: later })).toMatchObject({ removed: 1 });
|
||||
expect(new Date((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).getTime()).toBe(later.getTime());
|
||||
// Снятое больше не проверяется.
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: new Date(later.getTime() + 2 * day) })).toMatchObject({ removed: 0 });
|
||||
});
|
||||
|
||||
it('свежее объявление первый раз проверяется через 4 часа после находки', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const item = listing();
|
||||
await publish(item);
|
||||
const [lead] = await poolOf(a);
|
||||
await a.api.post(`/api/leads/${lead.id}/take`);
|
||||
unpublish(item);
|
||||
|
||||
const hour = 3_600_000;
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 3 * hour) })).toMatchObject({ removed: 0 });
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 4 * hour + 60_000) })).toMatchObject({ removed: 1 });
|
||||
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('«Объявления больше нет»: отметка у всех компаний, объявление снова в списке — вернулось на сайт', async () => {
|
||||
const first = await company(1);
|
||||
const second = await company(1);
|
||||
const [a] = first.agents as [Member];
|
||||
const [b] = second.agents as [Member];
|
||||
const item = listing();
|
||||
await publish(item);
|
||||
const [lead] = await poolOf(a);
|
||||
const [other] = await poolOf(b);
|
||||
|
||||
// Отметить можно и объект из пула.
|
||||
expect((await a.api.post(`/api/leads/${lead.id}/removed`)).status).toBe(200);
|
||||
const card = (await a.api.get(`/api/leads/${lead.id}`)).body;
|
||||
expect(card.removedAt).not.toBeNull();
|
||||
expect(card.events.map((e: any) => e.type)).toContain('listing_removed');
|
||||
expect((await b.api.get(`/api/leads/${other.id}`)).body.removedAt).not.toBeNull();
|
||||
|
||||
// Ошибочная отметка: объявление всё ещё в списке list.am, и следующий проход парсера её снимает.
|
||||
await runIngest({ db: t.db, source });
|
||||
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull();
|
||||
|
||||
// У объекта, заведённого вручную, объявления нет.
|
||||
const manual = (await a.api.post('/api/leads', { title: 'Дом в Гарни' })).body.id;
|
||||
expect((await a.api.post(`/api/leads/${manual}/removed`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('снятое объявление: при настройке компании открытые лиды сами уходят в «Отказ», у других — без изменений', async () => {
|
||||
const on = await company(1);
|
||||
const off = await company(1);
|
||||
expect((await on.boss.api.patch('/api/company', { autoRejectRemoved: true })).status).toBe(200);
|
||||
const [a] = on.agents as [Member];
|
||||
const [b] = off.agents as [Member];
|
||||
const taken = listing();
|
||||
const pooled = listing();
|
||||
await publish(taken, pooled);
|
||||
const leadOf = async (m: Member, item: SourcedListing) => (await poolOf(m)).find((l) => l.url === item.sourceUrl);
|
||||
const mine = await leadOf(a, taken);
|
||||
const inPool = await leadOf(a, pooled);
|
||||
const other = await leadOf(b, taken);
|
||||
await a.api.post(`/api/leads/${mine.id}/take`);
|
||||
|
||||
// Проверка list.am увидела, что объявление сняли.
|
||||
unpublish(taken);
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * 3_600_000) })).toMatchObject({ removed: 1 });
|
||||
const card = (await a.api.get(`/api/leads/${mine.id}`)).body;
|
||||
expect(card.statusId).toBe(on.object.status('rejected'));
|
||||
expect(on.object.reasons.find((r: any) => r.id === card.rejectReasonId)).toMatchObject({ code: 'gone', name: 'Уже продано или сдано' });
|
||||
expect(card.events.some((e: any) => e.type === 'status' && e.text === 'Объявление снято с List.am')).toBe(true);
|
||||
expect((await b.api.get(`/api/leads/${other.id}`)).body.statusId).toBe(off.object.status('new'));
|
||||
|
||||
// Отметка агента «Объявления больше нет» — лид из пула тоже уходит в «Отказ» и пропадает из пула.
|
||||
expect((await a.api.post(`/api/leads/${inPool.id}/removed`)).status).toBe(200);
|
||||
expect((await a.api.get(`/api/leads/${inPool.id}`)).body.statusId).toBe(on.object.status('rejected'));
|
||||
expect((await poolOf(a)).map((l) => l.id)).not.toContain(inPool.id);
|
||||
await on.boss.api.patch('/api/company', { autoRejectRemoved: false });
|
||||
});
|
||||
|
||||
it('правка объекта: ответственный и руководитель меняют данные карточки, общая база не меняется', async () => {
|
||||
const c = await company(2);
|
||||
const [a, b] = c.agents as [Member, Member];
|
||||
const item = listing();
|
||||
await publish(item);
|
||||
const [lead] = await poolOf(a);
|
||||
|
||||
// Объект в пуле агент не правит — сначала «Беру».
|
||||
expect((await a.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(403);
|
||||
await a.api.post(`/api/leads/${lead.id}/take`);
|
||||
// Чужой объект другой агент даже не видит.
|
||||
expect((await b.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(404);
|
||||
|
||||
expect((await a.api.patch(`/api/leads/${lead.id}`, { title: '2-комн. квартира', price: 900, rooms: '', currency: 'AMD' })).status).toBe(200);
|
||||
const card = (await a.api.get(`/api/leads/${lead.id}`)).body;
|
||||
expect(card).toMatchObject({ title: '2-комн. квартира', price: 900, rooms: null, currency: 'AMD', area: 80, district: 'Кентрон' });
|
||||
expect(card.events.filter((e: any) => e.type === 'edited')).toHaveLength(1);
|
||||
// Текст для клиента собран из новых данных.
|
||||
expect(card.clientText).not.toContain('Комнат');
|
||||
|
||||
// Повтор тех же значений историю не засоряет.
|
||||
await a.api.patch(`/api/leads/${lead.id}`, { price: 900 });
|
||||
expect((await a.api.get(`/api/leads/${lead.id}`)).body.events.filter((e: any) => e.type === 'edited')).toHaveLength(1);
|
||||
|
||||
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { floor: 5 })).status).toBe(200);
|
||||
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { title: '' })).status).toBe(400);
|
||||
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, {})).status).toBe(400);
|
||||
|
||||
const [base] = await t.db.select().from(listings).where(eq(listings.externalId, item.externalId));
|
||||
expect(base).toMatchObject({ title: '3-комн. квартира', price: 150_000, rooms: 3, floor: 4 });
|
||||
});
|
||||
|
||||
it('снятое объявление опубликовано снова под тем же номером: новый лид со ссылкой на прошлый', async () => {
|
||||
const closed = await company(1);
|
||||
const open = await company(1);
|
||||
const [a] = closed.agents as [Member];
|
||||
const [b] = open.agents as [Member];
|
||||
const item = listing();
|
||||
await publish(item);
|
||||
const first = (await poolOf(a))[0];
|
||||
await a.api.post(`/api/leads/${first.id}/take`);
|
||||
await a.api.post(`/api/leads/${first.id}/status`, { statusId: closed.object.status('rejected'), rejectReasonId: closed.object.reasons[1].id });
|
||||
const kept = (await poolOf(b))[0];
|
||||
await b.api.post(`/api/leads/${kept.id}/take`);
|
||||
|
||||
unpublish(item);
|
||||
const removedAt = new Date(Date.now() + day + 60_000);
|
||||
await runRemovalCheck({ db: t.db, source, now: removedAt });
|
||||
// Пока объявление снято, повторный проход парсера ничего не создаёт.
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 });
|
||||
|
||||
const before = tg.inbox(a.chat).length;
|
||||
alive.delete(item.externalId);
|
||||
const report = await publish(item);
|
||||
// Новый лид получает только компания, у которой прошлый закрыт.
|
||||
expect(report.leads).toBe(1);
|
||||
expect(await poolOf(b)).toHaveLength(0);
|
||||
const again = (await poolOf(a))[0];
|
||||
expect(again.id).not.toBe(first.id);
|
||||
expect(tg.inbox(a.chat).length).toBe(before + 1);
|
||||
const card = (await a.api.get(`/api/leads/${again.id}`)).body;
|
||||
expect(card.removedAt).toBeNull();
|
||||
expect(card.previous).toMatchObject({ id: first.id, assigneeName: 'Агент 1', status: 'Отказ' });
|
||||
expect(new Date(card.previous.removedAt).getTime()).toBe(removedAt.getTime());
|
||||
// Следующие проходы лид не дублируют.
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 });
|
||||
expect(await poolOf(a)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('тот же объект под новым номером: совпали адрес и характеристики, прошлое объявление снято', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const old = listing({ address: 'ул. Туманяна 12' });
|
||||
await publish(old);
|
||||
const first = (await poolOf(a))[0];
|
||||
await a.api.post(`/api/leads/${first.id}/take`);
|
||||
|
||||
// Пока прошлое объявление на сайте, совпадение адреса ничего не значит (соседняя квартира).
|
||||
await publish(listing({ address: 'ул. Туманяна 12' }));
|
||||
const neighbour = (await poolOf(a))[0];
|
||||
expect((await a.api.get(`/api/leads/${neighbour.id}`)).body.previous).toBeNull();
|
||||
await a.api.post(`/api/leads/${neighbour.id}/take`);
|
||||
|
||||
unpublish(old);
|
||||
await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + day + 60_000) });
|
||||
await publish(listing({ address: ' УЛ. Туманяна 12 ', price: 140_000 }), listing({ address: 'ул. Туманяна 12', rooms: 2 }));
|
||||
const pool = await poolOf(a);
|
||||
expect(pool).toHaveLength(2);
|
||||
const cards = await Promise.all(pool.map(async (l) => (await a.api.get(`/api/leads/${l.id}`)).body));
|
||||
expect(cards.find((x) => x.rooms === 3).previous).toMatchObject({ id: first.id, status: 'Взят в работу' });
|
||||
expect(cards.find((x) => x.rooms === 2).previous).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('база бывших клиентов и объектов', () => {
|
||||
it('закрытые сделкой и отказом объекты и клиенты попадают в базу; Solo базы и аналитики не видит', async () => {
|
||||
const c = await company(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const mk = async (m: Member, over: object) => (await m.api.post('/api/leads', { title: 'Дом в Арабкире', district: 'Арабкир', price: 300_000, ...over })).body.id as number;
|
||||
const sold = await mk(a1, { phone: '091 111 222', authorName: 'Гагик' });
|
||||
const lost = await mk(a1, { phone: '091 111 222', title: 'Квартира Гагика' });
|
||||
const working = await mk(a1, { title: 'В работе' });
|
||||
const foreign = await mk(a2, { title: 'Чужой', phone: '077 333 444' });
|
||||
const buyer = (await a1.api.post('/api/clients', { name: 'Анна', phone: '055 000 111', kind: 'buyer' })).body.id as number;
|
||||
await a1.api.post(`/api/clients/${buyer}/leads`, { leadId: sold });
|
||||
await a1.api.post(`/api/leads/${sold}/status`, { statusId: c.object.status('deal'), deal: { ...deal, clientId: buyer } });
|
||||
await a1.api.post(`/api/leads/${lost}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[2].id });
|
||||
await a2.api.post(`/api/leads/${foreign}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[1].id });
|
||||
|
||||
const mine = (await a1.api.get('/api/archive/leads')).body;
|
||||
expect(mine.map((l: any) => l.id).sort()).toEqual([sold, lost].sort());
|
||||
expect(mine.find((l: any) => l.id === sold)).toMatchObject({ result: 'deal', commission: 3000, dealCurrency: 'USD', phone: '+37491111222' });
|
||||
expect(mine.find((l: any) => l.id === lost)).toMatchObject({ result: 'rejected', reason: c.object.reasons[2].name, removedAt: null });
|
||||
expect(mine.some((l: any) => l.id === working)).toBe(false);
|
||||
expect((await a1.api.get('/api/archive/leads?result=deal')).body).toHaveLength(1);
|
||||
expect((await a1.api.get('/api/archive/leads?q=111222')).body).toHaveLength(2);
|
||||
expect((await c.boss.api.get('/api/archive/leads')).body).toHaveLength(3);
|
||||
expect((await c.boss.api.get(`/api/archive/leads?assigneeId=${a2.id}`)).body).toMatchObject([{ id: foreign }]);
|
||||
|
||||
// Клиенты: только закрытые покупатели и арендаторы, собственники закрытых объектов сюда не попадают.
|
||||
const contacts = (await a1.api.get('/api/archive/contacts')).body;
|
||||
expect(contacts).toHaveLength(1);
|
||||
expect(contacts[0]).toMatchObject({ type: 'buyer', name: 'Анна', phone: '+37455000111', result: 'deal', objects: [{ id: sold }] });
|
||||
expect((await c.boss.api.get('/api/archive/contacts')).body).toHaveLength(1);
|
||||
expect((await a1.api.get('/api/archive/contacts?q=Анна')).body).toHaveLength(1);
|
||||
|
||||
const solo = await company(0, 'solo');
|
||||
expect((await solo.boss.api.get('/api/archive/leads')).body.code).toBe('MODULE_NOT_AVAILABLE');
|
||||
expect((await solo.boss.api.get('/api/archive/contacts')).status).toBe(403);
|
||||
expect((await solo.boss.api.get(`/api/analytics?from=${new Date(0).toISOString()}&to=${new Date().toISOString()}`)).body.code).toBe('MODULE_NOT_AVAILABLE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('суперадмин: общая база объявлений', () => {
|
||||
it('суперадмин видит все объявления с фильтрами и кому они ушли лидом; руководителю раздел закрыт', async () => {
|
||||
const c = await company(1);
|
||||
const hit = listing({ title: 'Особняк на Туманяна', price: 777_000 });
|
||||
const miss = listing({ district: 'Нубарашен', isAgency: true, title: 'Склад' });
|
||||
await publish(hit, miss);
|
||||
const [sa] = await t.db.insert(users).values({ name: 'Владелец', role: 'superadmin', email: `own${Math.random()}@test.am`, passwordHash: 'x' }).returning();
|
||||
const admin = t.client(await createSession(t.db, sa!.id));
|
||||
|
||||
const all = (await admin.get('/api/admin/listings')).body;
|
||||
expect(all.total).toBeGreaterThanOrEqual(2);
|
||||
expect(all.items.length).toBe(Math.min(all.total, 50));
|
||||
const found = (await admin.get(`/api/admin/listings?q=${hit.externalId}`)).body;
|
||||
expect(found).toMatchObject({ total: 1, items: [{ title: 'Особняк на Туманяна', price: 777_000, active: true }] });
|
||||
// Лид получили все компании с подходящим фильтром, включая компании прошлых сценариев.
|
||||
expect(found.items[0].leads).toBeGreaterThanOrEqual(1);
|
||||
expect((await admin.get(`/api/admin/listings?q=${encodeURIComponent('особняк')}&leads=no`)).body.total).toBe(0);
|
||||
const agencies = (await admin.get('/api/admin/listings?author=agency&leads=no')).body.items;
|
||||
expect(agencies.map((l: any) => l.externalId)).toContain(miss.externalId);
|
||||
expect((await admin.get('/api/admin/listings?priceFrom=700000&priceTo=800000')).body.items.map((l: any) => l.externalId)).toEqual([hit.externalId]);
|
||||
|
||||
const card = (await admin.get(`/api/admin/listings/${found.items[0].id}`)).body;
|
||||
expect(card.leads).toHaveLength(found.items[0].leads);
|
||||
expect(card.leads[0]).toMatchObject({ company: 'Агентство', status: 'Новый', assignee: null });
|
||||
expect((await c.boss.api.get('/api/admin/listings')).status).toBe(403);
|
||||
expect((await c.boss.api.get(`/api/admin/listings/${found.items[0].id}`)).status).toBe(403);
|
||||
|
||||
// Настройки парсера: ключ ридера наружу не отдаётся; правка одного поля не трогает остальные.
|
||||
expect((await c.boss.api.get('/api/admin/parser/settings')).status).toBe(403);
|
||||
const before = (await admin.get('/api/admin/parser/settings')).body;
|
||||
expect(before).toMatchObject({ enabled: true, freshDays: 3, pauseSec: 5, maxPages: 3, removalCheck: true, jinaKey: { source: 'none', tail: null } });
|
||||
expect(before.categories).toHaveLength(10);
|
||||
const saved = (await admin.patch('/api/admin/parser/settings', { jinaApiKey: 'jina_secret_1234', pauseSec: 30 })).body;
|
||||
expect(saved).toMatchObject({ pauseSec: 30, freshDays: 3, jinaKey: { source: 'settings', tail: '1234' } });
|
||||
expect(JSON.stringify(saved)).not.toContain('jina_secret');
|
||||
expect((await admin.patch('/api/admin/parser/settings', { freshDays: 0 })).status).toBe(400);
|
||||
|
||||
// Выключенная категория не читается; срок свежести действует на блок «Топ» — его порядок ротируется.
|
||||
await admin.patch('/api/admin/parser/settings', { categories: [56], freshDays: 1 });
|
||||
const skipped = listing({ promoted: true });
|
||||
expect(await publish(skipped)).toMatchObject({ categories: 1, foundNew: 0 });
|
||||
await admin.patch('/api/admin/parser/settings', { categories: before.categories });
|
||||
source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 3 * 86_400_000) });
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ categories: 10, foundNew: 0 });
|
||||
source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) });
|
||||
await admin.patch('/api/admin/parser/settings', { freshDays: 3, removalCheck: false });
|
||||
// Проверка снятых выключена — ничего не проверяется.
|
||||
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * day) })).toEqual({ checked: 0, removed: 0 });
|
||||
await admin.patch('/api/admin/parser/settings', { removalCheck: true, jinaApiKey: '' });
|
||||
expect((await admin.get('/api/admin/parser/settings')).body.jinaKey).toEqual({ source: 'none', tail: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('аналитика по сотрудникам', () => {
|
||||
const range = () => `from=${new Date(Date.now() - day).toISOString()}&to=${new Date(Date.now() + 3 * day).toISOString()}`;
|
||||
|
||||
it('воронка объекта: взято, скорость реакции, возвраты в пул, конверсия, сделки, причины отказа', async () => {
|
||||
const c = await company(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const st = c.object.statuses.map((s: any) => s.id) as number[]; // new, taken, reached, meeting, contract, sale, deal, rejected
|
||||
await publish(listing(), listing(), listing(), listing());
|
||||
const pool = await poolOf(a1);
|
||||
expect(pool).toHaveLength(4);
|
||||
const [l1, l2, l3, l4] = pool.map((l) => l.id) as [number, number, number, number];
|
||||
|
||||
// Агент 1 берёт три лида: один доводит до сделки, один — до встречи и отказа, один теряет по таймеру.
|
||||
for (const id of [l1, l2, l3]) await a1.api.post(`/api/leads/${id}/take`);
|
||||
// Скорость реакции: уведомление пришло за 60, 120 и 600 секунд до «Беру» → медиана 120.
|
||||
for (const [id, sec] of [[l1, 60], [l2, 120], [l3, 600]] as const) {
|
||||
const [taken] = await t.db.select().from(events).where(eq(events.leadId, id)).then((r) => r.filter((e) => e.type === 'taken'));
|
||||
await t.db.update(leadNotifications).set({ sentAt: new Date(taken!.at.getTime() - sec * 1000) }).where(eq(leadNotifications.leadId, id));
|
||||
}
|
||||
await a1.api.post(`/api/leads/${l1}/status`, { statusId: st[4] });
|
||||
await a1.api.post(`/api/leads/${l1}/status`, { statusId: c.object.status('deal'), deal });
|
||||
await a1.api.post(`/api/leads/${l2}/status`, { statusId: st[3] });
|
||||
await a1.api.post(`/api/leads/${l2}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[4].id });
|
||||
await t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - 25 * 3_600_000) }).where(eq(leads.id, l3));
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ returned: 1 });
|
||||
// Агент 2 подбирает потерянный лид и доводит до «Дозвонился»; руководитель назначает ему четвёртый.
|
||||
await a2.api.post(`/api/leads/${l3}/take`);
|
||||
await a2.api.post(`/api/leads/${l3}/status`, { statusId: st[2] });
|
||||
await c.boss.api.post(`/api/leads/${l4}/assign`, { userId: a2.id });
|
||||
|
||||
const r = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body;
|
||||
expect(r.statuses.map((s: any) => s.id)).toEqual(st);
|
||||
expect(r.summary).toMatchObject({ created: 4, taken: 4, open: 0, cohort: 4 });
|
||||
// Воронка целиком: все 4 взяты; до «Дозвонился» и дальше дошли 3; до встречи — 2; договор и сделка — 1; отказ — 1.
|
||||
expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([4, 4, 3, 2, 1, 1, 1, 1]);
|
||||
|
||||
const row = (m: Member) => r.agents.find((x: any) => x.userId === m.id);
|
||||
expect(row(a1)).toMatchObject({ taken: 3, reactionSec: 120, poolReturns: 1, cohort: 3, deals: 1, commission: { USD: 3000 } });
|
||||
// Лид, потерянный агентом 1, у него остался на этапе «Взят»; дальнейшее — заслуга агента 2.
|
||||
expect(st.map((id) => row(a1).reached[id] ?? 0)).toEqual([3, 3, 2, 2, 1, 1, 1, 1]);
|
||||
expect(row(a1).reasons).toEqual({ [c.object.reasons[4].id]: 1 });
|
||||
expect(row(a2)).toMatchObject({ taken: 2, poolReturns: 0, cohort: 2, deals: 0, commission: {} });
|
||||
expect(st.map((id) => row(a2).reached[id] ?? 0)).toEqual([2, 2, 1, 0, 0, 0, 0, 0]);
|
||||
expect(row(c.boss)).toMatchObject({ taken: 0, cohort: 0 });
|
||||
expect(r.totals).toMatchObject({ taken: 5, poolReturns: 1, deals: 1, commission: { USD: 3000 } });
|
||||
|
||||
// Прошлый период пуст; агент аналитику не видит.
|
||||
const past = (await c.boss.api.get(`/api/analytics?from=${new Date(Date.now() - 30 * day).toISOString()}&to=${new Date(Date.now() - 20 * day).toISOString()}`)).body;
|
||||
expect(past.summary).toMatchObject({ created: 0, taken: 0 });
|
||||
expect(past.totals.deals).toBe(0);
|
||||
expect((await a1.api.get(`/api/analytics?${range()}`)).status).toBe(403);
|
||||
});
|
||||
|
||||
it('воронка клиента: новые запросы, показы, конверсия, сделки, причины отказа', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const st = c.client.statuses.map((s: any) => s.id) as number[]; // new, qualification, selection, showing, negotiation, deal, rejected
|
||||
const mk = async (name: string) => (await a.api.post('/api/clients', { name, kind: 'buyer' })).body.id as number;
|
||||
const [won, lost] = [await mk('Анна'), await mk('Давид'), await mk('Мария')];
|
||||
const leadId = (await a.api.post('/api/leads', { title: 'Квартира' })).body.id as number;
|
||||
await a.api.post(`/api/clients/${won}/leads`, { leadId, state: 'shown' });
|
||||
await a.api.post(`/api/clients/${won}/status`, { statusId: st[3] });
|
||||
await a.api.post(`/api/clients/${won}/status`, { statusId: c.client.status('deal'), deal: { ...deal, commission: 2000, leadId } });
|
||||
await a.api.post(`/api/clients/${lost}/status`, { statusId: st[1] });
|
||||
await a.api.post(`/api/clients/${lost}/status`, { statusId: c.client.status('rejected'), rejectReasonId: c.client.reasons[0].id });
|
||||
|
||||
const r = (await c.boss.api.get(`/api/analytics?pipeline=client&${range()}`)).body;
|
||||
expect(r.summary).toMatchObject({ created: 3, open: 1, cohort: 3 });
|
||||
expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([3, 2, 1, 1, 1, 1, 1]);
|
||||
const row = r.agents.find((x: any) => x.userId === a.id);
|
||||
expect(row).toMatchObject({ taken: 3, showings: 1, cohort: 3, deals: 1, commission: { USD: 2000 }, reasons: { [c.client.reasons[0].id]: 1 } });
|
||||
// Парная сделка одна: в аналитике объектов та же комиссия, без удвоения.
|
||||
const objects = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body;
|
||||
expect(objects.totals).toMatchObject({ deals: 1, commission: { USD: 2000 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { hasModule, type Plan } from '@lr/shared';
|
||||
import { and, desc, eq, gte, ilike, inArray, lte, not, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clientLeads, clients, deals, leads, listings, pipelineStatuses, rejectReasons, users } from '../db/schema.ts';
|
||||
import type { Guards } from '../leads/routes.ts';
|
||||
import { forbidden } from '../lib/errors.ts';
|
||||
import { departmentScope, inDepartment, listingLive, priceRange } from '../leads/service.ts';
|
||||
import { loadBillingSettings } from '../billing/service.ts';
|
||||
|
||||
// База бывших клиентов и объектов (тарифы Team и Agency): всё, что закрыто сделкой или отказом, и объекты,
|
||||
// снятые с List.am. Попадает сюда автоматически — это те же лиды и клиенты, отобранные по итоговому статусу.
|
||||
// Сотрудник видит базу своего отдела, руководитель — всех отделов.
|
||||
|
||||
const CLOSED = ['deal', 'rejected'];
|
||||
|
||||
export function registerArchiveRoutes(app: FastifyInstance, db: Db, g: Guards) {
|
||||
const guard = (req: Parameters<Guards['member']>[0]) => {
|
||||
const ctx = g.member(req);
|
||||
if (!hasModule(ctx.company.plan as Plan, 'archive')) throw forbidden('MODULE_NOT_AVAILABLE');
|
||||
return ctx;
|
||||
};
|
||||
const Query = z.object({
|
||||
q: z.string().trim().max(100).optional(),
|
||||
/** removed — объекты, снятые с List.am, в любом статусе. */
|
||||
result: z.enum(['deal', 'rejected', 'removed']).optional(),
|
||||
assigneeId: z.coerce.number().int().optional(),
|
||||
district: z.string().trim().max(60).optional(),
|
||||
priceFrom: z.coerce.number().nonnegative().optional(),
|
||||
priceTo: z.coerce.number().nonnegative().optional(),
|
||||
priceCurrency: z.enum(['AMD', 'USD']).default('USD'),
|
||||
from: z.coerce.date().optional(),
|
||||
to: z.coerce.date().optional(),
|
||||
});
|
||||
const like = (q: string) => `%${q.replace(/[%_\\]/g, '\\$&')}%`;
|
||||
|
||||
/** Закрытые объекты. Агент видит свои, руководитель и агенты при открытой видимости — все. */
|
||||
app.get('/api/archive/leads', async (req) => {
|
||||
const ctx = guard(req);
|
||||
const q = Query.parse(req.query);
|
||||
const isManager = ctx.role === 'manager';
|
||||
const seeAll = isManager || ctx.company.settings.agentsSeeAll;
|
||||
const removed = not(listingLive);
|
||||
const where: (SQL | undefined)[] = [
|
||||
eq(leads.companyId, ctx.company.id),
|
||||
q.result === 'removed' ? removed : q.result ? inArray(pipelineStatuses.code, [q.result]) : or(inArray(pipelineStatuses.code, CLOSED), removed),
|
||||
departmentScope(ctx, leads.departmentId),
|
||||
seeAll ? undefined : eq(leads.assigneeId, ctx.user.id),
|
||||
];
|
||||
if (q.assigneeId && seeAll) where.push(eq(leads.assigneeId, q.assigneeId));
|
||||
if (q.district) where.push(ilike(leads.district, q.district));
|
||||
if (q.priceFrom !== undefined || q.priceTo !== undefined) {
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
where.push(priceRange(leads.price, leads.currency, rates, q.priceCurrency, q.priceFrom, q.priceTo));
|
||||
}
|
||||
if (q.from) where.push(gte(leads.statusChangedAt, q.from));
|
||||
if (q.to) where.push(lte(leads.statusChangedAt, q.to));
|
||||
if (q.q) {
|
||||
const digits = q.q.replace(/\D/g, '');
|
||||
where.push(
|
||||
or(
|
||||
ilike(leads.address, like(q.q)),
|
||||
ilike(leads.title, like(q.q)),
|
||||
ilike(leads.url, like(q.q)),
|
||||
digits.length >= 4
|
||||
? and(ilike(leads.phone, `%${digits}%`), isManager ? undefined : eq(leads.assigneeId, ctx.user.id))
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
lead: leads, result: pipelineStatuses.code, assigneeName: users.name, reason: rejectReasons.name,
|
||||
commission: deals.commission, dealCurrency: deals.currency, active: listings.active, removedAt: listings.removedAt,
|
||||
})
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.leftJoin(rejectReasons, eq(rejectReasons.id, leads.rejectReasonId))
|
||||
.leftJoin(deals, eq(deals.leadId, leads.id))
|
||||
.leftJoin(listings, eq(listings.id, leads.listingId))
|
||||
.where(and(...where))
|
||||
.orderBy(desc(leads.statusChangedAt))
|
||||
.limit(300);
|
||||
return rows.map(({ lead: l, ...r }) => {
|
||||
const own = isManager || l.assigneeId === ctx.user.id;
|
||||
return {
|
||||
id: l.id, title: l.title, url: l.url, source: l.source, dealType: l.dealType, district: l.district, region: l.region,
|
||||
address: l.address, price: l.price, currency: l.currency, photo: l.photos[0] ?? null,
|
||||
authorName: own ? l.authorName : null, phone: own ? l.phone : null,
|
||||
assigneeId: l.assigneeId, assigneeName: r.assigneeName, closedAt: l.statusChangedAt,
|
||||
result: r.result, reason: r.reason, commission: r.commission, dealCurrency: r.dealCurrency,
|
||||
// Отметка «Снято с List.am».
|
||||
removedAt: r.active === false ? r.removedAt : null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Клиенты: закрытые покупатели и арендаторы, у каждого — список связанных объектов. */
|
||||
app.get('/api/archive/contacts', async (req) => {
|
||||
const ctx = guard(req);
|
||||
const q = Query.pick({ q: true, result: true, assigneeId: true }).parse(req.query);
|
||||
const isManager = ctx.role === 'manager';
|
||||
const seeAll = isManager || ctx.company.settings.agentsSeeAll;
|
||||
const digits = q.q?.replace(/\D/g, '') ?? '';
|
||||
const agent = q.assigneeId && seeAll ? q.assigneeId : null;
|
||||
|
||||
const closedClients = await db
|
||||
.select({ client: clients, result: pipelineStatuses.code, assigneeName: users.name, reason: rejectReasons.name })
|
||||
.from(clients)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId))
|
||||
.leftJoin(users, eq(users.id, clients.assigneeId))
|
||||
.leftJoin(rejectReasons, eq(rejectReasons.id, clients.rejectReasonId))
|
||||
.where(
|
||||
and(
|
||||
eq(clients.companyId, ctx.company.id),
|
||||
inArray(pipelineStatuses.code, q.result && q.result !== 'removed' ? [q.result] : CLOSED),
|
||||
isManager ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)),
|
||||
seeAll ? undefined : eq(clients.assigneeId, ctx.user.id),
|
||||
agent ? eq(clients.assigneeId, agent) : undefined,
|
||||
q.q
|
||||
? or(
|
||||
ilike(clients.name, like(q.q)),
|
||||
digits.length >= 4
|
||||
? and(ilike(clients.phone, `%${digits}%`), isManager ? undefined : eq(clients.assigneeId, ctx.user.id))
|
||||
: undefined,
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(clients.statusChangedAt))
|
||||
.limit(300);
|
||||
const ids = closedClients.map((r) => r.client.id);
|
||||
const links = ids.length
|
||||
? await db
|
||||
.select({ clientId: clientLeads.clientId, id: leads.id, title: leads.title, state: clientLeads.state })
|
||||
.from(clientLeads)
|
||||
.innerJoin(leads, eq(leads.id, clientLeads.leadId))
|
||||
.where(inArray(clientLeads.clientId, ids))
|
||||
: [];
|
||||
|
||||
return closedClients.map(({ client: c, result, assigneeName, reason }) => {
|
||||
const own = isManager || c.assigneeId === ctx.user.id;
|
||||
return {
|
||||
type: c.kind, key: `c${c.id}`, clientId: c.id, name: c.name, phone: own ? c.phone : null, result, reason,
|
||||
closedAt: c.statusChangedAt, assigneeName,
|
||||
objects: links.filter((l) => l.clientId === c.id).map((l) => ({ id: l.id, title: l.title, state: l.state })),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scryptAsync = promisify(scrypt) as (pw: string, salt: Buffer, len: number) => Promise<Buffer>;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const hash = await scryptAsync(password, salt, 64);
|
||||
return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string | null): Promise<boolean> {
|
||||
const [algo, salt, hash] = (stored ?? '').split('$');
|
||||
if (algo !== 'scrypt' || !salt || !hash) return false;
|
||||
const expected = Buffer.from(hash, 'base64');
|
||||
const actual = await scryptAsync(password, Buffer.from(salt, 'base64'), expected.length);
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { sessions, users } from '../db/schema.ts';
|
||||
|
||||
export const SESSION_COOKIE = 'lr_session';
|
||||
export const SESSION_DAYS = 30;
|
||||
|
||||
export const newToken = () => randomBytes(32).toString('base64url');
|
||||
export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
|
||||
export async function createSession(db: Db, userId: number, viaTelegram = false): Promise<string> {
|
||||
const token = newToken();
|
||||
await db.insert(sessions).values({
|
||||
tokenHash: hashToken(token),
|
||||
userId,
|
||||
viaTelegram,
|
||||
expiresAt: new Date(Date.now() + SESSION_DAYS * 86_400_000),
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function findSession(db: Db, token: string) {
|
||||
const [row] = await db
|
||||
.select({ user: users, actingCompanyId: sessions.actingCompanyId, viaTelegram: sessions.viaTelegram })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date())));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export const deleteSession = (db: Db, token: string) =>
|
||||
db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export interface TelegramUser {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
languageCode?: string;
|
||||
}
|
||||
|
||||
/** Данные входа старше суток не принимаются. */
|
||||
const MAX_AGE_SEC = 86_400;
|
||||
|
||||
const safeEqualHex = (a: string, b: string) => {
|
||||
const x = Buffer.from(a, 'hex');
|
||||
const y = Buffer.from(b, 'hex');
|
||||
return x.length === y.length && x.length > 0 && timingSafeEqual(x, y);
|
||||
};
|
||||
|
||||
const checkString = (pairs: [string, string][]) =>
|
||||
pairs
|
||||
.filter(([k]) => k !== 'hash')
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n');
|
||||
|
||||
const fresh = (authDate: string | undefined, now: number) => {
|
||||
const t = Number(authDate);
|
||||
return Number.isFinite(t) && now / 1000 - t <= MAX_AGE_SEC;
|
||||
};
|
||||
|
||||
/** Проверка подписи Telegram Login Widget (вход в веб-кабинете). */
|
||||
export function verifyLoginWidget(
|
||||
data: Record<string, string | number | undefined>,
|
||||
botToken: string,
|
||||
now = Date.now(),
|
||||
): TelegramUser | null {
|
||||
const pairs = Object.entries(data)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => [k, String(v)] as [string, string]);
|
||||
const map = new Map(pairs);
|
||||
const hash = map.get('hash');
|
||||
if (!hash) return null;
|
||||
const secret = createHash('sha256').update(botToken).digest();
|
||||
const expected = createHmac('sha256', secret).update(checkString(pairs)).digest('hex');
|
||||
if (!safeEqualHex(expected, hash) || !fresh(map.get('auth_date'), now)) return null;
|
||||
const id = Number(map.get('id'));
|
||||
if (!Number.isSafeInteger(id)) return null;
|
||||
return {
|
||||
id,
|
||||
firstName: map.get('first_name') ?? '',
|
||||
lastName: map.get('last_name'),
|
||||
username: map.get('username'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Проверка initData из Telegram Mini App. */
|
||||
export function verifyWebAppInitData(
|
||||
initData: string,
|
||||
botToken: string,
|
||||
now = Date.now(),
|
||||
): TelegramUser | null {
|
||||
const params = new URLSearchParams(initData);
|
||||
const hash = params.get('hash');
|
||||
if (!hash) return null;
|
||||
const secret = createHmac('sha256', 'WebAppData').update(botToken).digest();
|
||||
const expected = createHmac('sha256', secret).update(checkString([...params.entries()])).digest('hex');
|
||||
if (!safeEqualHex(expected, hash) || !fresh(params.get('auth_date') ?? undefined, now)) return null;
|
||||
try {
|
||||
const u = JSON.parse(params.get('user') ?? '') as Record<string, unknown>;
|
||||
if (typeof u.id !== 'number') return null;
|
||||
return {
|
||||
id: u.id,
|
||||
firstName: typeof u.first_name === 'string' ? u.first_name : '',
|
||||
lastName: typeof u.last_name === 'string' ? u.last_name : undefined,
|
||||
username: typeof u.username === 'string' ? u.username : undefined,
|
||||
languageCode: typeof u.language_code === 'string' ? u.language_code : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import { normalizeTelegram } from './barev/service.ts';
|
||||
import { clients, companies, outbox, users, viewings } from './db/schema.ts';
|
||||
import { testApp } from './testing.ts';
|
||||
|
||||
// Сайт BarevClub: объекты «дозвонились и указали комиссию» → заявка на просмотр → клиент в CRM и сообщение в топик группы.
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
beforeAll(async () => {
|
||||
t = await testApp('barev');
|
||||
});
|
||||
afterAll(() => t.close());
|
||||
|
||||
let keySeq = 0;
|
||||
|
||||
/** Запрос от сервера сайта: с ключом компании. */
|
||||
const site = async (key: string, method: 'GET' | 'POST', url: string, body?: object) => {
|
||||
const res = await t.app.inject({ method, url, payload: body, headers: { 'x-barev-key': key } });
|
||||
return { status: res.statusCode, body: res.headers['content-type']?.toString().startsWith('application/json') ? (res.json() as any) : res.rawPayload };
|
||||
};
|
||||
|
||||
const tomorrow = () => new Date(Date.now() + 4 * 3_600_000 + 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
const setup = async () => {
|
||||
const [sa] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
|
||||
.returning();
|
||||
const admin = t.client(await createSession(t.db, sa!.id));
|
||||
const created = await admin.post('/api/admin/companies', {
|
||||
name: 'AREV', plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
const boss = await t.register('Руководитель');
|
||||
await boss.post(`/api/invites/${(created.body.managerUrl as string).split('/invite/')[1]}/accept`);
|
||||
const agentApi = await t.register('Арам');
|
||||
await agentApi.post(`/api/invites/${(await boss.post('/api/invites')).body.token}/accept`);
|
||||
const agentId = (await agentApi.get('/api/me')).body.user.id as number;
|
||||
const id = created.body.id as number;
|
||||
const key = `barev-test-key-${++keySeq}-0123456789abcdef`;
|
||||
const [co] = await t.db.select().from(companies).where(eq(companies.id, id));
|
||||
await t.db
|
||||
.update(companies)
|
||||
.set({ settings: { ...co!.settings, clientHost: 'arev.test', groupBot: { token: 'G:T', chatId: -100500, threadId: 2 }, barevClub: { key, threadId: 442 } } })
|
||||
.where(eq(companies.id, id));
|
||||
return { id, boss, agentApi, agentId, key };
|
||||
};
|
||||
|
||||
describe('витрина BarevClub', () => {
|
||||
it('без ключа и с чужим ключом — 401; ключ не виден в кабинете и не стирается сохранением настроек', async () => {
|
||||
const { boss, key } = await setup();
|
||||
expect((await site('', 'GET', '/api/barev/objects')).status).toBe(401);
|
||||
expect((await site('x'.repeat(30), 'GET', '/api/barev/objects')).status).toBe(401);
|
||||
expect((await boss.get('/api/me')).body.company.settings.barevClub).toBeUndefined();
|
||||
await boss.patch('/api/company', { agentsSeeAll: true });
|
||||
expect((await site(key, 'GET', '/api/barev/objects')).status).toBe(200);
|
||||
});
|
||||
|
||||
it('показывает только объекты в работе с комиссией; заявка создаёт клиента у агента объекта и пишет в топик', async () => {
|
||||
const { id: companyId, agentApi, agentId, key } = await setup();
|
||||
const withCommission = (await agentApi.post('/api/leads', { title: '2-комн. квартира, ул. Абовяна 5', district: 'Кентрон', dealType: 'rent', price: 350000, currency: 'AMD', rooms: 2 })).body.id as number;
|
||||
const noCommission = (await agentApi.post('/api/leads', { title: 'Дом', dealType: 'rent', price: 500000, currency: 'AMD' })).body.id as number;
|
||||
await agentApi.post(`/api/leads/${withCommission}/commission`, { rule: { mode: 'percent', value: 50 } });
|
||||
|
||||
const list = await site(key, 'GET', '/api/barev/objects');
|
||||
const ids = (list.body as { id: number }[]).map((o) => o.id);
|
||||
expect(ids).toContain(withCommission);
|
||||
expect(ids).not.toContain(noCommission);
|
||||
const obj = (list.body as any[]).find((o) => o.id === withCommission);
|
||||
expect(obj).toMatchObject({ district: 'Кентрон', price: 350000, currency: 'AMD', rooms: 2, photos: 0 });
|
||||
expect(typeof obj.text).toBe('string');
|
||||
// Ни адреса, ни ссылки, ни комиссии наружу.
|
||||
expect(JSON.stringify(obj)).not.toContain('Абовяна');
|
||||
expect(obj).not.toHaveProperty('commission');
|
||||
expect(obj).not.toHaveProperty('url');
|
||||
|
||||
// Без контакта и в прошлый день — нельзя; на объект без комиссии — нельзя.
|
||||
expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна' })).body.code).toBe('CONTACT_REQUIRED');
|
||||
expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: '2020-01-01', time: 'evening', name: 'Анна', phone: '+37491111111' })).body.code).toBe('BAD_DAY');
|
||||
expect((await site(key, 'POST', '/api/barev/requests', { leadId: noCommission, day: tomorrow(), time: 'evening', name: 'Анна', phone: '+37491111111' })).status).toBe(404);
|
||||
expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна', telegram: 'не ник' })).body.code).toBe('BAD_TELEGRAM');
|
||||
|
||||
t.groupSent.length = 0;
|
||||
const res = await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна', telegram: 'https://t.me/anna_ru' });
|
||||
expect(res.status).toBe(200);
|
||||
const [client] = await t.db.select().from(clients).where(eq(clients.id, res.body.clientId));
|
||||
expect(client).toMatchObject({ companyId, name: 'Анна', telegram: '@anna_ru', phone: null, source: 'BarevClub', assigneeId: agentId, kind: 'tenant' });
|
||||
const [viewing] = await t.db.select().from(viewings).where(eq(viewings.clientId, client!.id));
|
||||
expect(viewing).toMatchObject({ leadId: withCommission, day: tomorrow() });
|
||||
expect(viewing!.note).toContain('вечер');
|
||||
expect(t.groupSent).toHaveLength(1);
|
||||
expect(t.groupSent[0]).toMatchObject({ kind: 'text', chatId: -100500, threadId: 442 });
|
||||
expect(t.groupSent[0]!.text).toContain('Клиент из BarevClub');
|
||||
expect(t.groupSent[0]!.text).toContain('просьба позвонить');
|
||||
expect(t.groupSent[0]!.text).toContain('@anna_ru');
|
||||
expect(t.groupSent[0]!.text).toContain('Объект ведёт: <b>Арам</b>');
|
||||
expect(t.groupSent[0]!.text).toContain(`https://arev.test/clients/${client!.id}`);
|
||||
|
||||
// Тот же человек снова — та же карточка, с телефоном; в группе пометка о повторной заявке.
|
||||
const again = await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'morning', name: 'Анна К.', phone: '091 22 33 44', telegram: '@anna_ru' });
|
||||
expect(again.body.clientId).toBe(client!.id);
|
||||
const [updated] = await t.db.select().from(clients).where(eq(clients.id, client!.id));
|
||||
expect(updated!.phone).toBeTruthy();
|
||||
expect(t.groupSent.at(-1)!.text).toContain('повторная заявка');
|
||||
});
|
||||
|
||||
it('группа недоступна — агенту объекта уходит уведомление в бот', async () => {
|
||||
const { agentApi, agentId, key } = await setup();
|
||||
const lead = (await agentApi.post('/api/leads', { title: 'Студия', dealType: 'rent', price: 200000, currency: 'AMD' })).body.id as number;
|
||||
await agentApi.post(`/api/leads/${lead}/commission`, { rule: { mode: 'percent', value: 50 } });
|
||||
const failing = { album: async () => {}, text: async () => { throw new Error('chat not found'); } };
|
||||
const { createViewingRequest, companyByKey } = await import('./barev/service.ts');
|
||||
const company = await companyByKey(t.db, key);
|
||||
await createViewingRequest({ db: t.db, config: t.config, sender: failing }, company, { leadId: lead, day: tomorrow(), time: 'day', name: 'Пётр', phone: '+37499000000' });
|
||||
const queued = await t.db.select().from(outbox).where(eq(outbox.userId, agentId));
|
||||
expect(queued.some((m) => (m.payload as { key: string }).key === 'viewingBooked')).toBe(true);
|
||||
});
|
||||
|
||||
it('ник Telegram понимается в разных видах', () => {
|
||||
expect(normalizeTelegram('@ivan_p')).toBe('@ivan_p');
|
||||
expect(normalizeTelegram('t.me/ivan_p')).toBe('@ivan_p');
|
||||
expect(normalizeTelegram('https://t.me/ivan_p/')).toBe('@ivan_p');
|
||||
expect(normalizeTelegram('иван')).toBeNull();
|
||||
expect(normalizeTelegram('ab')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import type { GroupSender } from '../group/service.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import type { ImageFetcher } from '../showcase/media.ts';
|
||||
import { companyByKey, createViewingRequest, listObjects, objectPhoto, VIEWING_TIMES, type ViewingTime } from './service.ts';
|
||||
|
||||
// API для сайта BarevClub. Вход — ключ компании в заголовке X-Barev-Key: его знает только сервер сайта,
|
||||
// посетителю сайта он не виден.
|
||||
|
||||
export function registerBarevRoutes(app: FastifyInstance, db: Db, config: Config, fetchImage?: ImageFetcher, sender?: GroupSender) {
|
||||
const company = (req: FastifyRequest) => companyByKey(db, req.headers['x-barev-key'] as string | undefined);
|
||||
|
||||
app.get('/api/barev/objects', async (req) => listObjects(db, await company(req)));
|
||||
|
||||
app.get('/api/barev/objects/:id/photos/:index', async (req, reply) => {
|
||||
const { id, index } = z.object({ id: z.coerce.number().int().positive(), index: z.coerce.number().int().min(0).max(50) }).parse(req.params);
|
||||
const photo = await objectPhoto(db, config, await company(req), id, index, fetchImage);
|
||||
return reply.header('content-type', 'image/jpeg').header('cache-control', 'public, max-age=86400').send(photo);
|
||||
});
|
||||
|
||||
app.post('/api/barev/requests', async (req) => {
|
||||
const co = await company(req);
|
||||
const body = z
|
||||
.object({
|
||||
leadId: z.number().int().positive(),
|
||||
day: z.iso.date(),
|
||||
time: z.enum(Object.keys(VIEWING_TIMES) as [ViewingTime, ...ViewingTime[]]),
|
||||
name: z.string().trim().min(1).max(80),
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
telegram: z.string().trim().max(80).optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
return createViewingRequest({ db, config, sender }, co, body);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { normalizePhone } from '@lr/shared';
|
||||
import { and, desc, eq, isNotNull, or, sql } from 'drizzle-orm';
|
||||
import type { Db, Tx } from '../db/client.ts';
|
||||
import { clientLeads, clients, companies, events, leads, outbox, pipelineStatuses, users, viewings } from '../db/schema.ts';
|
||||
import { telegramGroupSender, type GroupSender } from '../group/service.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { listingLive } from '../leads/service.ts';
|
||||
import { AppError, badRequest, notFound } from '../lib/errors.ts';
|
||||
import { statusByCode } from '../pipelines/service.ts';
|
||||
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
|
||||
import { renderShowcaseText } from '../showcase/text.ts';
|
||||
import { esc } from '../telegram/texts.ts';
|
||||
|
||||
// Витрина на сайте BarevClub (barevclub.com → «Найдём квартиру»).
|
||||
// Сайт по ключу компании берёт её объекты — те же, что бот группы публикует в закрытую группу:
|
||||
// агент дозвонился (объект в работе) и указал комиссию. Клиент выбирает объект и день просмотра,
|
||||
// оставляет имя и телефон или Telegram — в CRM появляется клиент «BarevClub», закреплённый за агентом
|
||||
// объекта, а бот группы пишет об этом в топик заявок.
|
||||
|
||||
export const BAREV_SOURCE = 'BarevClub';
|
||||
|
||||
/** Сколько фото объекта показываем на сайте (как на странице подборки). */
|
||||
const MAX_PHOTOS = 20;
|
||||
const isListAmPhoto = (url: string) => /^https:\/\/(?:img|s)\.list\.am\//.test(url);
|
||||
|
||||
type Company = typeof companies.$inferSelect;
|
||||
type Lead = typeof leads.$inferSelect;
|
||||
|
||||
/** Компания по ключу сайта. Ключ сравнивается целиком; пустой или чужой — 401. */
|
||||
export async function companyByKey(db: Db, key: string | undefined): Promise<Company> {
|
||||
if (!key || key.length < 24) throw new AppError(401, 'BAD_KEY');
|
||||
const [company] = await db.select().from(companies).where(sql`${companies.settings}->'barevClub'->>'key' = ${key}`);
|
||||
if (!company) throw new AppError(401, 'BAD_KEY');
|
||||
return company;
|
||||
}
|
||||
|
||||
/** Объекты на витрине: в работе (не новый, не сделка, не отказ), с агентом и с комиссией в карточке. */
|
||||
async function showcaseLeads(db: Tx, companyId: number, leadId?: number) {
|
||||
return db
|
||||
.select({ lead: leads })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.where(
|
||||
and(
|
||||
eq(leads.companyId, companyId),
|
||||
isNotNull(leads.assigneeId),
|
||||
isNotNull(leads.commission),
|
||||
sql`coalesce(${pipelineStatuses.code}, '') not in ('new', 'rejected', 'deal')`,
|
||||
// Снятый с List.am объект с витрины убирается.
|
||||
listingLive,
|
||||
leadId ? eq(leads.id, leadId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(leads.takenAt), desc(leads.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Что видит посетитель сайта. Адреса, телефона собственника, ссылки на объявление и комиссии здесь нет:
|
||||
* показ идёт через агента. Фото — адреса относительно сайта, сам сайт забирает их по ключу.
|
||||
*/
|
||||
export async function listObjects(db: Db, company: Company) {
|
||||
const rows = await showcaseLeads(db, company.id);
|
||||
return rows.map(({ lead: l }) => ({
|
||||
id: l.id,
|
||||
/** «Описание для клиента» из карточки объекта. */
|
||||
text: l.showcaseText ?? renderShowcaseText(l),
|
||||
dealType: l.dealType,
|
||||
propertyType: l.propertyType,
|
||||
district: l.district,
|
||||
price: l.price,
|
||||
currency: l.currency,
|
||||
rooms: l.rooms,
|
||||
area: l.area,
|
||||
floor: l.floor,
|
||||
floorsTotal: l.floorsTotal,
|
||||
/** Сколько фото у объекта; само фото — GET …/objects/:id/photos/:index. */
|
||||
photos: Math.min(l.photos.length, MAX_PHOTOS),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Фото объекта витрины: из объявления — обрезанное, у объекта, заведённого вручную, — как есть. */
|
||||
export async function objectPhoto(db: Db, config: Config, company: Company, leadId: number, index: number, fetcher?: ImageFetcher) {
|
||||
const [row] = await showcaseLeads(db, company.id, leadId);
|
||||
const url = row?.lead.photos[index];
|
||||
if (!url || index >= MAX_PHOTOS) throw notFound();
|
||||
if (!isListAmPhoto(url)) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(20_000) }).catch(() => null);
|
||||
if (!res?.ok) throw notFound();
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
return croppedPhoto(config, url, fetcher).catch(() => {
|
||||
throw notFound();
|
||||
});
|
||||
}
|
||||
|
||||
export const VIEWING_TIMES = { morning: 'утро, 10:00–13:00', day: 'день, 13:00–17:00', evening: 'вечер, 17:00–20:00' } as const;
|
||||
export type ViewingTime = keyof typeof VIEWING_TIMES;
|
||||
|
||||
export interface ViewingRequest {
|
||||
leadId: number;
|
||||
day: string;
|
||||
time: ViewingTime;
|
||||
name: string;
|
||||
phone?: string;
|
||||
telegram?: string;
|
||||
}
|
||||
|
||||
/** «@ivan», «ivan», «t.me/ivan», «https://t.me/ivan» → «@ivan». null — не похоже на ник. */
|
||||
export function normalizeTelegram(raw: string): string | null {
|
||||
const nick = raw.trim().replace(/^(?:https?:\/\/)?(?:t\.me|telegram\.me)\//i, '').replace(/^@/, '').replace(/\/$/, '');
|
||||
return /^[A-Za-z][A-Za-z0-9_]{3,31}$/.test(nick) ? `@${nick}` : null;
|
||||
}
|
||||
|
||||
/** Дата по Еревану (UTC+4). */
|
||||
const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
|
||||
|
||||
/** «пятница, 24 октября». */
|
||||
const humanDay = (day: string) =>
|
||||
new Date(`${day}T12:00:00+04:00`).toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Asia/Yerevan' });
|
||||
|
||||
const SYMBOL: Record<string, string> = { USD: '$', AMD: '֏', RUB: '₽', EUR: '€' };
|
||||
const priceLine = (price: number | null, currency: string | null) => {
|
||||
if (!price) return null;
|
||||
const n = Math.round(price).toLocaleString('ru-RU').replace(/[ ]/g, ' ');
|
||||
const c = currency ?? 'AMD';
|
||||
return c === 'AMD' || c === 'RUB' ? `${n} ${SYMBOL[c]}` : `${SYMBOL[c] ?? c}${n}`;
|
||||
};
|
||||
|
||||
/** Адрес кабинета компании: свой домен, если задан, иначе адрес сервиса. */
|
||||
const cabinetUrl = (config: Config, company: Company) =>
|
||||
company.settings.clientHost ? `https://${company.settings.clientHost}` : config.appUrl;
|
||||
|
||||
/** Сообщение в топик заявок группы. */
|
||||
export function requestText(input: { client: { id: number; name: string; phone: string | null; telegram: string | null }; lead: Lead; day: string; time: ViewingTime; agentName: string | null; url: string; repeat: boolean }) {
|
||||
const { client, lead, day, time, agentName, url, repeat } = input;
|
||||
const object = [lead.title, priceLine(lead.price, lead.currency), lead.district].filter(Boolean).join(' · ');
|
||||
return [
|
||||
`🆕 <b>Клиент из BarevClub${repeat ? ' (повторная заявка)' : ''}</b> — просьба позвонить`,
|
||||
'',
|
||||
`👤 ${esc(client.name)}`,
|
||||
client.phone ? `📞 ${esc(client.phone)}` : null,
|
||||
client.telegram ? `✈️ ${esc(client.telegram)}` : null,
|
||||
`🏠 ${esc(object)}`,
|
||||
`📅 Просмотр: <b>${esc(humanDay(day))}, ${VIEWING_TIMES[time]}</b>`,
|
||||
`🧑💼 Объект ведёт: <b>${esc(agentName ?? '—')}</b>`,
|
||||
`🔗 ${esc(url)}`,
|
||||
]
|
||||
.filter((line) => line !== null)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Заявка с сайта: клиент (новый или уже приходивший с тем же телефоном или Telegram), объект в его
|
||||
* подборке, запись на показ и уведомление в топик заявок группы. Не дошло до группы — агенту объекта
|
||||
* в бот уходит обычное уведомление о записи на показ.
|
||||
*/
|
||||
export async function createViewingRequest(
|
||||
{ db, config, sender = telegramGroupSender, now = new Date() }: { db: Db; config: Config; sender?: GroupSender; now?: Date },
|
||||
company: Company,
|
||||
input: ViewingRequest,
|
||||
) {
|
||||
const phone = input.phone?.trim() ? (normalizePhone(input.phone) ?? input.phone.trim()) : null;
|
||||
if (phone && phone.replace(/\D/g, '').length < 6) throw badRequest('BAD_PHONE');
|
||||
const telegram = input.telegram?.trim() ? normalizeTelegram(input.telegram) : null;
|
||||
if (input.telegram?.trim() && !telegram) throw badRequest('BAD_TELEGRAM');
|
||||
if (!phone && !telegram) throw badRequest('CONTACT_REQUIRED');
|
||||
const today = ymd(now);
|
||||
const last = ymd(new Date(now.getTime() + 14 * 86_400_000));
|
||||
if (input.day < today || input.day > last) throw badRequest('BAD_DAY');
|
||||
const name = input.name.trim().slice(0, 80);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [row] = await showcaseLeads(tx, company.id, input.leadId);
|
||||
if (!row) throw notFound('OBJECT_NOT_FOUND');
|
||||
const lead = row.lead;
|
||||
const assigneeId = lead.assigneeId!;
|
||||
// Тот же человек уже оставлял заявку на сайте — дописываем к его карточке, а не заводим вторую.
|
||||
const [earlier] = await tx
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.companyId, company.id),
|
||||
eq(clients.source, BAREV_SOURCE),
|
||||
or(phone ? eq(clients.phone, phone) : undefined, telegram ? eq(clients.telegram, telegram) : undefined),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(clients.id))
|
||||
.limit(1);
|
||||
let client = earlier;
|
||||
if (client) {
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ name, phone: phone ?? client.phone, telegram: telegram ?? client.telegram })
|
||||
.where(eq(clients.id, client.id));
|
||||
client = { ...client, name, phone: phone ?? client.phone, telegram: telegram ?? client.telegram };
|
||||
} else {
|
||||
const fresh = await statusByCode(tx, company.id, 'new', 'client');
|
||||
[client] = await tx
|
||||
.insert(clients)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
name,
|
||||
phone,
|
||||
telegram,
|
||||
kind: lead.dealType === 'sale' ? 'buyer' : 'tenant',
|
||||
request: {},
|
||||
source: BAREV_SOURCE,
|
||||
assigneeId,
|
||||
statusId: fresh.id,
|
||||
nextActionAt: now,
|
||||
nextActionNote: 'Позвонить: заявка с сайта BarevClub',
|
||||
})
|
||||
.returning();
|
||||
await tx.insert(events).values({ companyId: company.id, clientId: client!.id, userId: null, type: 'created' });
|
||||
}
|
||||
await tx.insert(clientLeads).values({ clientId: client!.id, leadId: lead.id, state: 'offered' }).onConflictDoNothing();
|
||||
const note = `${VIEWING_TIMES[input.time]} · заявка с сайта BarevClub`;
|
||||
await tx
|
||||
.insert(viewings)
|
||||
.values({ companyId: company.id, clientId: client!.id, leadId: lead.id, day: input.day, note })
|
||||
.onConflictDoUpdate({ target: [viewings.clientId, viewings.leadId], set: { day: input.day, note } });
|
||||
await tx.insert(events).values([
|
||||
{ companyId: company.id, clientId: client!.id, userId: null, type: 'viewing', newValue: lead.title, text: input.day },
|
||||
{ companyId: company.id, leadId: lead.id, userId: null, type: 'client_viewing', newValue: client!.name, text: input.day },
|
||||
]);
|
||||
await tx.update(companies).set({ lastActivityAt: now }).where(eq(companies.id, company.id));
|
||||
const [agent] = await tx.select({ name: users.name }).from(users).where(eq(users.id, assigneeId));
|
||||
return { client: client!, lead, assigneeId, agentName: agent?.name ?? null, repeat: Boolean(earlier) };
|
||||
});
|
||||
|
||||
const text = requestText({
|
||||
client: result.client,
|
||||
lead: result.lead,
|
||||
day: input.day,
|
||||
time: input.time,
|
||||
agentName: result.agentName,
|
||||
url: `${cabinetUrl(config, company)}/clients/${result.client.id}`,
|
||||
repeat: result.repeat,
|
||||
});
|
||||
const bot = company.settings.groupBot;
|
||||
let notified = false;
|
||||
if (bot?.chatId) {
|
||||
try {
|
||||
await sender.text({ token: bot.token, chatId: bot.chatId, threadId: company.settings.barevClub?.threadId }, text);
|
||||
notified = true;
|
||||
} catch (error) {
|
||||
console.error(`[barev] заявка клиента ${result.client.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
if (!notified) {
|
||||
await db.insert(outbox).values({
|
||||
kind: 'text',
|
||||
userId: result.assigneeId,
|
||||
leadId: result.lead.id,
|
||||
payload: { key: 'viewingBooked', args: [result.client.name, result.lead.title, input.day, `${VIEWING_TIMES[input.time]} · BarevClub`] },
|
||||
});
|
||||
}
|
||||
return { ok: true, clientId: result.client.id };
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import type { PaymentProvider } from './billing/provider.ts';
|
||||
import { addDays, addMonths, endOfDay, runBillingJobs, ymd } from './billing/service.ts';
|
||||
import { companies, deals, invoices, payments, users } from './db/schema.ts';
|
||||
import { processOutbox } from './telegram/outbox.ts';
|
||||
import { fakeTelegram, testApp } from './testing.ts';
|
||||
|
||||
// Биллинг, сообщения суперадмина, мастер настройки.
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
let tgId = 5000;
|
||||
|
||||
/** Платёжный провайдер-заглушка: подпись — заголовок x-secret. */
|
||||
const provider: PaymentProvider = {
|
||||
name: 'fakepay',
|
||||
createPayment: async (r) => ({ externalId: `pay-${r.invoiceId}`, url: `https://pay.test/${r.invoiceId}` }),
|
||||
parseWebhook: async (headers, body) => (headers['x-secret'] === 'ok' ? (body as { externalId: string; status: 'succeeded' | 'failed' }) : null),
|
||||
};
|
||||
|
||||
beforeAll(async () => { t = await testApp('billing', null, provider); });
|
||||
afterAll(() => t.close());
|
||||
|
||||
const tg = fakeTelegram();
|
||||
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
|
||||
|
||||
const superadmin = async () => {
|
||||
const [u] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
|
||||
.returning();
|
||||
return t.client(await createSession(t.db, u!.id));
|
||||
};
|
||||
|
||||
const join = async (name: string, token: string) => {
|
||||
const api = await t.register(name);
|
||||
await api.post(`/api/invites/${token}/accept`);
|
||||
const id = (await api.get('/api/me')).body.user.id as number;
|
||||
const chat = ++tgId;
|
||||
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
|
||||
return { api, id, chat };
|
||||
};
|
||||
|
||||
/** Компания, включённая суперадмином; подписка заканчивается через `days` дней. */
|
||||
const company = async (plan: 'solo' | 'team' | 'agency' = 'team', billingModel: 'fixed' | 'percent' = 'fixed', days = 30) => {
|
||||
const sa = await superadmin();
|
||||
const created = await sa.post('/api/admin/companies', {
|
||||
name: `Компания ${plan}`, plan, billingModel, subscriptionEndsAt: endOfDay(addDays(ymd(new Date()), days)).toISOString(),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
|
||||
return { sa, boss, id: created.body.id as number };
|
||||
};
|
||||
|
||||
const row = async (id: number) => (await t.db.select().from(companies).where(eq(companies.id, id)))[0]!;
|
||||
const invoicesOf = async (id: number) => t.db.select().from(invoices).where(eq(invoices.companyId, id)).orderBy(invoices.id);
|
||||
/** Полдень по Еревану: напоминания уходят только днём. */
|
||||
const noon = (day: string) => new Date(`${day}T12:00:00+04:00`);
|
||||
const today = () => ymd(new Date());
|
||||
|
||||
describe('даты', () => {
|
||||
it('месяц прибавляется с учётом короткого февраля', () => {
|
||||
expect(addMonths('2027-01-31', 1)).toBe('2027-02-28');
|
||||
expect(addMonths('2026-12-15', 1)).toBe('2027-01-15');
|
||||
expect(addDays('2026-12-31', 1)).toBe('2027-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('фикс', () => {
|
||||
it('самостоятельное подключение: счета за установку и первый месяц, доступ открывается после оплаты', async () => {
|
||||
const u = await t.register('Сам');
|
||||
await u.post('/api/company', { name: 'Сама', plan: 'team', billingModel: 'fixed' });
|
||||
const me = (await u.get('/api/me')).body;
|
||||
const sa = await superadmin();
|
||||
await t.db.update(users).set({ telegramId: ++tgId }).where(eq(users.id, me.user.id));
|
||||
|
||||
const bill = (await u.get('/api/billing')).body;
|
||||
expect(bill.access).toBe('disabled');
|
||||
expect(bill.invoices.map((i: any) => [i.kind, i.amount, i.status])).toEqual([['fixed', 350, 'pending'], ['setup', 500, 'pending']]);
|
||||
|
||||
const fixed = bill.invoices.find((i: any) => i.kind === 'fixed');
|
||||
const paid = await sa.post(`/api/admin/invoices/${fixed.id}/pay`);
|
||||
expect(paid.body).toMatchObject({ status: 'paid', paidMethod: 'manual', periodStart: today(), periodEnd: addMonths(today(), 1) });
|
||||
// Повторная отметка ничего не меняет.
|
||||
await sa.post(`/api/admin/invoices/${fixed.id}/pay`);
|
||||
expect(await t.db.select().from(payments).where(eq(payments.invoiceId, fixed.id))).toHaveLength(1);
|
||||
|
||||
const after = (await u.get('/api/me')).body.company;
|
||||
expect(after.access).toBe('active');
|
||||
expect(ymd(new Date(after.subscriptionEndsAt))).toBe(addMonths(today(), 1));
|
||||
await flush();
|
||||
expect(tg.inbox(tgId).at(-1)!.text).toContain('Оплата получена');
|
||||
});
|
||||
|
||||
it('счёт на следующий месяц и напоминания за 7, 3 и 1 день; оплата продлевает на месяц', async () => {
|
||||
const c = await company('team', 'fixed', 30);
|
||||
const end = addDays(today(), 30);
|
||||
// Компания подключена вручную: начислена только установка.
|
||||
expect((await invoicesOf(c.id)).map((i) => i.kind)).toEqual(['setup']);
|
||||
|
||||
await runBillingJobs(t.db, noon(addDays(end, -8)));
|
||||
expect(await invoicesOf(c.id)).toHaveLength(1);
|
||||
|
||||
await runBillingJobs(t.db, noon(addDays(end, -7)));
|
||||
await runBillingJobs(t.db, noon(addDays(end, -7)));
|
||||
const list = await invoicesOf(c.id);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[1]).toMatchObject({ kind: 'fixed', amount: 350, status: 'pending', periodStart: addDays(end, 1), periodEnd: addMonths(end, 1) });
|
||||
await flush();
|
||||
const got = () => tg.inbox(c.boss.chat).map((m) => m.text);
|
||||
expect(got().filter((x) => x.includes('осталось 7 дн'))).toHaveLength(1);
|
||||
expect(got().filter((x) => x.includes('Выставлен счёт на $350'))).toHaveLength(1);
|
||||
|
||||
// Ночью напоминание не уходит.
|
||||
await runBillingJobs(t.db, new Date(`${addDays(end, -3)}T03:00:00+04:00`));
|
||||
await flush();
|
||||
expect(got().some((x) => x.includes('осталось 3 дн'))).toBe(false);
|
||||
await runBillingJobs(t.db, noon(addDays(end, -3)));
|
||||
await runBillingJobs(t.db, noon(addDays(end, -1)));
|
||||
await flush();
|
||||
expect(got().filter((x) => x.includes('осталось 3 дн') || x.includes('осталось 1 дн'))).toHaveLength(2);
|
||||
|
||||
await c.sa.post(`/api/admin/invoices/${list[1]!.id}/pay`);
|
||||
expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(addMonths(end, 1));
|
||||
// Следующий счёт появится только за 7 дней до нового окончания.
|
||||
await runBillingJobs(t.db, noon(end));
|
||||
expect(await invoicesOf(c.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('16-й день просрочки: доступ ограничен, после отметки оплаты возвращается', async () => {
|
||||
const c = await company('team', 'fixed', -16);
|
||||
const end = addDays(today(), -16);
|
||||
expect((await c.boss.api.get('/api/me')).body.company.access).toBe('restricted');
|
||||
expect((await c.boss.api.post('/api/invites')).body.code).toBe('SUBSCRIPTION_REQUIRED');
|
||||
|
||||
await runBillingJobs(t.db, noon(today()));
|
||||
await runBillingJobs(t.db, noon(today()));
|
||||
await flush();
|
||||
expect(tg.inbox(c.boss.chat).filter((m) => m.text.includes('Доступ ограничен'))).toHaveLength(1);
|
||||
|
||||
// Раздел «Подписка» открыт и при ограниченном доступе.
|
||||
const bill = (await c.boss.api.get('/api/billing')).body;
|
||||
const fixed = bill.invoices.find((i: any) => i.kind === 'fixed' && i.status === 'pending');
|
||||
expect(fixed).toMatchObject({ amount: 350, periodStart: addDays(end, 1) });
|
||||
await c.sa.post(`/api/admin/invoices/${fixed.id}/pay`);
|
||||
expect((await c.boss.api.get('/api/me')).body.company.access).toBe('active');
|
||||
expect((await c.boss.api.post('/api/invites')).status).toBe(200);
|
||||
});
|
||||
|
||||
it('просрочка: ежедневное напоминание с числом оставшихся дней', async () => {
|
||||
const c = await company('solo', 'fixed', -2);
|
||||
await runBillingJobs(t.db, noon(today()));
|
||||
await runBillingJobs(t.db, noon(today()));
|
||||
await runBillingJobs(t.db, noon(addDays(today(), 1)));
|
||||
await flush();
|
||||
const texts = tg.inbox(c.boss.chat).map((m) => m.text).filter((x) => x.includes('просрочена'));
|
||||
expect(texts).toHaveLength(2);
|
||||
expect(texts[0]).toContain('ещё 13 дн');
|
||||
expect(texts[1]).toContain('ещё 12 дн');
|
||||
});
|
||||
});
|
||||
|
||||
describe('модель «%»', () => {
|
||||
const lastMonthEnd = () => addDays(`${today().slice(0, 7)}-01`, -1);
|
||||
|
||||
it('счёт 1-го числа совпадает с ручным расчётом; оплата продлевает до 8-го числа следующего месяца', async () => {
|
||||
const c = await company('agency', 'percent', 5);
|
||||
const to = lastMonthEnd();
|
||||
const from = `${to.slice(0, 7)}-01`;
|
||||
await t.db.update(companies).set({ percentFrom: addDays(from, 2) }).where(eq(companies.id, c.id));
|
||||
const deal = (commission: number, currency: string, dealDate: string) =>
|
||||
t.db.insert(deals).values({ companyId: c.id, agentId: c.boss.id, ownerAgentId: c.boss.id, clientAgentId: c.boss.id, commission, ownerCommission: commission, currency, dealDate });
|
||||
await deal(3000, 'USD', addDays(from, 5));
|
||||
await deal(385_000, 'AMD', to);
|
||||
// Не попадают: сделка до перехода на «%» и сделка текущего месяца.
|
||||
await deal(9000, 'USD', from);
|
||||
await deal(7000, 'USD', today());
|
||||
|
||||
const first = noon(`${today().slice(0, 7)}-01`);
|
||||
await runBillingJobs(t.db, first);
|
||||
await runBillingJobs(t.db, first);
|
||||
const list = (await invoicesOf(c.id)).filter((i) => i.kind === 'percent');
|
||||
expect(list).toHaveLength(1);
|
||||
// Вручную: (3000 + 385000 / 385) × 7 % = 4000 × 0,07 = 280.
|
||||
expect(list[0]).toMatchObject({ amount: 280, status: 'pending', periodStart: from, periodEnd: to });
|
||||
expect(list[0]!.details).toMatchObject({ percent: 7, total: 4000, deals: [{ commission: 3000, usd: 3000 }, { commission: 385_000, currency: 'AMD', usd: 1000 }] });
|
||||
|
||||
// Клиент видит расшифровку и то, что набежало за текущий месяц.
|
||||
const bill = (await c.boss.api.get('/api/billing')).body;
|
||||
expect(bill.invoices.find((i: any) => i.kind === 'percent').details.deals).toHaveLength(2);
|
||||
expect(bill.preview).toMatchObject({ total: 7000, amount: 490 });
|
||||
|
||||
await c.sa.post(`/api/admin/invoices/${list[0]!.id}/pay`);
|
||||
expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(`${addMonths(`${today().slice(0, 7)}-01`, 1).slice(0, 7)}-08`);
|
||||
});
|
||||
|
||||
it('месяц без сделок: нулевой счёт закрывается сам и продлевает подписку', async () => {
|
||||
const c = await company('team', 'percent', 5);
|
||||
await t.db.update(companies).set({ percentFrom: '2020-01-01' }).where(eq(companies.id, c.id));
|
||||
await runBillingJobs(t.db, noon(`${today().slice(0, 7)}-01`));
|
||||
const [inv] = (await invoicesOf(c.id)).filter((i) => i.kind === 'percent');
|
||||
expect(inv).toMatchObject({ amount: 0, status: 'paid', paidMethod: 'auto' });
|
||||
expect(ymd((await row(c.id)).subscriptionEndsAt!).slice(8)).toBe('08');
|
||||
});
|
||||
|
||||
it('суперадмин меняет курсы валют для счёта', async () => {
|
||||
const sa = await superadmin();
|
||||
expect((await sa.get('/api/admin/billing/settings')).body.rates.AMD).toBe(385);
|
||||
expect((await sa.patch('/api/admin/billing/settings', { rates: { AMD: 400, RUB: 90, EUR: 0.9 } })).status).toBe(200);
|
||||
expect((await sa.get('/api/admin/billing/settings')).body.rates).toEqual({ AMD: 400, RUB: 90, EUR: 0.9 });
|
||||
await sa.patch('/api/admin/billing/settings', { rates: { AMD: 385, RUB: 95, EUR: 0.92 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('смена тарифа', () => {
|
||||
it('из кабинета применяется только со следующего месяца', async () => {
|
||||
const c = await company('team', 'fixed', 20);
|
||||
const end = addDays(today(), 20);
|
||||
const agentInvite = (await c.boss.api.post('/api/invites')).body.token as string;
|
||||
const agent = await join('Агент', agentInvite);
|
||||
|
||||
expect((await agent.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' })).status).toBe(403);
|
||||
expect((await c.boss.api.post('/api/billing/plan', { plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED');
|
||||
// В компании двое — Solo не вмещает.
|
||||
expect((await c.boss.api.post('/api/billing/plan', { plan: 'solo', billingModel: 'fixed' })).body.code).toBe('PLAN_TOO_SMALL');
|
||||
|
||||
const res = await c.boss.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' });
|
||||
expect(res.body.from).toBe(addDays(end, 1));
|
||||
const me = (await c.boss.api.get('/api/me')).body.company;
|
||||
expect(me).toMatchObject({ plan: 'team', maxMembers: 10, planned: { plan: 'agency', billingModel: 'fixed', from: addDays(end, 1) } });
|
||||
|
||||
// Счёт на следующий месяц уже по новому тарифу; сам тариф пока прежний.
|
||||
await runBillingJobs(t.db, noon(addDays(end, -7)));
|
||||
const next = (await invoicesOf(c.id)).find((i) => i.kind === 'fixed')!;
|
||||
expect(next).toMatchObject({ amount: 550, periodStart: addDays(end, 1) });
|
||||
expect((await row(c.id)).plan).toBe('team');
|
||||
|
||||
// Передумали: счёт пересоздаётся по старому тарифу.
|
||||
await c.boss.api.del('/api/billing/plan');
|
||||
expect((await c.boss.api.get('/api/me')).body.company.planned).toBeNull();
|
||||
await c.boss.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' });
|
||||
await runBillingJobs(t.db, noon(addDays(end, -7)));
|
||||
const pending = (await invoicesOf(c.id)).filter((i) => i.kind === 'fixed' && i.status === 'pending');
|
||||
expect(pending.map((i) => i.amount)).toEqual([550]);
|
||||
|
||||
await runBillingJobs(t.db, noon(addDays(end, 1)));
|
||||
const after = await row(c.id);
|
||||
expect(after).toMatchObject({ plan: 'agency', billingModel: 'fixed', plannedPlan: null, plannedFrom: null });
|
||||
// Установка начислена один раз — при подключении Team.
|
||||
expect((await invoicesOf(c.id)).filter((i) => i.kind === 'setup')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('переход с фикса на «%»: сделки до перехода в счёт не попадают', async () => {
|
||||
const c = await company('team', 'fixed', 3);
|
||||
const from = addDays(today(), 4);
|
||||
expect((await c.boss.api.post('/api/billing/plan', { plan: 'team', billingModel: 'percent' })).body.from).toBe(from);
|
||||
await runBillingJobs(t.db, noon(from));
|
||||
const after = await row(c.id);
|
||||
expect(after).toMatchObject({ billingModel: 'percent', percentFrom: from });
|
||||
expect(ymd(after.subscriptionEndsAt!)).toBe(`${addMonths(from, 1).slice(0, 7)}-08`);
|
||||
// Неоплаченный счёт на фикс за следующий месяц отменён.
|
||||
expect((await invoicesOf(c.id)).filter((i) => i.kind === 'fixed' && i.status === 'pending')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('подписка ещё не начиналась: тариф меняется сразу, счета пересчитываются', async () => {
|
||||
const u = await t.register('Новый');
|
||||
await u.post('/api/company', { name: 'Новая', plan: 'solo', billingModel: 'fixed' });
|
||||
const id = (await u.get('/api/me')).body.company.id as number;
|
||||
expect((await invoicesOf(id)).map((i) => [i.kind, i.amount])).toEqual([['fixed', 40]]);
|
||||
expect((await u.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' })).body.from).toBeNull();
|
||||
expect((await u.get('/api/me')).body.company.plan).toBe('agency');
|
||||
const open = (await invoicesOf(id)).filter((i) => i.status === 'pending').map((i) => [i.kind, i.amount]);
|
||||
expect(open).toEqual([['setup', 750], ['fixed', 550]]);
|
||||
});
|
||||
|
||||
it('уход с «%» посреди месяца: сделки с начала месяца закрываются отдельным счётом', async () => {
|
||||
const c = await company('team', 'percent', -1);
|
||||
const first = `${today().slice(0, 7)}-01`;
|
||||
if (today() === first) return;
|
||||
await t.db.update(companies).set({ percentFrom: '2020-01-01' }).where(eq(companies.id, c.id));
|
||||
await t.db.insert(deals).values({ companyId: c.id, agentId: c.boss.id, ownerAgentId: c.boss.id, clientAgentId: c.boss.id, commission: 2000, ownerCommission: 2000, currency: 'USD', dealDate: first });
|
||||
// Подписка закончилась — тариф меняется сразу.
|
||||
expect((await c.boss.api.post('/api/billing/plan', { plan: 'team', billingModel: 'fixed' })).body.from).toBeNull();
|
||||
const list = await invoicesOf(c.id);
|
||||
expect(list.find((i) => i.kind === 'percent')).toMatchObject({ amount: 200, periodStart: first, periodEnd: addDays(today(), -1), status: 'pending' });
|
||||
expect(list.find((i) => i.kind === 'fixed')).toMatchObject({ amount: 350, status: 'pending' });
|
||||
});
|
||||
|
||||
it('суперадмин меняет тариф сразу', async () => {
|
||||
const c = await company('team', 'fixed', 20);
|
||||
expect((await c.sa.patch(`/api/admin/companies/${c.id}`, { plan: 'agency', billingModel: 'percent' })).status).toBe(200);
|
||||
expect(await row(c.id)).toMatchObject({ plan: 'agency', billingModel: 'percent', percentFrom: today() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('онлайн-оплата через провайдера', () => {
|
||||
it('ссылка на оплату, webhook закрывает счёт; неверная подпись отклоняется', async () => {
|
||||
const u = await t.register('Онлайн');
|
||||
await u.post('/api/company', { name: 'Онлайн', plan: 'solo', billingModel: 'fixed' });
|
||||
const id = (await u.get('/api/me')).body.company.id as number;
|
||||
const [inv] = await invoicesOf(id);
|
||||
expect((await t.client().get('/api/config')).body.onlinePayments).toBe(true);
|
||||
expect((await u.post(`/api/invoices/${inv!.id}/pay`)).body.url).toBe(`https://pay.test/${inv!.id}`);
|
||||
|
||||
const hook = (secret: string, status = 'succeeded') =>
|
||||
t.app.inject({ method: 'POST', url: '/api/payments/webhook/fakepay', headers: { 'x-secret': secret }, payload: { externalId: `pay-${inv!.id}`, status } });
|
||||
expect((await hook('wrong')).statusCode).toBe(400);
|
||||
expect((await t.app.inject({ method: 'POST', url: '/api/payments/webhook/other', payload: {} })).statusCode).toBe(404);
|
||||
expect((await hook('ok')).statusCode).toBe(200);
|
||||
expect((await hook('ok')).statusCode).toBe(200);
|
||||
|
||||
expect((await invoicesOf(id))[0]).toMatchObject({ status: 'paid', paidMethod: 'online' });
|
||||
expect(await t.db.select().from(payments).where(eq(payments.invoiceId, inv!.id))).toMatchObject([{ provider: 'fakepay', status: 'succeeded' }]);
|
||||
expect((await u.get('/api/me')).body.company.access).toBe('active');
|
||||
// Чужой счёт оплатить нельзя.
|
||||
const other = await company('solo');
|
||||
expect((await other.boss.api.post(`/api/invoices/${inv!.id}/pay`)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('суперадмин', () => {
|
||||
it('счета: список, отмена; сводка считает выручку месяца', async () => {
|
||||
const c = await company('agency', 'fixed', 30);
|
||||
const before = (await c.sa.get('/api/admin/summary')).body;
|
||||
const [setup] = await invoicesOf(c.id);
|
||||
const list = (await c.sa.get(`/api/admin/invoices?status=pending&companyId=${c.id}`)).body;
|
||||
expect(list).toMatchObject([{ id: setup!.id, kind: 'setup', amount: 750, company: 'Компания agency' }]);
|
||||
expect((await c.boss.api.get('/api/admin/invoices')).status).toBe(403);
|
||||
expect((await c.boss.api.post(`/api/admin/invoices/${setup!.id}/pay`)).status).toBe(403);
|
||||
|
||||
await c.sa.post(`/api/admin/invoices/${setup!.id}/pay`);
|
||||
const after = (await c.sa.get('/api/admin/summary')).body;
|
||||
expect(after.revenueMonth - before.revenueMonth).toBe(750);
|
||||
expect(after.pendingAmount - before.pendingAmount).toBe(-750);
|
||||
expect(after.active).toBeGreaterThan(0);
|
||||
// Оплата установки дату подписки не двигает.
|
||||
expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(addDays(today(), 30));
|
||||
expect((await c.sa.post(`/api/admin/invoices/${setup!.id}/cancel`)).body.code).toBe('INVOICE_PAID');
|
||||
|
||||
const d = await company('team');
|
||||
const [s2] = await invoicesOf(d.id);
|
||||
expect((await d.sa.post(`/api/admin/invoices/${s2!.id}/cancel`)).status).toBe(200);
|
||||
expect((await d.sa.post(`/api/admin/invoices/${s2!.id}/pay`)).body.code).toBe('INVOICE_CANCELED');
|
||||
});
|
||||
|
||||
it('сообщения в Telegram: всем, компании, руководителям, одному пользователю; журнал', async () => {
|
||||
const a = await company('team');
|
||||
const b = await company('team');
|
||||
const agent = await join('Агент', (await a.boss.api.post('/api/invites')).body.token);
|
||||
// Пользователь без Telegram сообщение не получит — это видно в журнале.
|
||||
const silent = await join('Без телеграма', (await a.boss.api.post('/api/invites')).body.token);
|
||||
await t.db.update(users).set({ telegramId: null }).where(eq(users.id, silent.id));
|
||||
await flush();
|
||||
const count = (text: string, chat: number) => tg.inbox(chat).filter((m) => m.text === text).length;
|
||||
|
||||
expect((await a.boss.api.post('/api/admin/messages', { text: 'x', audience: 'all' })).status).toBe(403);
|
||||
expect((await a.sa.post('/api/admin/messages', { text: 'x', audience: 'company' })).body.code).toBe('VALIDATION');
|
||||
expect((await a.sa.post('/api/admin/messages', { text: 'x', audience: 'user', userId: 999_999 })).body.code).toBe('NO_RECIPIENTS');
|
||||
|
||||
const toCompany = await a.sa.post('/api/admin/messages', { text: 'Компании <b>A</b>', audience: 'company', companyId: a.id });
|
||||
expect(toCompany.body).toMatchObject({ recipients: 2, unreachable: 1 });
|
||||
await a.sa.post('/api/admin/messages', { text: 'Руководителям', audience: 'managers' });
|
||||
await a.sa.post('/api/admin/messages', { text: 'Лично', audience: 'user', userId: agent.id });
|
||||
await a.sa.post('/api/admin/messages', { text: 'Всем', audience: 'all' });
|
||||
await flush();
|
||||
|
||||
// Разметка в тексте суперадмина экранируется.
|
||||
expect(count('Компании <b>A</b>', a.boss.chat)).toBe(1);
|
||||
expect(count('Компании <b>A</b>', agent.chat)).toBe(1);
|
||||
expect(count('Компании <b>A</b>', b.boss.chat)).toBe(0);
|
||||
expect([count('Руководителям', a.boss.chat), count('Руководителям', b.boss.chat), count('Руководителям', agent.chat)]).toEqual([1, 1, 0]);
|
||||
expect([count('Лично', agent.chat), count('Лично', a.boss.chat)]).toEqual([1, 0]);
|
||||
expect([count('Всем', a.boss.chat), count('Всем', b.boss.chat), count('Всем', agent.chat)]).toEqual([1, 1, 1]);
|
||||
|
||||
const journal = (await a.sa.get('/api/admin/messages')).body;
|
||||
expect(journal.slice(0, 4).map((m: any) => m.audience)).toEqual(['all', 'user', 'managers', 'company']);
|
||||
expect(journal[1]).toMatchObject({ text: 'Лично', user: 'Агент', recipients: 1 });
|
||||
expect(journal[3]).toMatchObject({ company: 'Компания team', recipients: 2, unreachable: 1 });
|
||||
|
||||
const found = (await a.sa.get('/api/admin/users?q=Без')).body;
|
||||
expect(found).toMatchObject([{ id: silent.id, telegramLinked: false, company: 'Компания team' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('мастер настройки', () => {
|
||||
it('показывается один раз: после отметки больше не нужен', async () => {
|
||||
const c = await company('team');
|
||||
expect((await c.boss.api.get('/api/me')).body.user.onboarded).toBe(false);
|
||||
expect((await c.boss.api.patch('/api/me', { onboarded: true })).status).toBe(200);
|
||||
expect((await c.boss.api.get('/api/me')).body.user.onboarded).toBe(true);
|
||||
// Правка профиля отметку не сбрасывает.
|
||||
await c.boss.api.patch('/api/me', { name: 'Новое имя' });
|
||||
const [u] = await t.db.select().from(users).where(and(eq(users.id, c.boss.id)));
|
||||
expect(u!.onboardedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Платёжный модуль (ТЗ, раздел 7): провайдер эквайринга ещё не выбран, поэтому биллинг знает
|
||||
// только этот контракт. Чтобы подключить провайдера, нужно реализовать интерфейс и передать
|
||||
// его в buildApp({ payments }) — счета, продление подписки и кабинет менять не придётся.
|
||||
|
||||
export interface PaymentRequest {
|
||||
invoiceId: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
/** Куда вернуть пользователя после оплаты. */
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
export interface PaymentEvent {
|
||||
/** Идентификатор платежа у провайдера — тот, что вернул createPayment. */
|
||||
externalId: string;
|
||||
status: 'succeeded' | 'failed';
|
||||
}
|
||||
|
||||
export interface PaymentProvider {
|
||||
/** Короткое имя: попадает в адрес webhook (/api/payments/webhook/<name>) и в историю платежей. */
|
||||
name: string;
|
||||
/** Создаёт платёж у провайдера и возвращает ссылку на страницу оплаты. */
|
||||
createPayment(request: PaymentRequest): Promise<{ externalId: string; url: string }>;
|
||||
/** Проверяет подпись уведомления и разбирает его. null — уведомление не о платеже или подпись неверна. */
|
||||
parseWebhook(headers: Record<string, string | string[] | undefined>, body: unknown): Promise<PaymentEvent | null>;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { accessState, BILLING_MODELS, PLANS, type AccessState } from '@lr/shared';
|
||||
import { and, desc, eq, ilike, isNotNull, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { adminMessages, companies, invoices, payments, users } from '../db/schema.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { badRequest, notFound } from '../lib/errors.ts';
|
||||
import { enqueueRaw } from '../telegram/outbox.ts';
|
||||
import type { PaymentProvider } from './provider.ts';
|
||||
import {
|
||||
addDays,
|
||||
BillingSettings,
|
||||
cancelInvoice,
|
||||
loadBillingSettings,
|
||||
nextPeriodStart,
|
||||
percentCalc,
|
||||
saveBillingSettings,
|
||||
schedulePlan,
|
||||
settleInvoice,
|
||||
ymd,
|
||||
type Invoice,
|
||||
} from './service.ts';
|
||||
|
||||
type Company = typeof companies.$inferSelect;
|
||||
type AccountCtx = { user: typeof users.$inferSelect; company: Company; access: AccessState };
|
||||
|
||||
export interface BillingGuards {
|
||||
/** Руководитель компании. Работает и при ограниченном доступе, и вне Mini App: иначе подписку не оплатить. */
|
||||
owner: (req: FastifyRequest) => AccountCtx;
|
||||
superadmin: (req: FastifyRequest) => unknown;
|
||||
}
|
||||
|
||||
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
const invoiceView = (i: Invoice) => ({
|
||||
id: i.id,
|
||||
kind: i.kind,
|
||||
periodStart: i.periodStart,
|
||||
periodEnd: i.periodEnd,
|
||||
amount: i.amount,
|
||||
currency: i.currency,
|
||||
status: i.status,
|
||||
paidAt: i.paidAt,
|
||||
paidMethod: i.paidMethod,
|
||||
details: i.details,
|
||||
createdAt: i.createdAt,
|
||||
});
|
||||
|
||||
export function registerBillingRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
g: BillingGuards,
|
||||
config: Config,
|
||||
provider: PaymentProvider | null,
|
||||
) {
|
||||
// --- кабинет: подписка, счета, смена тарифа ---
|
||||
|
||||
app.get('/api/billing', async (req) => {
|
||||
const { company } = g.owner(req);
|
||||
const rows = await db.select().from(invoices).where(eq(invoices.companyId, company.id)).orderBy(desc(invoices.id));
|
||||
const today = ymd(new Date());
|
||||
// На модели «%» видно, сколько набежало за текущий месяц.
|
||||
const preview =
|
||||
company.billingModel === 'percent'
|
||||
? await percentCalc(db, company, `${today.slice(0, 7)}-01`, today)
|
||||
: null;
|
||||
return {
|
||||
plan: company.plan,
|
||||
billingModel: company.billingModel,
|
||||
access: accessState(company),
|
||||
subscriptionEndsAt: company.subscriptionEndsAt,
|
||||
graceDays: company.graceDays,
|
||||
planned:
|
||||
company.plannedPlan && company.plannedFrom
|
||||
? { plan: company.plannedPlan, billingModel: company.plannedBillingModel, from: company.plannedFrom }
|
||||
: null,
|
||||
/** С какой даты вступит в силу смена тарифа; null — сразу. */
|
||||
changeFrom: nextPeriodStart(company),
|
||||
onlinePayments: Boolean(provider),
|
||||
invoices: rows.map(invoiceView),
|
||||
preview: preview && { from: `${today.slice(0, 7)}-01`, to: today, amount: preview.amount, ...preview.details },
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/billing/plan', async (req) => {
|
||||
const { company } = g.owner(req);
|
||||
const body = z.object({ plan: z.enum(PLANS), billingModel: z.enum(BILLING_MODELS) }).parse(req.body);
|
||||
return schedulePlan(db, company.id, body.plan, body.billingModel);
|
||||
});
|
||||
|
||||
/** Отменить запланированную смену тарифа. */
|
||||
app.delete('/api/billing/plan', async (req) => {
|
||||
const { company } = g.owner(req);
|
||||
return schedulePlan(db, company.id, company.plan as (typeof PLANS)[number], company.billingModel as (typeof BILLING_MODELS)[number]);
|
||||
});
|
||||
|
||||
/** Онлайн-оплата счёта: возвращает ссылку на страницу провайдера. */
|
||||
app.post('/api/invoices/:id/pay', async (req) => {
|
||||
const { company } = g.owner(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
if (!provider) throw badRequest('PAYMENTS_NOT_CONFIGURED');
|
||||
const [invoice] = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.companyId, company.id)));
|
||||
if (!invoice) throw notFound();
|
||||
if (invoice.status !== 'pending') throw badRequest('INVOICE_NOT_PENDING');
|
||||
const created = await provider.createPayment({
|
||||
invoiceId: invoice.id,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
description: `Y&P Lead Radar #${invoice.id}`,
|
||||
returnUrl: `${config.appUrl}/billing`,
|
||||
});
|
||||
await db.insert(payments).values({
|
||||
invoiceId: invoice.id,
|
||||
provider: provider.name,
|
||||
externalId: created.externalId,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
status: 'pending',
|
||||
});
|
||||
return { url: created.url };
|
||||
});
|
||||
|
||||
/** Уведомление провайдера о платеже. Подпись проверяет сам провайдер. */
|
||||
app.post('/api/payments/webhook/:provider', async (req) => {
|
||||
const { provider: name } = z.object({ provider: z.string() }).parse(req.params);
|
||||
if (!provider || provider.name !== name) throw notFound();
|
||||
const event = await provider.parseWebhook(req.headers, req.body);
|
||||
if (!event) throw badRequest('BAD_WEBHOOK');
|
||||
await db.transaction(async (tx) => {
|
||||
const [payment] = await tx
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(and(eq(payments.provider, name), eq(payments.externalId, event.externalId)))
|
||||
.for('update');
|
||||
if (!payment || payment.status !== 'pending') return;
|
||||
if (event.status === 'failed') {
|
||||
await tx.update(payments).set({ status: 'failed' }).where(eq(payments.id, payment.id));
|
||||
return;
|
||||
}
|
||||
// Строка платежа уже есть — отмечаем её, а не создаём вторую.
|
||||
await tx.update(payments).set({ status: 'succeeded' }).where(eq(payments.id, payment.id));
|
||||
await settleInvoice(tx, payment.invoiceId, 'online');
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- суперадмин: счета, сводка, курсы ---
|
||||
|
||||
app.get('/api/admin/invoices', async (req) => {
|
||||
g.superadmin(req);
|
||||
const q = z
|
||||
.object({ status: z.enum(['pending', 'paid', 'canceled']).optional(), companyId: z.coerce.number().int().optional() })
|
||||
.parse(req.query);
|
||||
const where: SQL[] = [];
|
||||
if (q.status) where.push(eq(invoices.status, q.status));
|
||||
if (q.companyId) where.push(eq(invoices.companyId, q.companyId));
|
||||
const rows = await db
|
||||
.select({ invoice: invoices, company: companies.name })
|
||||
.from(invoices)
|
||||
.innerJoin(companies, eq(companies.id, invoices.companyId))
|
||||
.where(and(...where))
|
||||
.orderBy(desc(invoices.id))
|
||||
.limit(200);
|
||||
return rows.map((r) => ({ ...invoiceView(r.invoice), companyId: r.invoice.companyId, company: r.company }));
|
||||
});
|
||||
|
||||
/** Ручной режим: суперадмин отмечает счёт оплаченным, подписка продлевается. */
|
||||
app.post('/api/admin/invoices/:id/pay', async (req) => {
|
||||
g.superadmin(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
return invoiceView(await db.transaction((tx) => settleInvoice(tx, id, 'manual')));
|
||||
});
|
||||
|
||||
app.post('/api/admin/invoices/:id/cancel', async (req) => {
|
||||
g.superadmin(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
await db.transaction((tx) => cancelInvoice(tx, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/admin/summary', async (req) => {
|
||||
g.superadmin(req);
|
||||
const all = await db.select().from(companies);
|
||||
const states = all.map((c) => accessState(c));
|
||||
const month = `${ymd(new Date()).slice(0, 7)}-01`;
|
||||
const next = addDays(month, 32).slice(0, 7) + '-01';
|
||||
const [money] = await db
|
||||
.select({
|
||||
revenue: sql<number>`coalesce(sum(${invoices.amount}) filter (where ${invoices.status} = 'paid' and ${invoices.paidAt} >= ${`${month}T00:00:00+04:00`}::timestamptz and ${invoices.paidAt} < ${`${next}T00:00:00+04:00`}::timestamptz), 0)::float`,
|
||||
pending: sql<number>`coalesce(sum(${invoices.amount}) filter (where ${invoices.status} = 'pending'), 0)::float`,
|
||||
pendingCount: sql<number>`(count(*) filter (where ${invoices.status} = 'pending'))::int`,
|
||||
})
|
||||
.from(invoices);
|
||||
return {
|
||||
companies: all.length,
|
||||
active: states.filter((s) => s === 'active').length,
|
||||
overdue: states.filter((s) => s === 'overdue').length,
|
||||
restricted: states.filter((s) => s === 'restricted').length,
|
||||
disabled: states.filter((s) => s === 'disabled').length,
|
||||
revenueMonth: money?.revenue ?? 0,
|
||||
pendingAmount: money?.pending ?? 0,
|
||||
pendingCount: money?.pendingCount ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/admin/billing/settings', async (req) => {
|
||||
g.superadmin(req);
|
||||
return loadBillingSettings(db);
|
||||
});
|
||||
|
||||
app.patch('/api/admin/billing/settings', async (req) => {
|
||||
g.superadmin(req);
|
||||
const value = BillingSettings.parse(req.body);
|
||||
await saveBillingSettings(db, value);
|
||||
return value;
|
||||
});
|
||||
|
||||
// --- суперадмин: сообщения пользователям в Telegram ---
|
||||
|
||||
/** Поиск пользователя для сообщения «одному пользователю». */
|
||||
app.get('/api/admin/users', async (req) => {
|
||||
g.superadmin(req);
|
||||
const { q } = z.object({ q: z.string().trim().max(100).default('') }).parse(req.query);
|
||||
const like = `%${q}%`;
|
||||
return db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
company: companies.name,
|
||||
telegramLinked: sql<boolean>`${users.telegramId} is not null`,
|
||||
})
|
||||
.from(users)
|
||||
.innerJoin(companies, eq(companies.id, users.companyId))
|
||||
.where(
|
||||
and(
|
||||
isNull(users.removedAt),
|
||||
q ? or(ilike(users.name, like), ilike(users.email, like), ilike(users.telegramUsername, like), ilike(companies.name, like)) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(users.name)
|
||||
.limit(20);
|
||||
});
|
||||
|
||||
app.post('/api/admin/messages', async (req) => {
|
||||
g.superadmin(req);
|
||||
const body = z
|
||||
.object({
|
||||
text: z.string().trim().min(1).max(3500),
|
||||
audience: z.enum(['all', 'company', 'managers', 'user']),
|
||||
companyId: z.number().int().optional(),
|
||||
userId: z.number().int().optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
if (body.audience === 'company' && !body.companyId) throw badRequest('VALIDATION');
|
||||
if (body.audience === 'user' && !body.userId) throw badRequest('VALIDATION');
|
||||
const where: SQL[] = [isNull(users.removedAt), isNotNull(users.companyId)];
|
||||
if (body.audience === 'company') where.push(eq(users.companyId, body.companyId!));
|
||||
if (body.audience === 'managers') {
|
||||
where.push(eq(users.role, 'manager'));
|
||||
if (body.companyId) where.push(eq(users.companyId, body.companyId));
|
||||
}
|
||||
if (body.audience === 'user') where.push(eq(users.id, body.userId!));
|
||||
return db.transaction(async (tx) => {
|
||||
const targets = await tx.select({ id: users.id, telegramId: users.telegramId }).from(users).where(and(...where));
|
||||
if (!targets.length) throw badRequest('NO_RECIPIENTS');
|
||||
const reachable = targets.filter((u) => u.telegramId);
|
||||
await enqueueRaw(tx, reachable.map((u) => u.id), body.text);
|
||||
const [message] = await tx
|
||||
.insert(adminMessages)
|
||||
.values({
|
||||
text: body.text,
|
||||
audience: body.audience,
|
||||
companyId: body.audience === 'user' ? null : (body.companyId ?? null),
|
||||
userId: body.audience === 'user' ? body.userId! : null,
|
||||
recipients: reachable.length,
|
||||
unreachable: targets.length - reachable.length,
|
||||
})
|
||||
.returning();
|
||||
return { id: message!.id, recipients: reachable.length, unreachable: targets.length - reachable.length };
|
||||
});
|
||||
});
|
||||
|
||||
/** Журнал отправленных сообщений. */
|
||||
app.get('/api/admin/messages', async (req) => {
|
||||
g.superadmin(req);
|
||||
const rows = await db
|
||||
.select({ message: adminMessages, company: companies.name, user: users.name })
|
||||
.from(adminMessages)
|
||||
.leftJoin(companies, eq(companies.id, adminMessages.companyId))
|
||||
.leftJoin(users, eq(users.id, adminMessages.userId))
|
||||
.orderBy(desc(adminMessages.id))
|
||||
.limit(100);
|
||||
return rows.map((r) => ({ ...r.message, company: r.company, user: r.user }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import {
|
||||
accessState,
|
||||
allowedBillingModels,
|
||||
PLAN_SPECS,
|
||||
type BillingModel,
|
||||
type Plan,
|
||||
} from '@lr/shared';
|
||||
import { and, asc, eq, gte, isNull, lte, ne, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Db, Tx } from '../db/client.ts';
|
||||
import { clients, companies, deals, invoices, leads, payments, settings, users } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../lib/errors.ts';
|
||||
import { enqueueText } from '../telegram/outbox.ts';
|
||||
|
||||
// Биллинг (ТЗ, раздел 7).
|
||||
//
|
||||
// Фикс — предоплата: счёт на следующий месяц выставляется за 7 дней до окончания подписки,
|
||||
// оплата продлевает подписку до конца оплаченного периода.
|
||||
// «%» — постоплата по календарным месяцам: 1-го числа выставляется счёт за сделки прошлого
|
||||
// месяца, оплатить его нужно до 8-го. Оплата продлевает подписку до 8-го числа следующего месяца.
|
||||
// Все даты считаются по времени Еревана.
|
||||
|
||||
type Company = typeof companies.$inferSelect;
|
||||
export type Invoice = typeof invoices.$inferSelect;
|
||||
|
||||
const DAY = 86_400_000;
|
||||
/** За сколько дней до окончания подписки выставляется счёт на фикс и на какой день месяца срок оплаты «%». */
|
||||
const INVOICE_LEAD_DAYS = 7;
|
||||
const PERCENT_DUE_DAY = 8;
|
||||
const REMIND_DAYS = [7, 3, 1];
|
||||
/** Напоминания не уходят ночью: раньше этого часа по Еревану задача их не отправляет. */
|
||||
const REMIND_FROM_HOUR = 10;
|
||||
|
||||
/** Дата по Еревану в виде «ГГГГ-ММ-ДД». */
|
||||
export const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
|
||||
/** Подписка действует до конца дня по Еревану. */
|
||||
export const endOfDay = (day: string) => new Date(`${day}T23:59:59+04:00`);
|
||||
const utc = (day: string) => new Date(`${day}T00:00:00Z`);
|
||||
export const addDays = (day: string, n: number) => new Date(utc(day).getTime() + n * DAY).toISOString().slice(0, 10);
|
||||
export const daysBetween = (from: string, to: string) => Math.round((utc(to).getTime() - utc(from).getTime()) / DAY);
|
||||
/** Тот же день следующего месяца; 31 января → 28 (29) февраля. */
|
||||
export function addMonths(day: string, n: number): string {
|
||||
const d = utc(day);
|
||||
const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
|
||||
const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
target.setUTCDate(Math.min(d.getUTCDate(), last));
|
||||
return target.toISOString().slice(0, 10);
|
||||
}
|
||||
const monthStart = (day: string) => `${day.slice(0, 7)}-01`;
|
||||
const percentDue = (month: string) => `${month.slice(0, 7)}-${String(PERCENT_DUE_DAY).padStart(2, '0')}`;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
// --- курсы валют для счёта по комиссии ---
|
||||
|
||||
/** Сколько единиц валюты в одном долларе. Комиссия по сделке вводится в любой валюте, счёт — в USD. */
|
||||
export const BillingSettings = z.object({
|
||||
rates: z
|
||||
.object({
|
||||
AMD: z.number().positive().default(385),
|
||||
RUB: z.number().positive().default(95),
|
||||
EUR: z.number().positive().default(0.92),
|
||||
})
|
||||
.default({ AMD: 385, RUB: 95, EUR: 0.92 }),
|
||||
});
|
||||
export type BillingSettings = z.infer<typeof BillingSettings>;
|
||||
|
||||
export async function loadBillingSettings(db: Tx): Promise<BillingSettings> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, 'billing'));
|
||||
return BillingSettings.parse(row?.value ?? {});
|
||||
}
|
||||
|
||||
export async function saveBillingSettings(db: Tx, value: BillingSettings) {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: 'billing', value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
const toUsd = (amount: number, currency: string, rates: BillingSettings['rates']) =>
|
||||
currency === 'USD' ? amount : amount / (rates[currency as keyof typeof rates] ?? 1);
|
||||
|
||||
// --- расчёт по модели «%» ---
|
||||
|
||||
export interface PercentDeal {
|
||||
dealId: number;
|
||||
date: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
commission: number;
|
||||
currency: string;
|
||||
/** Комиссия в долларах по курсу на момент счёта. */
|
||||
usd: number;
|
||||
}
|
||||
|
||||
export interface PercentDetails {
|
||||
plan: Plan;
|
||||
percent: number;
|
||||
rates: BillingSettings['rates'];
|
||||
/** Сумма комиссий в долларах. */
|
||||
total: number;
|
||||
deals: PercentDeal[];
|
||||
}
|
||||
|
||||
/** Сделки компании за период и сумма к оплате: комиссии × процент тарифа. */
|
||||
export async function percentCalc(tx: Tx, company: Company, from: string, to: string) {
|
||||
const { rates } = await loadBillingSettings(tx);
|
||||
const since = company.percentFrom && company.percentFrom > from ? company.percentFrom : from;
|
||||
const rows = await tx
|
||||
.select({
|
||||
deal: deals,
|
||||
leadTitle: leads.title,
|
||||
clientName: clients.name,
|
||||
agent: users.name,
|
||||
})
|
||||
.from(deals)
|
||||
.leftJoin(leads, eq(leads.id, deals.leadId))
|
||||
.leftJoin(clients, eq(clients.id, deals.clientId))
|
||||
.innerJoin(users, eq(users.id, deals.agentId))
|
||||
.where(and(eq(deals.companyId, company.id), gte(deals.dealDate, since), lte(deals.dealDate, to)))
|
||||
.orderBy(asc(deals.dealDate), asc(deals.id));
|
||||
const plan = company.plan as Plan;
|
||||
const percent = PLAN_SPECS[plan].commissionPercent ?? 0;
|
||||
const list: PercentDeal[] = rows.map((r) => ({
|
||||
dealId: r.deal.id,
|
||||
date: r.deal.dealDate,
|
||||
title: [r.leadTitle, r.clientName].filter(Boolean).join(' · '),
|
||||
agent: r.agent,
|
||||
commission: r.deal.commission,
|
||||
currency: r.deal.currency,
|
||||
usd: round2(toUsd(r.deal.commission, r.deal.currency, rates)),
|
||||
}));
|
||||
const total = round2(list.reduce((sum, d) => sum + d.usd, 0));
|
||||
const details: PercentDetails = { plan, percent, rates, total, deals: list };
|
||||
return { details, amount: round2((total * percent) / 100) };
|
||||
}
|
||||
|
||||
// --- выставление счетов ---
|
||||
|
||||
const managersOf = async (tx: Tx, companyId: number) =>
|
||||
(
|
||||
await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, companyId), eq(users.role, 'manager'), isNull(users.removedAt)))
|
||||
).map((u) => u.id);
|
||||
|
||||
const money = (n: number) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
||||
|
||||
async function issue(
|
||||
tx: Tx,
|
||||
company: Company,
|
||||
v: { kind: 'setup' | 'fixed' | 'percent'; amount: number; periodStart?: string | null; periodEnd?: string | null; details?: unknown },
|
||||
notify = true,
|
||||
): Promise<Invoice> {
|
||||
const [invoice] = await tx
|
||||
.insert(invoices)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
kind: v.kind,
|
||||
amount: v.amount,
|
||||
periodStart: v.periodStart ?? null,
|
||||
periodEnd: v.periodEnd ?? null,
|
||||
details: v.details ?? { plan: company.plan },
|
||||
})
|
||||
.returning();
|
||||
if (notify && v.amount > 0) await enqueueText(tx, await managersOf(tx, company.id), 'invoiceIssued', [money(v.amount)]);
|
||||
return invoice!;
|
||||
}
|
||||
|
||||
/** Плата за установку начисляется один раз — при первом подключении Team или Agency. */
|
||||
export async function issueSetupIfNeeded(tx: Tx, company: Company, notify = true) {
|
||||
const fee = PLAN_SPECS[company.plan as Plan].setupFeeUsd;
|
||||
if (!fee || company.setupFeeInvoiced) return;
|
||||
await tx.update(companies).set({ setupFeeInvoiced: true }).where(eq(companies.id, company.id));
|
||||
await issue(tx, company, { kind: 'setup', amount: fee }, notify);
|
||||
}
|
||||
|
||||
/** Счета новой компании, которая подключается сама: установка и первый месяц фикса. */
|
||||
export async function issueInitialInvoices(tx: Tx, company: Company) {
|
||||
await issueSetupIfNeeded(tx, company, false);
|
||||
if (company.billingModel === 'fixed') {
|
||||
// Период первого счёта начинается в день оплаты.
|
||||
await issue(tx, company, { kind: 'fixed', amount: PLAN_SPECS[company.plan as Plan].monthlyUsd }, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Тариф и модель, которые будут действовать у компании на указанную дату. */
|
||||
function planOn(company: Company, day: string): { plan: Plan; model: BillingModel } {
|
||||
if (company.plannedPlan && company.plannedFrom && company.plannedFrom <= day) {
|
||||
return { plan: company.plannedPlan as Plan, model: (company.plannedBillingModel ?? company.billingModel) as BillingModel };
|
||||
}
|
||||
return { plan: company.plan as Plan, model: company.billingModel as BillingModel };
|
||||
}
|
||||
|
||||
/** С какой даты вступит в силу смена тарифа; null — сразу (подписка ещё не начиналась или уже закончилась). */
|
||||
export function nextPeriodStart(company: Company, now = new Date()): string | null {
|
||||
if (!company.subscriptionEndsAt || company.subscriptionEndsAt < now) return null;
|
||||
return company.billingModel === 'percent'
|
||||
? addMonths(monthStart(ymd(now)), 1)
|
||||
: addDays(ymd(company.subscriptionEndsAt), 1);
|
||||
}
|
||||
|
||||
/** Фикс: счёт на следующий месяц за 7 дней до окончания подписки. */
|
||||
async function ensureFixedInvoice(tx: Tx, company: Company, today: string) {
|
||||
if (!company.subscriptionEndsAt) return;
|
||||
const end = ymd(company.subscriptionEndsAt);
|
||||
if (daysBetween(today, end) > INVOICE_LEAD_DAYS) return;
|
||||
const periodStart = addDays(end, 1);
|
||||
const next = planOn(company, periodStart);
|
||||
if (next.model !== 'fixed') return;
|
||||
const [exists] = await tx
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.companyId, company.id),
|
||||
eq(invoices.kind, 'fixed'),
|
||||
ne(invoices.status, 'canceled'),
|
||||
sql`(${invoices.periodStart} = ${periodStart} or (${invoices.periodStart} is null and ${invoices.status} = 'pending'))`,
|
||||
),
|
||||
);
|
||||
if (exists) return;
|
||||
await issue(tx, company, {
|
||||
kind: 'fixed',
|
||||
amount: PLAN_SPECS[next.plan].monthlyUsd,
|
||||
periodStart,
|
||||
periodEnd: addMonths(end, 1),
|
||||
details: { plan: next.plan },
|
||||
});
|
||||
}
|
||||
|
||||
/** «%»: 1-го числа счёт за сделки прошлого месяца. Нулевой счёт закрывается сам и продлевает подписку. */
|
||||
async function ensurePercentInvoice(tx: Tx, company: Company, today: string) {
|
||||
const to = addDays(monthStart(today), -1);
|
||||
const from = monthStart(to);
|
||||
if (!company.percentFrom || company.percentFrom > to) return;
|
||||
const [exists] = await tx
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.companyId, company.id), eq(invoices.kind, 'percent'), eq(invoices.periodStart, from)));
|
||||
if (exists) return;
|
||||
const { details, amount } = await percentCalc(tx, company, from, to);
|
||||
const invoice = await issue(tx, company, { kind: 'percent', amount, periodStart: from, periodEnd: to, details });
|
||||
if (amount === 0) await settleInvoice(tx, invoice.id, 'auto');
|
||||
}
|
||||
|
||||
// --- оплата ---
|
||||
|
||||
/**
|
||||
* Отмечает счёт оплаченным и продлевает подписку. Вызывается суперадмином вручную
|
||||
* и платёжным модулем после подтверждения платежа.
|
||||
*/
|
||||
export async function settleInvoice(
|
||||
tx: Tx,
|
||||
invoiceId: number,
|
||||
method: 'manual' | 'online' | 'auto',
|
||||
now = new Date(),
|
||||
): Promise<Invoice> {
|
||||
const [invoice] = await tx.select().from(invoices).where(eq(invoices.id, invoiceId)).for('update');
|
||||
if (!invoice) throw notFound();
|
||||
if (invoice.status === 'paid') return invoice;
|
||||
if (invoice.status !== 'pending') throw conflict('INVOICE_CANCELED');
|
||||
const [company] = await tx.select().from(companies).where(eq(companies.id, invoice.companyId)).for('update');
|
||||
const today = ymd(now);
|
||||
let { periodStart, periodEnd } = invoice;
|
||||
let endsAt: Date | null = null;
|
||||
|
||||
if (invoice.kind === 'fixed') {
|
||||
// Первый счёт или оплата после долгого простоя: месяц отсчитывается от дня оплаты.
|
||||
if (!periodStart || !periodEnd || periodEnd < today) {
|
||||
periodStart = today;
|
||||
periodEnd = addMonths(today, 1);
|
||||
}
|
||||
endsAt = endOfDay(periodEnd);
|
||||
} else if (invoice.kind === 'percent' && company!.billingModel === 'percent') {
|
||||
endsAt = endOfDay(percentDue(addMonths(addDays(invoice.periodEnd!, 1), 1)));
|
||||
} else if (invoice.kind === 'setup' && company!.billingModel === 'percent' && !company!.subscriptionEndsAt) {
|
||||
// На модели «%» первого месячного счёта нет: подписка стартует с оплаты установки.
|
||||
endsAt = endOfDay(percentDue(addMonths(today, 1)));
|
||||
}
|
||||
|
||||
const [paid] = await tx
|
||||
.update(invoices)
|
||||
.set({ status: 'paid', paidAt: now, paidMethod: method, periodStart, periodEnd })
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.returning();
|
||||
// У онлайн-платежа строка уже есть: её создал запрос на оплату, а подтвердил webhook.
|
||||
if (method === 'manual') {
|
||||
await tx.insert(payments).values({
|
||||
invoiceId,
|
||||
provider: 'manual',
|
||||
externalId: null,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
status: 'succeeded',
|
||||
});
|
||||
}
|
||||
if (endsAt && (!company!.subscriptionEndsAt || endsAt > company!.subscriptionEndsAt)) {
|
||||
await tx
|
||||
.update(companies)
|
||||
.set({
|
||||
subscriptionEndsAt: endsAt,
|
||||
accessEnabled: true,
|
||||
billingNotice: null,
|
||||
...(company!.billingModel === 'percent' && !company!.percentFrom ? { percentFrom: today } : {}),
|
||||
})
|
||||
.where(eq(companies.id, company!.id));
|
||||
if (method !== 'auto') {
|
||||
await enqueueText(tx, await managersOf(tx, company!.id), 'paymentReceived', [ymd(endsAt)]);
|
||||
}
|
||||
}
|
||||
return paid!;
|
||||
}
|
||||
|
||||
export async function cancelInvoice(tx: Tx, invoiceId: number) {
|
||||
const [invoice] = await tx.select().from(invoices).where(eq(invoices.id, invoiceId)).for('update');
|
||||
if (!invoice) throw notFound();
|
||||
if (invoice.status === 'paid') throw conflict('INVOICE_PAID');
|
||||
await tx.update(invoices).set({ status: 'canceled' }).where(eq(invoices.id, invoiceId));
|
||||
if (invoice.kind === 'setup') {
|
||||
await tx.update(companies).set({ setupFeeInvoiced: false }).where(eq(companies.id, invoice.companyId));
|
||||
}
|
||||
}
|
||||
|
||||
// --- смена тарифа ---
|
||||
|
||||
const cancelPendingFixed = (tx: Tx, companyId: number) =>
|
||||
tx
|
||||
.update(invoices)
|
||||
.set({ status: 'canceled' })
|
||||
.where(and(eq(invoices.companyId, companyId), eq(invoices.kind, 'fixed'), eq(invoices.status, 'pending')));
|
||||
|
||||
/** Применяет тариф и модель прямо сейчас (суперадмин, запланированная дата, неначатая подписка). */
|
||||
export async function applyPlan(tx: Tx, company: Company, plan: Plan, model: BillingModel, now = new Date()): Promise<Company> {
|
||||
const today = ymd(now);
|
||||
const toPercent = model === 'percent' && company.billingModel !== 'percent';
|
||||
const running = Boolean(company.subscriptionEndsAt);
|
||||
// Уход с модели «%» посреди месяца: сделки с начала месяца закрываются отдельным счётом,
|
||||
// иначе они не попали бы ни в один.
|
||||
if (running && company.billingModel === 'percent' && model !== 'percent' && today !== monthStart(today)) {
|
||||
const to = addDays(today, -1);
|
||||
const { details, amount } = await percentCalc(tx, company, monthStart(today), to);
|
||||
if (amount > 0) await issue(tx, company, { kind: 'percent', amount, periodStart: monthStart(today), periodEnd: to, details });
|
||||
}
|
||||
const [updated] = await tx
|
||||
.update(companies)
|
||||
.set({
|
||||
plan,
|
||||
billingModel: model,
|
||||
plannedPlan: null,
|
||||
plannedBillingModel: null,
|
||||
plannedFrom: null,
|
||||
...(model === 'percent' && (toPercent || !company.percentFrom) ? { percentFrom: today } : {}),
|
||||
// Первый счёт по комиссии придёт 1-го числа следующего месяца, срок оплаты — 8-е.
|
||||
...(toPercent && running ? { subscriptionEndsAt: endOfDay(percentDue(addMonths(today, 1))), billingNotice: null } : {}),
|
||||
})
|
||||
.where(eq(companies.id, company.id))
|
||||
.returning();
|
||||
if (plan !== company.plan || model !== company.billingModel) await cancelPendingFixed(tx, company.id);
|
||||
let current = updated!;
|
||||
if (!running && plan !== company.plan) {
|
||||
// Подписка ещё не начиналась: неоплаченный счёт за установку пересчитывается под новый тариф.
|
||||
const dropped = await tx
|
||||
.update(invoices)
|
||||
.set({ status: 'canceled' })
|
||||
.where(and(eq(invoices.companyId, company.id), eq(invoices.kind, 'setup'), eq(invoices.status, 'pending')))
|
||||
.returning({ id: invoices.id });
|
||||
if (dropped.length) {
|
||||
[current] = await tx.update(companies).set({ setupFeeInvoiced: false }).where(eq(companies.id, company.id)).returning() as [Company];
|
||||
}
|
||||
}
|
||||
await issueSetupIfNeeded(tx, current, running);
|
||||
if (!running && model === 'fixed') {
|
||||
await issue(tx, updated!, { kind: 'fixed', amount: PLAN_SPECS[plan].monthlyUsd }, false);
|
||||
}
|
||||
// Подписка идёт или закончилась: счёт на следующий месяц выставляется по новому тарифу.
|
||||
if (running && model === 'fixed') await ensureFixedInvoice(tx, updated!, today);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Смена тарифа из кабинета: вступает в силу со следующего расчётного месяца.
|
||||
* Если подписка ещё не начиналась или уже закончилась, меняется сразу.
|
||||
*/
|
||||
export async function schedulePlan(db: Db, companyId: number, plan: Plan, model: BillingModel, now = new Date()) {
|
||||
if (!allowedBillingModels(plan).includes(model)) throw badRequest('BILLING_MODEL_NOT_ALLOWED');
|
||||
return db.transaction(async (tx) => {
|
||||
const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)).for('update');
|
||||
if (!company) throw notFound();
|
||||
const [{ n: members } = { n: 0 }] = await tx
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
|
||||
if (members > PLAN_SPECS[plan].maxMembers) throw conflict('PLAN_TOO_SMALL');
|
||||
const same = plan === company.plan && model === company.billingModel;
|
||||
const from = nextPeriodStart(company, now);
|
||||
if (from === null) {
|
||||
if (!same) await applyPlan(tx, company, plan, model, now);
|
||||
return { from: null };
|
||||
}
|
||||
await tx
|
||||
.update(companies)
|
||||
.set(
|
||||
same
|
||||
? { plannedPlan: null, plannedBillingModel: null, plannedFrom: null }
|
||||
: { plannedPlan: plan, plannedBillingModel: model, plannedFrom: from },
|
||||
)
|
||||
.where(eq(companies.id, companyId));
|
||||
// Счёт на следующий месяц мог быть уже выставлен по старому тарифу — он пересоздаётся.
|
||||
await cancelPendingFixed(tx, companyId);
|
||||
const [fresh] = await tx.select().from(companies).where(eq(companies.id, companyId));
|
||||
if (fresh!.billingModel === 'fixed') await ensureFixedInvoice(tx, fresh!, ymd(now));
|
||||
return { from: same ? null : from };
|
||||
});
|
||||
}
|
||||
|
||||
// --- ежедневные задачи ---
|
||||
|
||||
async function remind(tx: Tx, company: Company, now: Date) {
|
||||
if (!company.subscriptionEndsAt || !company.accessEnabled) return;
|
||||
const today = ymd(now);
|
||||
const end = ymd(company.subscriptionEndsAt);
|
||||
const state = accessState(company, now);
|
||||
const left = daysBetween(today, end);
|
||||
let key: string | null = null;
|
||||
let text: [string, unknown[]] | null = null;
|
||||
if (state === 'active' && REMIND_DAYS.includes(left)) {
|
||||
key = `before:${left}:${end}`;
|
||||
text = ['subEnding', [left, end]];
|
||||
} else if (state === 'overdue') {
|
||||
key = `overdue:${today}`;
|
||||
text = ['subOverdue', [Math.max(0, company.graceDays + left)]];
|
||||
} else if (state === 'restricted') {
|
||||
key = `restricted:${end}`;
|
||||
text = ['subRestricted', []];
|
||||
}
|
||||
if (!key || !text || key === company.billingNotice) return;
|
||||
await tx.update(companies).set({ billingNotice: key }).where(eq(companies.id, company.id));
|
||||
await enqueueText(tx, await managersOf(tx, company.id), text[0], text[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускается раз в несколько минут: счета по комиссии 1-го числа, запланированная смена тарифа,
|
||||
* счёт на фикс за 7 дней до окончания, напоминания о подписке. Повторный запуск ничего не дублирует.
|
||||
*/
|
||||
export async function runBillingJobs(db: Db, now = new Date()) {
|
||||
const today = ymd(now);
|
||||
const hour = new Date(now.getTime() + 4 * 3_600_000).getUTCHours();
|
||||
const ids = await db.select({ id: companies.id }).from(companies).orderBy(companies.id);
|
||||
for (const { id } of ids) {
|
||||
await db.transaction(async (tx) => {
|
||||
let [company] = await tx.select().from(companies).where(eq(companies.id, id)).for('update');
|
||||
if (!company?.subscriptionEndsAt) return;
|
||||
// Счёт за прошлый месяц считается по тарифу, который действовал в том месяце, — до его смены.
|
||||
if (company.billingModel === 'percent') await ensurePercentInvoice(tx, company, today);
|
||||
if (company.plannedPlan && company.plannedFrom && company.plannedFrom <= today) {
|
||||
const [{ n: members } = { n: 0 }] = await tx
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, id), isNull(users.removedAt)));
|
||||
const plan = company.plannedPlan as Plan;
|
||||
if (members > PLAN_SPECS[plan].maxMembers) {
|
||||
// Участников стало больше, чем вмещает новый тариф: смена отменяется.
|
||||
await tx
|
||||
.update(companies)
|
||||
.set({ plannedPlan: null, plannedBillingModel: null, plannedFrom: null })
|
||||
.where(eq(companies.id, id));
|
||||
await enqueueText(tx, await managersOf(tx, id), 'planChangeCanceled');
|
||||
} else {
|
||||
await applyPlan(tx, company, plan, (company.plannedBillingModel ?? company.billingModel) as BillingModel, now);
|
||||
}
|
||||
}
|
||||
[company] = await tx.select().from(companies).where(eq(companies.id, id));
|
||||
if (company!.billingModel === 'fixed') await ensureFixedInvoice(tx, company!, today);
|
||||
[company] = await tx.select().from(companies).where(eq(companies.id, id));
|
||||
if (hour >= REMIND_FROM_HOUR) await remind(tx, company!, now);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Создаёт суперадмина (владельца сервиса) или меняет ему пароль:
|
||||
// pnpm --filter @lr/api superadmin <email> "Имя"
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { hashPassword } from '../auth/password.ts';
|
||||
import { connect } from '../db/client.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
import { loadConfig } from '../lib/config.ts';
|
||||
|
||||
const [email, name = 'Суперадмин'] = process.argv.slice(2);
|
||||
if (!email) throw new Error('Укажите email: superadmin <email> "Имя"');
|
||||
|
||||
const { db, close } = connect(loadConfig().databaseUrl);
|
||||
const password = randomBytes(9).toString('base64url');
|
||||
const passwordHash = await hashPassword(password);
|
||||
const [existing] = await db.select().from(users).where(sql`lower(${users.email}) = ${email.toLowerCase()}`);
|
||||
if (existing && existing.role !== 'superadmin') throw new Error('Этот email уже занят обычным пользователем');
|
||||
if (existing) await db.update(users).set({ passwordHash }).where(sql`${users.id} = ${existing.id}`);
|
||||
else {
|
||||
await db.insert(users).values({
|
||||
email: email.toLowerCase(), name, role: 'superadmin', passwordHash, emailVerifiedAt: new Date(),
|
||||
});
|
||||
}
|
||||
await close();
|
||||
console.log(`Суперадмин: ${email}\nПароль: ${password}`);
|
||||
@@ -0,0 +1,698 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import { runClientJobs } from './clients/service.ts';
|
||||
import { deals, leads, outbox, users } from './db/schema.ts';
|
||||
import { runReportJobs } from './reports.ts';
|
||||
import { processOutbox } from './telegram/outbox.ts';
|
||||
import { fakeTelegram, testApp } from './testing.ts';
|
||||
|
||||
// Сценарии воронки клиента: карточка, видимость, связь «клиент ↔ объект», общая сделка,
|
||||
// настройка воронок руководителем.
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
let tgId = 5000;
|
||||
beforeAll(async () => { t = await testApp('clients'); });
|
||||
afterAll(() => t.close());
|
||||
|
||||
const tg = fakeTelegram();
|
||||
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
|
||||
|
||||
interface Member { api: ReturnType<typeof t.client>; id: number; chat: number }
|
||||
|
||||
/** Компания Team с руководителем и агентами; у всех привязан Telegram. */
|
||||
const company = async (agents = 2) => {
|
||||
const [sa] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
|
||||
.returning();
|
||||
const admin = t.client(await createSession(t.db, sa!.id));
|
||||
const created = await admin.post('/api/admin/companies', {
|
||||
name: 'Агентство', plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
const join = async (name: string, token: string): Promise<Member> => {
|
||||
const api = await t.register(name);
|
||||
await api.post(`/api/invites/${token}/accept`);
|
||||
const id = (await api.get('/api/me')).body.user.id as number;
|
||||
const chat = ++tgId;
|
||||
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
|
||||
return { api, id, chat };
|
||||
};
|
||||
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
|
||||
const invite = (await boss.api.post('/api/invites')).body.token as string;
|
||||
const team: Member[] = [];
|
||||
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite));
|
||||
await flush();
|
||||
return { boss, agents: team, id: created.body.id as number };
|
||||
};
|
||||
|
||||
const pipeline = async (m: Member, name: 'object' | 'client') => {
|
||||
const p = (await m.api.get(`/api/pipelines/${name}`)).body;
|
||||
const status = (code: string) => p.statuses.find((s: any) => s.code === code).id as number;
|
||||
return { ...p, status, names: p.statuses.map((s: any) => s.name) as string[] };
|
||||
};
|
||||
|
||||
const newClient = async (m: Member, over: object = {}) =>
|
||||
(await m.api.post('/api/clients', { name: 'Анна', phone: '091 123 456', kind: 'buyer', request: { districts: ['Кентрон'], priceTo: 200_000 }, ...over })).body.id as number;
|
||||
|
||||
const newLead = async (m: Member, over: object = {}) =>
|
||||
(await m.api.post('/api/leads', { title: '3-комн. на Абовяна', dealType: 'sale', propertyType: 'apartment', district: 'Кентрон', price: 150_000, ...over })).body.id as number;
|
||||
|
||||
const deal = { commission: 3000, currency: 'USD', date: '2026-10-08' };
|
||||
|
||||
describe('клиенты', () => {
|
||||
it('карточка клиента: создание, запрос, правка, комментарий, история', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const id = await newClient(a, { source: 'Instagram' });
|
||||
const p = await pipeline(a, 'client');
|
||||
let card = (await a.api.get(`/api/clients/${id}`)).body;
|
||||
expect(card).toMatchObject({
|
||||
name: 'Анна', phone: '+37491123456', kind: 'buyer', source: 'Instagram', assigneeId: a.id,
|
||||
statusId: p.status('new'), request: { districts: ['Кентрон'], priceTo: 200_000 },
|
||||
});
|
||||
|
||||
expect((await a.api.patch(`/api/clients/${id}`, { name: 'Анна С.', source: '', request: { rooms: [2, 3] } })).status).toBe(200);
|
||||
await a.api.post(`/api/clients/${id}/comments`, { text: 'Ищет рядом с метро' });
|
||||
card = (await a.api.get(`/api/clients/${id}`)).body;
|
||||
expect(card).toMatchObject({ name: 'Анна С.', source: null, request: { rooms: [2, 3] } });
|
||||
expect(card.events.map((e: any) => e.type)).toEqual(['comment', 'edited', 'created']);
|
||||
// Правка одного поля остальные не трогает.
|
||||
await a.api.patch(`/api/clients/${id}`, { phone: '093 000 111' });
|
||||
expect((await a.api.get(`/api/clients/${id}`)).body).toMatchObject({ name: 'Анна С.', phone: '+37493000111', kind: 'buyer', request: { rooms: [2, 3] } });
|
||||
expect((await a.api.post('/api/clients', { name: '', kind: 'buyer' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('видимость: чужого клиента агент не видит; при «видят все» видит без телефона и не может вести', async () => {
|
||||
const c = await company(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const id = await newClient(a1);
|
||||
expect((await a2.api.get(`/api/clients/${id}`)).status).toBe(404);
|
||||
expect((await a2.api.get('/api/clients?tab=all')).status).toBe(403);
|
||||
expect((await a2.api.get('/api/clients?tab=mine')).body).toHaveLength(0);
|
||||
expect((await c.boss.api.get('/api/clients?tab=all')).body).toMatchObject([{ id, phone: '+37491123456', assigneeName: 'Агент 1' }]);
|
||||
// Поиск по телефону находит клиента руководителю, но не чужому агенту.
|
||||
expect((await c.boss.api.get('/api/clients?tab=all&q=123456')).body).toHaveLength(1);
|
||||
|
||||
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
|
||||
const seen = (await a2.api.get(`/api/clients/${id}`)).body;
|
||||
expect(seen).toMatchObject({ name: 'Анна', phone: null, phoneHidden: true });
|
||||
expect((await a2.api.get('/api/clients?tab=all&q=123456')).body).toHaveLength(0);
|
||||
expect((await a2.api.post(`/api/clients/${id}/comments`, { text: 'x' })).body.code).toBe('NOT_YOUR_CLIENT');
|
||||
|
||||
// Клиент из другой компании недоступен.
|
||||
const other = await company(0);
|
||||
expect((await other.boss.api.get(`/api/clients/${id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('воронка клиента: отказ требует причину, сделка — комиссию; закрытую сделку меняет руководитель', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const p = await pipeline(a, 'client');
|
||||
expect(p.names).toEqual(['Новый запрос', 'Квалификация', 'Подбор объектов', 'Показ', 'Переговоры и задаток', 'Сделка', 'Отказ']);
|
||||
const id = await newClient(a);
|
||||
const move = (body: object, who = a) => who.api.post(`/api/clients/${id}/status`, body);
|
||||
|
||||
expect((await move({ statusId: p.statuses[1].id })).status).toBe(200);
|
||||
expect((await move({ statusId: p.status('rejected') })).body.code).toBe('REJECT_REASON_REQUIRED');
|
||||
// Причина из воронки объекта клиенту не подходит.
|
||||
const objectReason = (await pipeline(a, 'object')).reasons[1].id;
|
||||
expect((await move({ statusId: p.status('rejected'), rejectReasonId: objectReason })).body.code).toBe('REJECT_REASON_REQUIRED');
|
||||
expect((await move({ statusId: p.status('rejected'), rejectReasonId: p.reasons[0].id })).status).toBe(200);
|
||||
expect((await a.api.get(`/api/clients/${id}`)).body.rejectReasonId).toBe(p.reasons[0].id);
|
||||
|
||||
expect((await move({ statusId: p.status('deal') })).body.code).toBe('COMMISSION_REQUIRED');
|
||||
expect((await move({ statusId: p.status('deal'), deal })).status).toBe(200);
|
||||
const card = (await a.api.get(`/api/clients/${id}`)).body;
|
||||
expect(card).toMatchObject({ statusId: p.status('deal'), rejectReasonId: null, deal: { commission: 3000, currency: 'USD', date: '2026-10-08', leadId: null } });
|
||||
|
||||
// Сумму исправляет руководитель; агент — нет.
|
||||
expect((await a.api.patch(`/api/deals/${card.deal.id}`, { ...deal, commission: 1 })).status).toBe(403);
|
||||
expect((await c.boss.api.patch(`/api/deals/${card.deal.id}`, { ...deal, commission: 3500 })).status).toBe(200);
|
||||
expect((await a.api.get(`/api/clients/${id}`)).body.deal.commission).toBe(3500);
|
||||
|
||||
// Сделку отменяет только руководитель: запись сделки исчезает.
|
||||
expect((await move({ statusId: p.statuses[1].id })).body.code).toBe('DEAL_CLOSED');
|
||||
expect((await move({ statusId: p.statuses[1].id }, c.boss)).status).toBe(200);
|
||||
expect(await t.db.select().from(deals).where(eq(deals.clientId, id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('руководитель переназначает клиента; напоминание о следующем действии приходит в бот', async () => {
|
||||
const c = await company(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const id = await newClient(a1);
|
||||
expect((await a1.api.post(`/api/clients/${id}/assign`, { userId: a2.id })).status).toBe(403);
|
||||
expect((await c.boss.api.post(`/api/clients/${id}/assign`, { userId: a2.id })).status).toBe(200);
|
||||
await flush();
|
||||
expect(tg.inbox(a2.chat).at(-1)!.text).toBe('Вам назначен клиент: Анна');
|
||||
expect(tg.inbox(a1.chat).at(-1)!.text).toBe('Клиент «Анна» передан агенту Агент 2.');
|
||||
expect((await a1.api.get(`/api/clients/${id}`)).status).toBe(404);
|
||||
const events = (await a2.api.get(`/api/clients/${id}`)).body.events;
|
||||
expect(events[0]).toMatchObject({ type: 'assigned', oldValue: 'Агент 1', newValue: 'Агент 2' });
|
||||
|
||||
await a2.api.post(`/api/clients/${id}/next-action`, { at: new Date(Date.now() - 1000).toISOString(), note: 'Позвонить' });
|
||||
expect(await runClientJobs(t.db)).toEqual({ reminded: 1 });
|
||||
expect(await runClientJobs(t.db)).toEqual({ reminded: 0 });
|
||||
await flush();
|
||||
expect(tg.inbox(a2.chat).at(-1)!.text).toBe('🔔 Напоминание по клиенту «Анна»: Позвонить');
|
||||
|
||||
// Удалённого агента сменяет наследник; без наследника клиентов забирает руководитель.
|
||||
await c.boss.api.del(`/api/members/${a2.id}`);
|
||||
expect((await c.boss.api.get(`/api/clients/${id}`)).body.assigneeId).toBe(c.boss.id);
|
||||
});
|
||||
|
||||
it('ответственный — тот, кто создал клиента, даже если руководитель указал другого', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const res = await c.boss.api.post('/api/clients', { name: 'Ольга', kind: 'tenant', assigneeId: a.id });
|
||||
expect((await c.boss.api.get(`/api/clients/${res.body.id}`)).body.assigneeId).toBe(c.boss.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('связь «клиент ↔ объект» и общая сделка', () => {
|
||||
it('объект прикрепляется к клиенту: «предложен» → «показан»; в карточке объекта видны клиенты', async () => {
|
||||
const c = await company(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const clientId = await newClient(a1);
|
||||
const leadId = await newLead(a1);
|
||||
const foreign = await newLead(a2);
|
||||
|
||||
// Подбор: подходит объект в Кентроне до 200 000; дорогой и арендный — нет.
|
||||
await newLead(a1, { title: 'Дорогая', price: 500_000 });
|
||||
await newLead(a1, { title: 'Аренда', dealType: 'rent', price: 800 });
|
||||
const suggested = (await a1.api.get(`/api/clients/${clientId}/suggestions`)).body;
|
||||
expect(suggested.map((l: any) => l.id)).toEqual([leadId]);
|
||||
|
||||
expect((await a1.api.post(`/api/clients/${clientId}/leads`, { leadId })).status).toBe(200);
|
||||
// Чужой объект при закрытой видимости прикрепить нельзя.
|
||||
expect((await a1.api.post(`/api/clients/${clientId}/leads`, { leadId: foreign })).status).toBe(404);
|
||||
expect((await a1.api.get(`/api/clients/${clientId}/suggestions`)).body).toHaveLength(0);
|
||||
await a1.api.post(`/api/clients/${clientId}/leads`, { leadId, state: 'shown' });
|
||||
|
||||
const card = (await a1.api.get(`/api/clients/${clientId}`)).body;
|
||||
expect(card.leads).toMatchObject([{ id: leadId, title: '3-комн. на Абовяна', state: 'shown' }]);
|
||||
expect(card.leads[0].shownAt).toBeTruthy();
|
||||
expect(card.events.slice(0, 2).map((e: any) => [e.type, e.newValue])).toEqual([['shown', '3-комн. на Абовяна'], ['offered', '3-комн. на Абовяна']]);
|
||||
|
||||
const lead = (await a1.api.get(`/api/leads/${leadId}`)).body;
|
||||
expect(lead.clients).toMatchObject([{ id: clientId, name: 'Анна', state: 'shown' }]);
|
||||
expect(lead.events[0]).toMatchObject({ type: 'client_shown', newValue: 'Анна' });
|
||||
expect((await a1.api.get('/api/clients-options')).body).toMatchObject([{ id: clientId, name: 'Анна' }]);
|
||||
|
||||
expect((await a1.api.del(`/api/clients/${clientId}/leads/${leadId}`)).status).toBe(200);
|
||||
expect((await a1.api.get(`/api/clients/${clientId}`)).body.leads).toHaveLength(0);
|
||||
expect((await a1.api.get(`/api/leads/${leadId}`)).body.clients).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('сделка объекта с клиентом из CRM закрывается один раз: оба в «Сделке», комиссия одна', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const po = await pipeline(a, 'object');
|
||||
const pc = await pipeline(a, 'client');
|
||||
const count = async () => (await t.db.select().from(deals).where(eq(deals.companyId, c.id))).length;
|
||||
|
||||
// Со стороны объекта.
|
||||
const client1 = await newClient(a);
|
||||
const lead1 = await newLead(a);
|
||||
const close = (leadId: number, clientId: number) =>
|
||||
a.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('deal'), deal: { ...deal, clientId } });
|
||||
// Неприкреплённого клиента в пару взять нельзя — и объект при этом остаётся в прежнем статусе.
|
||||
expect((await close(lead1, client1)).body.code).toBe('PAIR_NOT_LINKED');
|
||||
expect((await a.api.get(`/api/leads/${lead1}`)).body.statusId).toBe(po.status('taken'));
|
||||
await a.api.post(`/api/clients/${client1}/leads`, { leadId: lead1 });
|
||||
expect((await close(lead1, client1)).status).toBe(200);
|
||||
expect(await count()).toBe(1);
|
||||
const leadCard = (await a.api.get(`/api/leads/${lead1}`)).body;
|
||||
const clientCard = (await a.api.get(`/api/clients/${client1}`)).body;
|
||||
expect(leadCard).toMatchObject({ statusId: po.status('deal'), deal: { commission: 3000, clientId: client1, clientName: 'Анна' } });
|
||||
expect(clientCard).toMatchObject({ statusId: pc.status('deal'), deal: { id: leadCard.deal.id, commission: 3000, leadId: lead1 } });
|
||||
expect(clientCard.events[0]).toMatchObject({ type: 'deal', text: '3000 USD' });
|
||||
|
||||
// Со стороны клиента.
|
||||
const client2 = await newClient(a, { name: 'Давид' });
|
||||
const lead2 = await newLead(a);
|
||||
await a.api.post(`/api/clients/${client2}/leads`, { leadId: lead2, state: 'shown' });
|
||||
expect((await a.api.post(`/api/clients/${client2}/status`, { statusId: pc.status('deal'), deal: { ...deal, commission: 5000, leadId: lead2 } })).status).toBe(200);
|
||||
expect(await count()).toBe(2);
|
||||
expect((await a.api.get(`/api/leads/${lead2}`)).body).toMatchObject({ statusId: po.status('deal'), deal: { commission: 5000, clientId: client2 } });
|
||||
|
||||
// Второй клиент к уже закрытому объекту не подставляется.
|
||||
const client3 = await newClient(a, { name: 'Мария' });
|
||||
await a.api.post(`/api/clients/${client3}/leads`, { leadId: lead2 });
|
||||
expect((await a.api.post(`/api/clients/${client3}/status`, { statusId: pc.status('deal'), deal: { ...deal, leadId: lead2 } })).body.code).toBe('DEAL_HAS_PAIR');
|
||||
expect((await a.api.get(`/api/clients/${client3}`)).body.statusId).toBe(pc.status('new'));
|
||||
|
||||
// Руководитель отменил сделку у объекта: запись остаётся за клиентом, комиссия не теряется и не двоится.
|
||||
expect((await c.boss.api.post(`/api/leads/${lead2}/status`, { statusId: po.status('taken') })).status).toBe(200);
|
||||
expect(await count()).toBe(2);
|
||||
expect((await a.api.get(`/api/leads/${lead2}`)).body.deal).toBeNull();
|
||||
expect((await a.api.get(`/api/clients/${client2}`)).body.deal).toMatchObject({ commission: 5000, leadId: null });
|
||||
});
|
||||
|
||||
it('объект и клиент закрыты порознь, затем сведены в пару: записи сливаются в одну', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const po = await pipeline(a, 'object');
|
||||
const pc = await pipeline(a, 'client');
|
||||
const clientId = await newClient(a);
|
||||
const leadId = await newLead(a);
|
||||
await a.api.post(`/api/clients/${clientId}/leads`, { leadId });
|
||||
await a.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('deal'), deal });
|
||||
await a.api.post(`/api/clients/${clientId}/status`, { statusId: pc.status('deal'), deal });
|
||||
expect(await t.db.select().from(deals).where(eq(deals.companyId, c.id))).toHaveLength(2);
|
||||
|
||||
// Руководитель возвращает клиента на этап и закрывает заново — уже с объектом.
|
||||
await c.boss.api.post(`/api/clients/${clientId}/status`, { statusId: pc.statuses[1].id });
|
||||
expect((await c.boss.api.post(`/api/clients/${clientId}/status`, { statusId: pc.status('deal'), deal: { ...deal, commission: 4000, leadId } })).status).toBe(200);
|
||||
const rows = await t.db.select().from(deals).where(eq(deals.companyId, c.id));
|
||||
expect(rows).toMatchObject([{ leadId, clientId, commission: 4000 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('настройка воронок', () => {
|
||||
it('руководитель добавляет, переименовывает, переставляет и удаляет промежуточные статусы', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
expect((await a.api.post('/api/pipelines/object/statuses', { name: 'Оценка' })).status).toBe(403);
|
||||
|
||||
const added = (await c.boss.api.post('/api/pipelines/object/statuses', { name: 'Оценка' })).body.id as number;
|
||||
let p = await pipeline(c.boss, 'object');
|
||||
expect(p.names).toEqual(['Новый', 'Взят в работу', 'Дозвонился', 'Встреча с собственником', 'Договор (эксклюзив)', 'В продаже (идут показы)', 'Оценка', 'Сделка', 'Отказ']);
|
||||
|
||||
await c.boss.api.patch(`/api/pipeline-statuses/${added}`, { name: 'Оценка объекта', poolReturn: true });
|
||||
const middle = p.statuses.filter((s: any) => !s.code).map((s: any) => s.id) as number[];
|
||||
const order = [added, ...middle.filter((id) => id !== added)];
|
||||
expect((await c.boss.api.post('/api/pipelines/object/order', { ids: order })).status).toBe(200);
|
||||
// Неполный список и системные статусы в порядке не принимаются.
|
||||
expect((await c.boss.api.post('/api/pipelines/object/order', { ids: order.slice(1) })).status).toBe(400);
|
||||
expect((await c.boss.api.post('/api/pipelines/object/order', { ids: [p.status('deal'), ...order.slice(1)] })).status).toBe(400);
|
||||
p = await pipeline(c.boss, 'object');
|
||||
expect(p.names.slice(0, 4)).toEqual(['Новый', 'Взят в работу', 'Оценка объекта', 'Дозвонился']);
|
||||
expect(p.statuses[2]).toMatchObject({ poolReturn: true, count: 0 });
|
||||
// Агент видит новую воронку.
|
||||
expect((await pipeline(a, 'object')).names[2]).toBe('Оценка объекта');
|
||||
|
||||
// Системный статус удалить нельзя; занятый — только с переводом карточек.
|
||||
expect((await c.boss.api.del(`/api/pipeline-statuses/${p.status('taken')}`)).body.code).toBe('SYSTEM_STATUS');
|
||||
const leadId = await newLead(a);
|
||||
await a.api.post(`/api/leads/${leadId}/status`, { statusId: added });
|
||||
expect((await pipeline(c.boss, 'object')).statuses[2].count).toBe(1);
|
||||
expect((await c.boss.api.del(`/api/pipeline-statuses/${added}`)).body.code).toBe('STATUS_IN_USE');
|
||||
expect((await c.boss.api.del(`/api/pipeline-statuses/${added}?moveTo=${p.status('deal')}`)).body.code).toBe('BAD_STATUS');
|
||||
expect((await c.boss.api.del(`/api/pipeline-statuses/${added}?moveTo=${middle[0]}`)).status).toBe(200);
|
||||
const card = (await a.api.get(`/api/leads/${leadId}`)).body;
|
||||
expect(card.statusId).toBe(middle[0]);
|
||||
expect(card.events[0]).toMatchObject({ type: 'status', oldValue: 'Оценка объекта', newValue: 'Дозвонился' });
|
||||
expect((await pipeline(c.boss, 'object')).names).toHaveLength(8);
|
||||
expect((await a.api.post(`/api/leads/${leadId}/status`, { statusId: added })).body.code).toBe('BAD_STATUS');
|
||||
|
||||
// Воронка клиента настраивается отдельно; возврата в пул у неё нет.
|
||||
const cs = (await c.boss.api.post('/api/pipelines/client/statuses', { name: 'Ипотека' })).body.id as number;
|
||||
expect((await pipeline(c.boss, 'client')).names.slice(-3)).toEqual(['Ипотека', 'Сделка', 'Отказ']);
|
||||
expect((await c.boss.api.patch(`/api/pipeline-statuses/${cs}`, { poolReturn: true })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('причины отказа: добавить, переименовать, переставить, удалить; системную и последнюю — нельзя', async () => {
|
||||
const c = await company(1);
|
||||
const [a] = c.agents as [Member];
|
||||
const made = (await c.boss.api.post('/api/pipelines/client/reasons', { name: 'Уехал из страны' })).body.id as number;
|
||||
await c.boss.api.patch(`/api/reject-reasons/${made}`, { name: 'Уехал' });
|
||||
let p = await pipeline(a, 'client');
|
||||
expect(p.reasons.at(-1)).toMatchObject({ id: made, name: 'Уехал' });
|
||||
const ids = [made, ...p.reasons.filter((r: any) => r.id !== made).map((r: any) => r.id)];
|
||||
expect((await c.boss.api.post('/api/pipelines/client/reasons-order', { ids })).status).toBe(200);
|
||||
expect((await pipeline(a, 'client')).reasons[0].id).toBe(made);
|
||||
|
||||
// Клиент закрыт с причиной, причину потом удалили: в карточке она остаётся.
|
||||
const clientId = await newClient(a);
|
||||
await a.api.post(`/api/clients/${clientId}/status`, { statusId: p.status('rejected'), rejectReasonId: made });
|
||||
expect((await a.api.del(`/api/reject-reasons/${made}`)).status).toBe(403);
|
||||
expect((await c.boss.api.del(`/api/reject-reasons/${made}`)).status).toBe(200);
|
||||
expect((await a.api.get(`/api/clients/${clientId}`)).body.rejectReasonId).toBe(made);
|
||||
p = await pipeline(a, 'client');
|
||||
expect(p.reasons.some((r: any) => r.id === made)).toBe(false);
|
||||
|
||||
const po = await pipeline(c.boss, 'object');
|
||||
const agentReason = po.reasons.find((r: any) => r.code === 'agent').id;
|
||||
expect((await c.boss.api.del(`/api/reject-reasons/${agentReason}`)).body.code).toBe('SYSTEM_REASON');
|
||||
const rest = po.reasons.filter((r: any) => !r.code);
|
||||
for (const r of rest.slice(1)) expect((await c.boss.api.del(`/api/reject-reasons/${r.id}`)).status).toBe(200);
|
||||
expect((await c.boss.api.del(`/api/reject-reasons/${rest[0].id}`)).body.code).toBe('LAST_REASON');
|
||||
});
|
||||
|
||||
it('кто будет жить: состав, дети, животные; подбор не предлагает объект, где с ними нельзя', async () => {
|
||||
const c = await company();
|
||||
const agent = c.agents[0]!;
|
||||
const created = await agent.api.post('/api/clients', {
|
||||
name: 'Семья Саркисян', kind: 'tenant', request: {},
|
||||
household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' }, pets: { count: 1, age: 'кот, 2 года' } },
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const id = created.body.id as number;
|
||||
expect((await agent.api.get(`/api/clients/${id}`)).body.household).toEqual({
|
||||
who: 'couple', children: { count: 2, age: '3 и 7 лет' }, pets: { count: 1, age: 'кот, 2 года' },
|
||||
});
|
||||
expect((await agent.api.post('/api/clients', { name: 'Х', kind: 'tenant', household: { children: { count: 0 } } })).body.code).toBe('VALIDATION');
|
||||
|
||||
// Три объекта в аренду: с детьми нельзя; с животными нельзя; про правила ничего не сказано.
|
||||
const lead = async (title: string, features: object | null) => {
|
||||
const made = await agent.api.post('/api/leads', { title, dealType: 'rent', propertyType: 'apartment' });
|
||||
await t.db.update(leads).set({ features: features as never }).where(eq(leads.id, made.body.id));
|
||||
return title;
|
||||
};
|
||||
await lead('Без детей', { children: 'no', amenities: [], attrs: [] });
|
||||
await lead('Без животных', { children: 'yes', pets: 'no', amenities: [], attrs: [] });
|
||||
await lead('Можно всем', { children: 'yes', pets: 'negotiable', amenities: [], attrs: [] });
|
||||
await lead('Не указано', null);
|
||||
const titles = async () => ((await agent.api.get(`/api/clients/${id}/suggestions`)).body as any[]).map((x) => x.title).sort();
|
||||
expect(await titles()).toEqual(['Можно всем', 'Не указано']);
|
||||
|
||||
// Правка: животных больше нет — объект «без животных» появляется; остальное в карточке не затёрто.
|
||||
await agent.api.patch(`/api/clients/${id}`, { household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } } });
|
||||
expect(await titles()).toEqual(['Без животных', 'Можно всем', 'Не указано']);
|
||||
await agent.api.patch(`/api/clients/${id}`, { household: null });
|
||||
expect(await titles()).toHaveLength(4);
|
||||
expect((await agent.api.get(`/api/clients/${id}`)).body).toMatchObject({ name: 'Семья Саркисян', household: null });
|
||||
});
|
||||
|
||||
it('фото к клиенту: загрузка, просмотр, удаление; чужой агент и неподходящий файл получают отказ', async () => {
|
||||
const c = await company();
|
||||
const [agent, other] = c.agents as [typeof c.agents[0], typeof c.agents[0]];
|
||||
const id = (await agent.api.post('/api/clients', { name: 'С фото', kind: 'tenant' })).body.id as number;
|
||||
const login = async (m: typeof agent) => {
|
||||
const email = (await m.api.get('/api/me')).body.user.email as string;
|
||||
return (await t.client().post('/api/auth/login', { email, password: 'password-123' })).body.token as string;
|
||||
};
|
||||
const upload = async (token: string, type: string, body: Buffer) =>
|
||||
t.app.inject({ method: 'POST', url: `/api/clients/${id}/photos?type=${encodeURIComponent(type)}`, payload: body, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' } });
|
||||
const mine = await login(agent);
|
||||
const png = Buffer.from('89504e470d0a1a0a', 'hex');
|
||||
|
||||
const ok = await upload(mine, 'image/png', png);
|
||||
expect(ok.statusCode).toBe(200);
|
||||
const photoId = ok.json().id as number;
|
||||
expect((await upload(mine, 'application/pdf', png)).json().code).toBe('PHOTO_TYPE');
|
||||
expect((await upload(mine, 'image/png', Buffer.alloc(0))).statusCode).toBe(400);
|
||||
expect((await upload(await login(other), 'image/png', png)).statusCode).toBeGreaterThanOrEqual(403);
|
||||
|
||||
expect((await agent.api.get(`/api/clients/${id}`)).body.photos).toMatchObject([{ id: photoId }]);
|
||||
const file = await t.app.inject({ method: 'GET', url: `/api/clients/${id}/photos/${photoId}`, headers: { authorization: `Bearer ${mine}` } });
|
||||
expect(file.headers['content-type']).toBe('image/png');
|
||||
expect(file.rawPayload.equals(png)).toBe(true);
|
||||
// Руководитель видит фото; агент другой компании — нет.
|
||||
expect((await c.boss.api.get(`/api/clients/${id}`)).body.photos).toHaveLength(1);
|
||||
const stranger = await company();
|
||||
expect((await t.app.inject({ method: 'GET', url: `/api/clients/${id}/photos/${photoId}`, headers: { authorization: `Bearer ${await login(stranger.boss)}` } })).statusCode).toBe(404);
|
||||
|
||||
expect((await agent.api.del(`/api/clients/${id}/photos/${photoId}`)).status).toBe(200);
|
||||
expect((await agent.api.get(`/api/clients/${id}`)).body.photos).toEqual([]);
|
||||
});
|
||||
|
||||
it('комиссия агентства: руководитель задаёт условия, агент меняет их в карточке', async () => {
|
||||
const c = await company();
|
||||
const [agent, other] = c.agents as [typeof c.agents[0], typeof c.agents[0]];
|
||||
// Условия по умолчанию задаёт только руководитель.
|
||||
const terms = {
|
||||
objectSale: { mode: 'percent', value: 3, currency: 'USD' },
|
||||
objectRent: { mode: 'percent', value: 50, currency: 'USD' },
|
||||
clientBuyer: { mode: 'fixed', value: 1000, currency: 'USD' },
|
||||
clientTenant: null,
|
||||
};
|
||||
expect((await agent.api.patch('/api/company', { commission: terms })).status).toBe(403);
|
||||
expect((await c.boss.api.patch('/api/company', { commission: terms })).status).toBe(200);
|
||||
expect((await c.boss.api.get('/api/me')).body.company.settings.commission).toEqual(terms);
|
||||
// Остальные настройки компании при этом не тронуты.
|
||||
expect((await c.boss.api.get('/api/me')).body.company.settings.poolReturnHours).toBe(24);
|
||||
|
||||
// Объект на продажу: 3 % от цены; в аренду — 50 % месячной платы, в валюте объекта.
|
||||
const sale = await newLead(agent, { price: 150_000, currency: 'USD' });
|
||||
const rent = await newLead(agent, { dealType: 'rent', price: 400_000, currency: 'AMD' });
|
||||
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toEqual({ rule: terms.objectSale, custom: false, expected: { amount: 4500, currency: 'USD' } });
|
||||
expect((await agent.api.get(`/api/leads/${rent}`)).body.commission.expected).toEqual({ amount: 200_000, currency: 'AMD' });
|
||||
|
||||
// Агент договорился на 2,5 % по этому объекту; чужой агент условие менять не может.
|
||||
expect((await other.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: 1 } })).status).toBeGreaterThanOrEqual(403);
|
||||
expect((await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: 2.5 } })).status).toBe(200);
|
||||
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toMatchObject({ custom: true, rule: { mode: 'percent', value: 2.5 }, expected: { amount: 3750, currency: 'USD' } });
|
||||
expect((await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: -1 } })).body.code).toBe('VALIDATION');
|
||||
// Сброс возвращает условие компании.
|
||||
await agent.api.post(`/api/leads/${sale}/commission`, { rule: null });
|
||||
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toMatchObject({ custom: false, expected: { amount: 4500 } });
|
||||
|
||||
// Фикс + процент: $200 и 2 % от цены; в валюте цены — фикс приводится по курсу.
|
||||
await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'mixed', value: 2, fixed: 200, currency: 'USD' } });
|
||||
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission.expected).toEqual({ amount: 3200, currency: 'USD' });
|
||||
await agent.api.post(`/api/leads/${rent}/commission`, { rule: { mode: 'mixed', value: 50, fixed: 100, currency: 'USD' } });
|
||||
expect((await agent.api.get(`/api/leads/${rent}`)).body.commission.expected).toEqual({ amount: 238_500, currency: 'AMD' });
|
||||
await agent.api.post(`/api/leads/${sale}/commission`, { rule: null });
|
||||
|
||||
// История объекта: кто, с какого на какое условие; сброс — возврат к условию компании. Повтор того же значения записи не даёт.
|
||||
await agent.api.post(`/api/leads/${sale}/commission`, { rule: null });
|
||||
const history = ((await agent.api.get(`/api/leads/${sale}`)).body.events as any[]).filter((e) => e.type === 'commission').reverse();
|
||||
expect(history.map((e) => [e.userName, e.oldValue, e.newValue, e.text])).toEqual([
|
||||
// 3 % → 2,5 % от той же цены: ожидаемая сумма −16,7 %; возврат к 3 %: +20 %.
|
||||
['Агент 1', 'percent:3', 'percent:2.5', '|-16.7'],
|
||||
['Агент 1', 'percent:2.5', null, 'percent:3|20'],
|
||||
['Агент 1', 'percent:3', 'mixed:200:USD:2', '|-28.9'],
|
||||
['Агент 1', 'mixed:200:USD:2', null, 'percent:3|40.6'],
|
||||
]);
|
||||
|
||||
// Клиенты: покупателю — фикс; арендатору условие не задано, агент ставит своё — процент от бюджета.
|
||||
const buyer = await newClient(agent);
|
||||
expect((await agent.api.get(`/api/clients/${buyer}`)).body.commission).toEqual({ rule: terms.clientBuyer, custom: false, expected: { amount: 1000, currency: 'USD' } });
|
||||
const tenant = await newClient(agent, { kind: 'tenant', request: { priceTo: 600_000, priceCurrency: 'AMD' } });
|
||||
expect((await agent.api.get(`/api/clients/${tenant}`)).body.commission).toEqual({ rule: null, custom: false, expected: null });
|
||||
await agent.api.post(`/api/clients/${tenant}/commission`, { rule: { mode: 'percent', value: 50 } });
|
||||
expect((await agent.api.get(`/api/clients/${tenant}`)).body.commission).toMatchObject({ custom: true, expected: { amount: 300_000, currency: 'AMD' } });
|
||||
expect((await other.api.post(`/api/clients/${tenant}/commission`, { rule: null })).status).toBeGreaterThanOrEqual(403);
|
||||
// Руководитель поправил условие клиента — в истории клиента его имя и оба значения.
|
||||
await c.boss.api.post(`/api/clients/${tenant}/commission`, { rule: { mode: 'fixed', value: 300, currency: 'USD' } });
|
||||
const clientHistory = ((await agent.api.get(`/api/clients/${tenant}`)).body.events as any[]).filter((e) => e.type === 'commission').reverse();
|
||||
// Было не задано — сравнивать не с чем. 50 % от 600 000 драм (≈ $779) → фикс $300: −61,5 %.
|
||||
expect(clientHistory.map((e) => [e.userName, e.oldValue, e.newValue, e.text])).toEqual([
|
||||
['Агент 1', null, 'percent:50', '|'],
|
||||
['Руководитель', 'percent:50', 'fixed:300:USD', '|-61.5'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('оплата агентов: оклад, процент со сделок или оклад + процент; руководитель видит, кому сколько платить', async () => {
|
||||
const c = await company();
|
||||
const [a1, a2] = c.agents as [typeof c.agents[0], typeof c.agents[0]];
|
||||
const p = await pipeline(c.boss, 'object');
|
||||
// Условия задаёт только руководитель.
|
||||
expect((await a1.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 90 } })).status).toBe(403);
|
||||
expect((await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'mixed', fixed: 300, percent: 30, currency: 'USD' } })).status).toBe(200);
|
||||
expect((await c.boss.api.post(`/api/members/${a2.id}/pay`, { pay: { mode: 'percent', percent: 50, currency: 'AMD' } })).status).toBe(200);
|
||||
expect((await c.boss.api.post(`/api/members/${c.boss.id}/pay`, { pay: { mode: 'fixed', fixed: 1000, currency: 'USD' } })).status).toBe(200);
|
||||
expect((await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 150 } })).body.code).toBe('VALIDATION');
|
||||
expect((await c.boss.api.post('/api/members/999999/pay', { pay: null })).status).toBe(404);
|
||||
// Руководитель видит условия всех, агент — только свои.
|
||||
expect(((await c.boss.api.get('/api/members')).body as any[]).map((m) => m.pay?.mode)).toEqual(['fixed', 'mixed', 'percent']);
|
||||
expect(((await a1.api.get('/api/members')).body as any[]).map((m) => m.pay?.mode ?? null)).toEqual([null, 'mixed', null]);
|
||||
|
||||
// Сделки этого месяца: у первого агента $3000 и 385 000 драм (= $1000), у второго $2000.
|
||||
const today = new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10);
|
||||
const close = async (m: Member, commission: number, currency: string, date = today) => {
|
||||
const id = await newLead(m);
|
||||
await m.api.post(`/api/leads/${id}/status`, { statusId: p.status('deal'), deal: { commission, currency, date } });
|
||||
};
|
||||
await close(a1, 3000, 'USD');
|
||||
await close(a1, 385_000, 'AMD');
|
||||
await close(a2, 2000, 'USD');
|
||||
// Сделка прошлого года в текущий месяц не попадает.
|
||||
await close(a2, 9000, 'USD', '2020-01-15');
|
||||
|
||||
const month = (await c.boss.api.get('/api/payroll')).body;
|
||||
expect(month.months).toBe(1);
|
||||
const row = (id: number) => month.agents.find((x: any) => x.userId === id);
|
||||
// Оклад $300 + 30 % от $4000 = $1500.
|
||||
expect(row(a1.id)).toMatchObject({ deals: 2, commission: 4000, currency: 'USD', fixed: 300, share: 1200, total: 1500, byCurrency: { USD: 3000, AMD: 385_000 } });
|
||||
// 50 % от $2000, выплата в драмах: 385 000.
|
||||
expect(row(a2.id)).toMatchObject({ deals: 1, commission: 770_000, currency: 'AMD', fixed: 0, share: 385_000, total: 385_000 });
|
||||
expect(row(c.boss.id)).toMatchObject({ deals: 0, fixed: 1000, share: 0, total: 1000 });
|
||||
|
||||
// Свой период: полгода 2020-го — шесть окладов и старая сделка.
|
||||
const old = (await c.boss.api.get('/api/payroll?from=2020-01-01&to=2020-06-30')).body;
|
||||
expect(old.months).toBe(6);
|
||||
expect(old.agents.find((x: any) => x.userId === a1.id)).toMatchObject({ deals: 0, fixed: 1800, total: 1800 });
|
||||
expect(old.agents.find((x: any) => x.userId === a2.id)).toMatchObject({ deals: 1, share: 1_732_500 });
|
||||
|
||||
// Агент видит свои условия и начисления, чужие выплаты — нет.
|
||||
expect((await a1.api.get('/api/payroll')).status).toBe(403);
|
||||
expect((await a1.api.get('/api/my-pay')).body).toMatchObject({ userId: a1.id, pay: { mode: 'mixed' }, deals: 2, total: 1500 });
|
||||
// Условия сняли — начислений нет.
|
||||
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: null });
|
||||
expect((await a1.api.get('/api/my-pay')).body).toMatchObject({ pay: null, total: 0, deals: 2 });
|
||||
});
|
||||
|
||||
it('комиссия из двух частей: с собственника — агенту объекта, с клиента — агенту клиента; процент аренды свой', async () => {
|
||||
const c = await company();
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
|
||||
const po = await pipeline(c.boss, 'object');
|
||||
const pc = await pipeline(c.boss, 'client');
|
||||
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 40, percentRent: 50, currency: 'USD' } });
|
||||
await c.boss.api.post(`/api/members/${a2.id}/pay`, { pay: { mode: 'percent', percent: 30, currency: 'USD' } });
|
||||
const today = new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10);
|
||||
|
||||
// Объект в аренду ведёт первый агент, арендатора — второй. Сделку закрывает второй.
|
||||
const leadId = await newLead(a1, { dealType: 'rent', price: 1000 });
|
||||
const clientId = await newClient(a2, { kind: 'tenant' });
|
||||
await a2.api.post(`/api/clients/${clientId}/leads`, { leadId });
|
||||
const body = { statusId: pc.status('deal'), deal: { ownerCommission: 400, clientCommission: 600, currency: 'USD', date: today, leadId } };
|
||||
// Сделка без комиссии не закрывается.
|
||||
expect((await a2.api.post(`/api/clients/${clientId}/status`, { ...body, deal: { ...body.deal, ownerCommission: 0, clientCommission: 0 } })).body.code).toBe('COMMISSION_REQUIRED');
|
||||
expect((await a2.api.post(`/api/clients/${clientId}/status`, body)).status).toBe(200);
|
||||
expect((await a1.api.get(`/api/leads/${leadId}`)).body.deal).toMatchObject({
|
||||
commission: 1000, ownerCommission: 400, clientCommission: 600, ownerAgentName: 'Агент 1', clientAgentName: 'Агент 2', kind: 'rent',
|
||||
});
|
||||
|
||||
const rows = async () => (await c.boss.api.get('/api/payroll')).body.agents as any[];
|
||||
const row = async (id: number) => (await rows()).find((x) => x.userId === id);
|
||||
// Первому — 50 % (аренда) от $400, второму — 30 % (общий процент) от $600.
|
||||
expect(await row(a1.id)).toMatchObject({ deals: 1, commission: 400, commissionRent: 400, commissionSale: 0, shareRent: 200, share: 200, total: 200 });
|
||||
expect(await row(a2.id)).toMatchObject({ deals: 1, commission: 600, share: 180, total: 180 });
|
||||
|
||||
// Список сделок агента: руководителю — любого, агенту — только свои.
|
||||
const list = (await c.boss.api.get(`/api/payroll/deals?userId=${a1.id}`)).body;
|
||||
expect(list.deals).toEqual([
|
||||
expect.objectContaining({ kind: 'rent', side: 'owner', partner: 'Агент 2', leadId, clientId, clientName: 'Анна', dealCommission: 1000, part: 400, percent: 50, share: 200 }),
|
||||
]);
|
||||
const own = (await a2.api.get(`/api/payroll/deals?userId=${a1.id}`)).body;
|
||||
expect(own).toMatchObject({ userId: a2.id, deals: [{ side: 'client', partner: 'Агент 1', part: 600, percent: 30, share: 180 }] });
|
||||
|
||||
// В аналитике сделка одна, комиссия разнесена по агентам.
|
||||
const an = (await c.boss.api.get(`/api/analytics?pipeline=object&from=${today}&to=${today}`)).body;
|
||||
expect(an.totals).toMatchObject({ deals: 1, commission: { USD: 1000 } });
|
||||
expect(an.agents.find((x: any) => x.userId === a1.id)).toMatchObject({ deals: 1, commission: { USD: 400 } });
|
||||
expect(an.agents.find((x: any) => x.userId === a2.id)).toMatchObject({ deals: 1, commission: { USD: 600 } });
|
||||
|
||||
// Руководитель правит части; сумма одной строкой остаётся на прежней стороне.
|
||||
const dealId = list.deals[0].id;
|
||||
expect((await a1.api.patch(`/api/deals/${dealId}`, { ownerCommission: 1, clientCommission: 1, currency: 'USD', date: today })).status).toBe(403);
|
||||
expect((await c.boss.api.patch(`/api/deals/${dealId}`, { ownerCommission: 500, clientCommission: 700, currency: 'USD', date: today })).status).toBe(200);
|
||||
expect(await row(a1.id)).toMatchObject({ commission: 500, share: 250 });
|
||||
expect(await row(a2.id)).toMatchObject({ commission: 700, share: 210 });
|
||||
|
||||
// Объект вышел из сделки — запись остаётся за клиентом, обе части получает его агент.
|
||||
await c.boss.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('taken') });
|
||||
expect(await row(a1.id)).toMatchObject({ deals: 0, commission: 0, total: 0 });
|
||||
expect(await row(a2.id)).toMatchObject({ deals: 1, commission: 1200, share: 360 });
|
||||
|
||||
// Продажа одной суммой, как раньше: вся комиссия — агенту, который закрыл свой объект.
|
||||
const sale = await newLead(a1);
|
||||
await a1.api.post(`/api/leads/${sale}/status`, { statusId: po.status('deal'), deal: { commission: 2000, currency: 'USD', date: today } });
|
||||
expect(await row(a1.id)).toMatchObject({ deals: 1, commissionSale: 2000, commissionRent: 0, shareSale: 800, total: 800 });
|
||||
});
|
||||
|
||||
it('план и бонус, ручные начисления, «выплачено»: закрытый месяц не пересчитывается, разница уходит в следующий', async () => {
|
||||
const c = await company(1);
|
||||
const [a1] = c.agents as [Member];
|
||||
const po = await pipeline(c.boss, 'object');
|
||||
const ym = (back: number) => {
|
||||
const d = new Date(Date.now() + 4 * 3_600_000);
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - back, 1)).toISOString().slice(0, 7);
|
||||
};
|
||||
const [m1, m2, m3] = [ym(2), ym(1), ym(0)];
|
||||
const range = (m: string) => {
|
||||
const [y, mm] = m.split('-').map(Number) as [number, number];
|
||||
return `from=${m}-01&to=${m}-${new Date(Date.UTC(y, mm, 0)).getUTCDate()}`;
|
||||
};
|
||||
const row = async (m: string) => ((await c.boss.api.get(`/api/payroll?${range(m)}`)).body.agents as any[]).find((x) => x.userId === a1.id);
|
||||
const close = async (commission: number, month: string) => {
|
||||
const id = await newLead(a1);
|
||||
await a1.api.post(`/api/leads/${id}/status`, { statusId: po.status('deal'), deal: { commission, currency: 'USD', date: `${month}-10` } });
|
||||
};
|
||||
const terms = { mode: 'mixed', fixed: 300, percent: 30, currency: 'USD', planDeals: 2, planCommission: 3000, bonusMode: 'fixed', bonusValue: 200 };
|
||||
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: terms });
|
||||
|
||||
// Одна сделка — план не выполнен; две на $3500 — выполнен, бонус $200.
|
||||
await close(2000, m1);
|
||||
expect(await row(m1)).toMatchObject({ deals: 1, share: 600, planDone: false, bonus: 0, total: 900 });
|
||||
await close(1500, m1);
|
||||
expect(await row(m1)).toMatchObject({ deals: 2, commission: 3500, share: 1050, planDone: true, bonus: 200, accrued: 1550, total: 1550 });
|
||||
|
||||
// Ручные записи: премия и аванс. Агент их не ставит.
|
||||
const adjust = (m: Member, amount: number, note: string, month = m1) => m.api.post('/api/payroll/adjustments', { userId: a1.id, month, amount, note });
|
||||
expect((await adjust(a1, 500, 'себе')).status).toBe(403);
|
||||
expect((await adjust(c.boss, 100, 'премия')).status).toBe(200);
|
||||
const advance = (await adjust(c.boss, -50, 'аванс')).body.id;
|
||||
expect((await adjust(c.boss, 0, 'ноль')).body.code).toBe('VALIDATION');
|
||||
expect((await adjust(c.boss, 10, 'будущее', '2999-01')).body.code).toBe('VALIDATION');
|
||||
expect(await row(m1)).toMatchObject({ adjustmentsTotal: 50, total: 1600, payout: null, adjustments: [{ amount: 100, note: 'премия' }, { amount: -50, note: 'аванс' }] });
|
||||
|
||||
// «Выплачено»: месяц закрыт, ручные записи и повторная отметка — нельзя.
|
||||
const pay = (month: string, extra: object = {}) => c.boss.api.post('/api/payroll/payouts', { userId: a1.id, month, ...extra });
|
||||
expect((await a1.api.post('/api/payroll/payouts', { userId: a1.id, month: m1 })).status).toBe(403);
|
||||
const first = (await pay(m1, { paidAt: `${m2}-05` })).body.id;
|
||||
expect(await row(m1)).toMatchObject({ total: 1600, debt: 0, payout: { id: first, amount: 1600, currency: 'USD', paidAt: `${m2}-05`, last: true } });
|
||||
expect((await pay(m1)).body.code).toBe('MONTH_PAID');
|
||||
expect((await adjust(c.boss, 10, 'поздно')).body.code).toBe('MONTH_PAID');
|
||||
expect((await c.boss.api.del(`/api/payroll/adjustments/${advance}`)).body.code).toBe('MONTH_PAID');
|
||||
|
||||
// Условия поменяли — закрытый месяц считается по прежним.
|
||||
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { ...terms, percent: 50 } });
|
||||
expect(await row(m1)).toMatchObject({ pay: { percent: 30 }, share: 1050, total: 1600, debt: 0 });
|
||||
expect(await row(m2)).toMatchObject({ pay: { percent: 50 }, fixed: 300, recalc: [], total: 300 });
|
||||
|
||||
// Сделку закрытого месяца исправили: +$1000 комиссии = +$300 агенту. Разница — в следующем месяце.
|
||||
const list = (await c.boss.api.get(`/api/payroll/deals?userId=${a1.id}&${range(m1)}`)).body.deals as any[];
|
||||
await c.boss.api.patch(`/api/deals/${list[1].id}`, { commission: 2500, currency: 'USD', date: `${m1}-10` });
|
||||
expect(await row(m1)).toMatchObject({ share: 1350, total: 1900, debt: 300, payout: { amount: 1600 } });
|
||||
expect(await row(m2)).toMatchObject({ recalc: [{ month: m1, amount: 300 }], recalcTotal: 300, total: 600 });
|
||||
|
||||
// Заплатили меньше начисленного — остаток идёт дальше; долг первого месяца погашен.
|
||||
const second = (await pay(m2, { amount: 500, note: 'частично' })).body.id;
|
||||
expect(await row(m1)).toMatchObject({ debt: 0, payout: { last: false } });
|
||||
expect(await row(m2)).toMatchObject({ total: 600, debt: 100, payout: { amount: 500, note: 'частично', last: true } });
|
||||
expect(await row(m3)).toMatchObject({ recalc: [{ month: m2, amount: 100 }], total: 400 });
|
||||
|
||||
// История выплат: видна руководителю и самому агенту.
|
||||
expect((await a1.api.get('/api/payroll/payouts')).body).toMatchObject([{ month: m2, amount: 500, accrued: 600 }, { month: m1, amount: 1600, accrued: 1600 }]);
|
||||
expect(((await c.boss.api.get(`/api/payroll/payouts?userId=${a1.id}`)).body as any[]).length).toBe(2);
|
||||
|
||||
// Отменить можно только последнюю отметку; после отмены разница снова ждёт в открытом месяце.
|
||||
expect((await c.boss.api.del(`/api/payroll/payouts/${first}`)).body.code).toBe('PAYOUT_NOT_LAST');
|
||||
expect((await c.boss.api.del(`/api/payroll/payouts/${second}`)).status).toBe(200);
|
||||
expect(await row(m2)).toMatchObject({ payout: null, recalc: [{ month: m1, amount: 300 }], total: 600 });
|
||||
expect(await row(m3)).toMatchObject({ recalc: [], total: 300 });
|
||||
|
||||
// Надбавка к проценту за план — на всю комиссию агента за месяц: 10 % от $4000.
|
||||
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { ...terms, planDeals: 1, planCommission: null, bonusMode: 'percent', bonusValue: 10 } });
|
||||
await close(4000, m3);
|
||||
expect(await row(m3)).toMatchObject({ share: 1200, planDone: true, bonus: 400, total: 1900 });
|
||||
// Рейтинг виден агенту: место, сделки, выполнение плана — без денег.
|
||||
const board = (await a1.api.get(`/api/rating?month=${m3}`)).body;
|
||||
expect(board.agents[0]).toEqual({ place: 1, userId: a1.id, name: 'Агент 1', deals: 1, planPercent: 100 });
|
||||
expect(board.agents[1]).toMatchObject({ place: 2, userId: c.boss.id, deals: 0, planPercent: null });
|
||||
// Динамика: руководителю — по всем, агенту — только своя; прошлые месяцы на месте.
|
||||
const trend = (await c.boss.api.get('/api/payroll/trend?months=3')).body;
|
||||
expect(trend.months).toEqual([m1, m2, m3]);
|
||||
expect(trend.agents.find((x: any) => x.userId === a1.id).points).toEqual([{ deals: 2, commission: 4500 }, { deals: 0, commission: 0 }, { deals: 1, commission: 4000 }]);
|
||||
expect(trend.totals.map((x: any) => x.deals)).toEqual([2, 0, 1]);
|
||||
expect(((await a1.api.get('/api/payroll/trend?months=3')).body.agents as any[]).map((x) => x.userId)).toEqual([a1.id]);
|
||||
// Отчёты в Telegram: выключены — не уходят; включены — раз за период, первого числа и в понедельник после 9 утра.
|
||||
const reports = async () => (await t.db.select().from(outbox).where(eq(outbox.userId, c.boss.id))).map((j) => (j.payload as any).raw as string).filter(Boolean);
|
||||
const firstDay = new Date(`${m3}-01T06:00:00Z`);
|
||||
expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0);
|
||||
await c.boss.api.patch('/api/company', { reportMonthly: true, reportWeekly: true });
|
||||
expect(await runReportJobs(t.db, t.config, new Date(`${m3}-01T03:00:00Z`))).toBe(0);
|
||||
expect(await runReportJobs(t.db, t.config, firstDay)).toBeGreaterThanOrEqual(1);
|
||||
expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0);
|
||||
const monthly = (await reports()).find((x) => x.startsWith('Итоги месяца'))!;
|
||||
expect(monthly).toContain('К выплате:');
|
||||
expect(monthly).toContain('• Агент 1 — $600');
|
||||
expect(monthly).toContain('/team');
|
||||
// Настройки компании отметку об отправке не стирают.
|
||||
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
|
||||
expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0);
|
||||
const monday = new Date(`${m3}-01T06:00:00Z`);
|
||||
monday.setUTCDate(monday.getUTCDate() + ((8 - monday.getUTCDay()) % 7 || 7));
|
||||
expect(await runReportJobs(t.db, t.config, monday)).toBe(1);
|
||||
expect((await reports()).some((x) => x.startsWith('Итоги недели'))).toBe(true);
|
||||
|
||||
// Свои начисления за прошлый месяц.
|
||||
expect((await a1.api.get(`/api/my-pay?month=${m1}`)).body).toMatchObject({ userId: a1.id, deals: 2, payout: { amount: 1600 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
import { CLIENT_KINDS, ClientRequest, CommissionRule, CURRENCIES, fitsHousehold, Household, matchesClientRequest, RENT_TERMS, type Currency, type DealType } from '@lr/shared';
|
||||
import { and, desc, eq, ilike, inArray, isNull, notInArray, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clientLeads, clientPages, clientPhotos, clients, companies, deals, departments, events, leads, pipelineStatuses, users } from '../db/schema.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts';
|
||||
import { clientViewings, matchingLeads, pageUrl } from '../showcase/service.ts';
|
||||
import { DealBody, dealInput, dealText, dealView } from '../deals/service.ts';
|
||||
import type { Guards } from '../leads/routes.ts';
|
||||
import { canSee, inDepartment, type Actor } from '../leads/service.ts';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts';
|
||||
import { onlySent } from '../lib/patch.ts';
|
||||
import {
|
||||
addClientComment,
|
||||
assignClient,
|
||||
changeClientStatus,
|
||||
createClient,
|
||||
deleteClient,
|
||||
linkLead,
|
||||
loadClient,
|
||||
setClientNextAction,
|
||||
unlinkLead,
|
||||
updateClient,
|
||||
type Client,
|
||||
} from './service.ts';
|
||||
|
||||
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||
const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined);
|
||||
|
||||
const ClientBody = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
phone: optionalText(40),
|
||||
telegram: optionalText(64),
|
||||
rentTerm: z.enum(RENT_TERMS).nullable().optional(),
|
||||
kind: z.enum(CLIENT_KINDS),
|
||||
request: ClientRequest.partial().default({}),
|
||||
household: Household.nullable().optional(),
|
||||
source: optionalText(120),
|
||||
});
|
||||
|
||||
/** Фото к карточке клиента: до 10 штук, каждое до 5 МБ. */
|
||||
const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
|
||||
const MAX_PHOTO = 5 * 1024 * 1024;
|
||||
const MAX_PHOTOS = 10;
|
||||
|
||||
export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config) {
|
||||
type Ctx = ReturnType<Guards['member']>;
|
||||
const actor = (ctx: Ctx): Actor => ctx;
|
||||
|
||||
/** Телефон и Telegram клиента видны ответственному и руководителю. */
|
||||
const present = (ctx: Ctx, c: Client, assigneeName?: string | null) => {
|
||||
const own = ctx.role === 'manager' || c.assigneeId === ctx.user.id;
|
||||
return {
|
||||
...c, phone: own ? c.phone : null, telegram: own ? c.telegram : null,
|
||||
phoneHidden: (Boolean(c.phone) || Boolean(c.telegram)) && !own, assigneeName: assigneeName ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/clients', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = z
|
||||
.object({
|
||||
tab: z.enum(['mine', 'all']).default('mine'),
|
||||
statusId: z.coerce.number().int().optional(),
|
||||
assigneeId: z.coerce.number().int().optional(),
|
||||
kind: z.enum(CLIENT_KINDS).optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
/** Руководителю — один отдел; none — клиенты без отдела. */
|
||||
departmentId: z.union([z.literal('none'), z.coerce.number().int().positive()]).optional(),
|
||||
})
|
||||
.parse(req.query);
|
||||
const seeAll = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
|
||||
if (q.tab === 'all' && !seeAll) throw forbidden();
|
||||
const where: (SQL | undefined)[] = [
|
||||
eq(clients.companyId, ctx.company.id),
|
||||
// Сотрудник видит своих клиентов и клиентов своего отдела.
|
||||
ctx.role === 'manager' ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)),
|
||||
];
|
||||
if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(clients.departmentId, q.departmentId === 'none' ? null : q.departmentId));
|
||||
if (q.tab === 'mine') where.push(eq(clients.assigneeId, ctx.user.id));
|
||||
if (q.statusId) where.push(eq(clients.statusId, q.statusId));
|
||||
if (q.assigneeId && q.tab === 'all') where.push(eq(clients.assigneeId, q.assigneeId));
|
||||
if (q.kind) where.push(eq(clients.kind, q.kind));
|
||||
if (q.q) {
|
||||
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
|
||||
const digits = q.q.replace(/\D/g, '');
|
||||
where.push(
|
||||
or(
|
||||
ilike(clients.name, like),
|
||||
// По телефону агент находит только своих клиентов — чужие номера скрыты.
|
||||
digits.length >= 4
|
||||
? and(ilike(clients.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(clients.assigneeId, ctx.user.id))
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
client: clients,
|
||||
assigneeName: users.name,
|
||||
departmentName: departments.name,
|
||||
leadsCount: sql<number>`(select count(*)::int from client_leads cl where cl.client_id = "clients"."id")`,
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(users, eq(users.id, clients.assigneeId))
|
||||
.leftJoin(departments, eq(departments.id, clients.departmentId))
|
||||
.where(and(...where))
|
||||
.orderBy(desc(clients.createdAt))
|
||||
.limit(300);
|
||||
return rows.map((r) => ({ ...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount }));
|
||||
});
|
||||
|
||||
app.get('/api/clients-summary', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const [row] = await db
|
||||
.select({
|
||||
mine: sql<number>`count(*) filter (where ${clients.assigneeId} = ${ctx.user.id})::int`,
|
||||
all: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.companyId, ctx.company.id),
|
||||
ctx.role === 'manager' ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)),
|
||||
),
|
||||
);
|
||||
return row ?? { mine: 0, all: 0 };
|
||||
});
|
||||
|
||||
/** Объекты, прикреплённые к клиенту; чужие лиды, невидимые агенту, в список не попадают. */
|
||||
const linkedLeads = async (ctx: Ctx, clientId: number) => {
|
||||
const rows = await db
|
||||
.select({ link: clientLeads, lead: leads, assigneeName: users.name })
|
||||
.from(clientLeads)
|
||||
.innerJoin(leads, eq(leads.id, clientLeads.leadId))
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.where(eq(clientLeads.clientId, clientId))
|
||||
.orderBy(desc(clientLeads.createdAt));
|
||||
return rows
|
||||
.filter((r) => canSee(actor(ctx), r.lead))
|
||||
.map(({ link, lead, assigneeName }) => ({
|
||||
id: lead.id, title: lead.title, price: lead.price, currency: lead.currency, dealType: lead.dealType,
|
||||
district: lead.district, region: lead.region, address: lead.address, photo: lead.photos[0] ?? null,
|
||||
statusId: lead.statusId, assigneeId: lead.assigneeId, assigneeName, state: link.state, shownAt: link.shownAt,
|
||||
}));
|
||||
};
|
||||
|
||||
app.get('/api/clients/:id', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const client = await loadClient(db, actor(ctx), id);
|
||||
const photos = await db
|
||||
.select({ id: clientPhotos.id, createdAt: clientPhotos.createdAt })
|
||||
.from(clientPhotos)
|
||||
.where(eq(clientPhotos.clientId, id))
|
||||
.orderBy(clientPhotos.id);
|
||||
const [history, assignee, linked, deal] = await Promise.all([
|
||||
db
|
||||
.select({ event: events, userName: users.name })
|
||||
.from(events)
|
||||
.leftJoin(users, eq(users.id, events.userId))
|
||||
.where(eq(events.clientId, id))
|
||||
.orderBy(desc(events.at), desc(events.id)),
|
||||
db.select({ name: users.name }).from(users).where(eq(users.id, client.assigneeId)),
|
||||
linkedLeads(ctx, id),
|
||||
db
|
||||
.select({ deal: deals, leadTitle: leads.title })
|
||||
.from(deals)
|
||||
.leftJoin(leads, eq(leads.id, deals.leadId))
|
||||
.where(eq(deals.clientId, id)),
|
||||
]);
|
||||
const names = new Map(
|
||||
(await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.companyId, ctx.company.id))).map(
|
||||
(u) => [String(u.id), u.name] as const,
|
||||
),
|
||||
);
|
||||
const byId = (v: string | null) => (v ? (names.get(v) ?? v) : null);
|
||||
const d = deal[0];
|
||||
const [page] = await db
|
||||
.select({ token: clientPages.token, slug: companies.slug })
|
||||
.from(clientPages)
|
||||
.innerJoin(companies, eq(companies.id, clientPages.companyId))
|
||||
.where(and(eq(clientPages.clientId, id), isNull(clientPages.revokedAt)));
|
||||
return {
|
||||
...present(ctx, client, assignee[0]?.name),
|
||||
photos,
|
||||
/** Комиссия агентства с клиента: условие и ожидаемая сумма от его бюджета. */
|
||||
commission: commissionView(
|
||||
ctx.company.settings.commission, client.kind === 'tenant' ? 'clientTenant' : 'clientBuyer', client.commission,
|
||||
client.request.priceTo, client.request.priceCurrency ?? 'USD',
|
||||
),
|
||||
/** Страница подборки для клиента, если ссылка создана. */
|
||||
pageUrl: page?.slug ? pageUrl(config, ctx.company, page.slug, page.token) : null,
|
||||
viewings: await clientViewings(db, id),
|
||||
leads: linked,
|
||||
deal: d ? { ...dealView(d.deal, names), leadId: d.deal.leadId, leadTitle: d.leadTitle } : null,
|
||||
events: history.map(({ event: e, userName }) => ({
|
||||
id: e.id, type: e.type, at: e.at, userName,
|
||||
oldValue: e.type === 'assigned' ? byId(e.oldValue) : e.oldValue,
|
||||
newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue,
|
||||
text: e.text,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Подбор: объекты компании, подходящие под запрос клиента и ещё не прикреплённые к нему.
|
||||
* Закрытые сделкой и отказом не предлагаются.
|
||||
*/
|
||||
app.get('/api/clients/:id/suggestions', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const client = await loadClient(db, actor(ctx), id);
|
||||
return (await matchingLeads(db, actor(ctx), client))
|
||||
.slice(0, 30)
|
||||
.map(({ lead, assigneeName }) => ({
|
||||
id: lead.id, title: lead.title, price: lead.price, currency: lead.currency, dealType: lead.dealType,
|
||||
district: lead.district, region: lead.region, address: lead.address, photo: lead.photos[0] ?? null,
|
||||
statusId: lead.statusId, assigneeId: lead.assigneeId, assigneeName,
|
||||
}));
|
||||
});
|
||||
|
||||
/** Условие комиссии для этого клиента; null возвращает условие из настроек компании. */
|
||||
app.post('/api/clients/:id/commission', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { rule } = z.object({ rule: CommissionRule.nullable() }).parse(req.body);
|
||||
const client = await loadClient(db, actor(ctx), id);
|
||||
if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT');
|
||||
const before = ruleCode(client.commission);
|
||||
const after = ruleCode(rule);
|
||||
if (before === after) return { ok: true };
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(clients).set({ commission: rule }).where(eq(clients.id, id));
|
||||
const settings = ctx.company.settings.commission?.[client.kind === 'tenant' ? 'clientTenant' : 'clientBuyer'];
|
||||
await tx.insert(events).values({
|
||||
companyId: ctx.company.id, clientId: id, userId: ctx.user.id, type: 'commission',
|
||||
oldValue: before ?? ruleCode(settings), newValue: after,
|
||||
text: commissionNote(
|
||||
after ? null : ruleCode(settings),
|
||||
commissionDelta(client.commission ?? settings, rule ?? settings, client.request.priceTo, client.request.priceCurrency ?? 'USD'),
|
||||
),
|
||||
});
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- фото к карточке клиента ---
|
||||
|
||||
const ownClient = async (ctx: Ctx, id: number) => {
|
||||
const client = await loadClient(db, actor(ctx), id);
|
||||
if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT');
|
||||
return client;
|
||||
};
|
||||
|
||||
/** Файл приходит телом запроса как есть (application/octet-stream), тип картинки — в адресе. */
|
||||
app.post('/api/clients/:id/photos', { bodyLimit: MAX_PHOTO + 1024 }, async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { type } = z.object({ type: z.string() }).parse(req.query);
|
||||
if (!PHOTO_TYPES.includes(type)) throw badRequest('PHOTO_TYPE');
|
||||
if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION');
|
||||
if (req.body.length > MAX_PHOTO) throw badRequest('FILE_TOO_LARGE');
|
||||
await ownClient(ctx, id);
|
||||
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, id));
|
||||
if (n >= MAX_PHOTOS) throw conflict('PHOTO_LIMIT');
|
||||
const [photo] = await db
|
||||
.insert(clientPhotos)
|
||||
.values({ clientId: id, companyId: ctx.company.id, contentType: type, size: req.body.length, data: req.body.toString('base64'), uploadedById: ctx.user.id })
|
||||
.returning({ id: clientPhotos.id });
|
||||
return { id: photo!.id };
|
||||
});
|
||||
|
||||
app.get('/api/clients/:id/photos/:photoId', async (req, reply) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const { photoId } = z.object({ photoId: z.coerce.number().int().positive() }).parse(req.params);
|
||||
await loadClient(db, actor(ctx), id);
|
||||
const [photo] = await db.select().from(clientPhotos).where(and(eq(clientPhotos.id, photoId), eq(clientPhotos.clientId, id)));
|
||||
if (!photo) throw notFound();
|
||||
return reply
|
||||
.header('content-type', photo.contentType)
|
||||
.header('cache-control', 'private, max-age=86400')
|
||||
.send(Buffer.from(photo.data, 'base64'));
|
||||
});
|
||||
|
||||
app.delete('/api/clients/:id/photos/:photoId', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { photoId } = z.object({ photoId: z.coerce.number().int().positive() }).parse(req.params);
|
||||
await ownClient(ctx, id);
|
||||
await db.delete(clientPhotos).where(and(eq(clientPhotos.id, photoId), eq(clientPhotos.clientId, id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/clients', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
// Кто создал клиента, тот и ответственный; передать другому можно в карточке.
|
||||
const client = await createClient(db, actor(ctx), ClientBody.parse(req.body));
|
||||
return { id: client.id };
|
||||
});
|
||||
|
||||
app.patch('/api/clients/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
// Пустые телефон, Telegram, срок и источник стираются, а не пропускаются.
|
||||
const body = onlySent(
|
||||
req.body,
|
||||
ClientBody.partial()
|
||||
.extend({
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
telegram: z.string().trim().max(64).optional(),
|
||||
source: z.string().trim().max(120).optional(),
|
||||
})
|
||||
.parse(req.body),
|
||||
);
|
||||
await updateClient(db, actor(ctx), Id.parse(req.params).id, {
|
||||
...body,
|
||||
...(body.source !== undefined ? { source: body.source || null } : {}),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Удалить карточку клиента: руководитель; со сделкой — только суперадмин. */
|
||||
app.delete('/api/clients/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await deleteClient(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/clients/:id/status', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({
|
||||
statusId: z.number().int().positive(),
|
||||
rejectReasonId: z.number().int().positive().optional(),
|
||||
deal: DealBody.extend({ leadId: z.number().int().positive().optional() }).optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
await changeClientStatus(db, actor(ctx), Id.parse(req.params).id, body);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/clients/:id/assign', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body);
|
||||
await assignClient(db, actor(ctx), Id.parse(req.params).id, userId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/clients/:id/comments', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { text } = z.object({ text: z.string().trim().min(1).max(5000) }).parse(req.body);
|
||||
await addClientComment(db, actor(ctx), Id.parse(req.params).id, text);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/clients/:id/next-action', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({ at: z.coerce.date().nullable(), note: z.string().trim().max(500).nullable().default(null) })
|
||||
.parse(req.body);
|
||||
await setClientNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/clients/:id/leads', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({ leadId: z.number().int().positive(), state: z.enum(['offered', 'shown']).default('offered') })
|
||||
.parse(req.body);
|
||||
await linkLead(db, actor(ctx), Id.parse(req.params).id, body.leadId, body.state);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete('/api/clients/:id/leads/:leadId', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const p = Id.extend({ leadId: z.coerce.number().int().positive() }).parse(req.params);
|
||||
await unlinkLead(db, actor(ctx), p.id, p.leadId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Части комиссии и дату сделки может исправить руководитель. */
|
||||
app.patch('/api/deals/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const body = DealBody.parse(req.body);
|
||||
await db.transaction(async (tx) => {
|
||||
const [deal] = await tx
|
||||
.select()
|
||||
.from(deals)
|
||||
.where(and(eq(deals.id, id), eq(deals.companyId, ctx.company.id)))
|
||||
.for('update');
|
||||
if (!deal) throw notFound();
|
||||
// Сумма без разбивки остаётся на той стороне, где была.
|
||||
const next = dealInput(body, deal.ownerCommission > 0 || deal.clientCommission === 0 ? 'owner' : 'client');
|
||||
await tx
|
||||
.update(deals)
|
||||
.set({ commission: next.commission, ownerCommission: next.ownerCommission, clientCommission: next.clientCommission, currency: next.currency, dealDate: next.date })
|
||||
.where(eq(deals.id, id));
|
||||
const base = { companyId: ctx.company.id, userId: ctx.user.id, type: 'deal_edited', text: dealText(next) };
|
||||
if (deal.leadId) await tx.insert(events).values({ ...base, leadId: deal.leadId });
|
||||
if (deal.clientId) await tx.insert(events).values({ ...base, clientId: deal.clientId });
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Клиенты агента, к которым можно прикрепить объект: из карточки объекта. */
|
||||
app.get('/api/clients-options', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const rows = await db
|
||||
.select({ id: clients.id, name: clients.name, kind: clients.kind, code: pipelineStatuses.code })
|
||||
.from(clients)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId))
|
||||
.where(
|
||||
and(
|
||||
eq(clients.companyId, ctx.company.id),
|
||||
ctx.role === 'manager' ? undefined : eq(clients.assigneeId, ctx.user.id),
|
||||
or(isNull(pipelineStatuses.code), eq(pipelineStatuses.code, 'new')),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(clients.createdAt))
|
||||
.limit(300);
|
||||
return rows.map(({ id, name, kind }) => ({ id, name, kind }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { normalizePhone, type ClientKind, type ClientRequest, type Household, type RentTerm } from '@lr/shared';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { Db, Tx } from '../db/client.ts';
|
||||
import { clientLeads, clients, companies, deals, events, rejectReasons, users } from '../db/schema.ts';
|
||||
import { dealInput, dealText, dropDealSide, moveLeadToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts';
|
||||
import { loadLead, sameDepartment, type Actor } from '../leads/service.ts';
|
||||
import { badRequest, forbidden, notFound } from '../lib/errors.ts';
|
||||
import { pipelineOf, statusByCode } from '../pipelines/service.ts';
|
||||
import { refreshStaleGallery } from '../showcase/service.ts';
|
||||
import { enqueueText } from '../telegram/outbox.ts';
|
||||
|
||||
// Воронка клиента (покупатель или арендатор). Клиента агент заводит вручную, он закреплён
|
||||
// за агентом: правила пула на клиентов не действуют, переназначает только руководитель.
|
||||
|
||||
export type Client = typeof clients.$inferSelect;
|
||||
|
||||
const isManager = (a: Actor) => a.role === 'manager';
|
||||
|
||||
/**
|
||||
* Клиента видят руководитель и ответственный; остальные агенты — при открытой видимости и только
|
||||
* клиентов своего отдела.
|
||||
*/
|
||||
export function canSeeClient(actor: Actor, client: Client): boolean {
|
||||
if (client.companyId !== actor.company.id) return false;
|
||||
if (isManager(actor) || client.assigneeId === actor.user.id) return true;
|
||||
return actor.company.settings.agentsSeeAll && sameDepartment(client.departmentId, actor.user.departmentId);
|
||||
}
|
||||
|
||||
function assertCanWork(actor: Actor, client: Client) {
|
||||
if (!isManager(actor) && client.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_CLIENT');
|
||||
}
|
||||
|
||||
export async function loadClient(tx: Tx, actor: Actor, clientId: number): Promise<Client> {
|
||||
const [client] = await tx
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(eq(clients.id, clientId), eq(clients.companyId, actor.company.id)));
|
||||
if (!client || !canSeeClient(actor, client)) throw notFound('CLIENT_NOT_FOUND');
|
||||
return client;
|
||||
}
|
||||
|
||||
async function logEvent(
|
||||
tx: Tx,
|
||||
client: Pick<Client, 'id' | 'companyId'>,
|
||||
userId: number | null,
|
||||
type: string,
|
||||
extra: { oldValue?: string | null; newValue?: string | null; text?: string | null; statusId?: number } = {},
|
||||
) {
|
||||
await tx.insert(events).values({ companyId: client.companyId, clientId: client.id, userId, type, ...extra });
|
||||
}
|
||||
|
||||
const touchCompany = (tx: Tx, companyId: number) =>
|
||||
tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId));
|
||||
|
||||
async function member(tx: Tx, companyId: number, userId: number) {
|
||||
const [user] = await tx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.id, userId), eq(users.companyId, companyId), isNull(users.removedAt)));
|
||||
if (!user) throw badRequest('BAD_ASSIGNEE');
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Telegram клиента в одном виде: @ник; ссылка t.me и ник без @ приводятся к нему. */
|
||||
export function normalizeTelegram(raw: string | null | undefined): string | null {
|
||||
const v = raw?.trim();
|
||||
if (!v) return null;
|
||||
const nick = /^(?:https?:\/\/)?(?:t\.me|telegram\.me)\/([A-Za-z0-9_]{4,32})\/?$/.exec(v)?.[1] ?? /^@?([A-Za-z0-9_]{4,32})$/.exec(v)?.[1];
|
||||
return nick ? `@${nick}` : v;
|
||||
}
|
||||
|
||||
export interface ClientInput {
|
||||
name: string;
|
||||
phone?: string;
|
||||
telegram?: string;
|
||||
rentTerm?: RentTerm | null;
|
||||
kind: ClientKind;
|
||||
request: Partial<ClientRequest>;
|
||||
/** Кто будет жить; null — сведения убраны. */
|
||||
household?: Household | null;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export async function createClient(db: Db, actor: Actor, input: ClientInput): Promise<Client> {
|
||||
return db.transaction(async (tx) => {
|
||||
const fresh = await statusByCode(tx, actor.company.id, 'new', 'client');
|
||||
const [client] = await tx
|
||||
.insert(clients)
|
||||
.values({
|
||||
...input,
|
||||
phone: normalizePhone(input.phone) ?? input.phone ?? null,
|
||||
telegram: normalizeTelegram(input.telegram),
|
||||
companyId: actor.company.id,
|
||||
assigneeId: actor.user.id,
|
||||
// Клиент попадает в отдел того, кто его завёл.
|
||||
departmentId: actor.user.departmentId,
|
||||
statusId: fresh.id,
|
||||
})
|
||||
.returning();
|
||||
await logEvent(tx, client!, actor.user.id, 'created');
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return client!;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClient(
|
||||
db: Db,
|
||||
actor: Actor,
|
||||
clientId: number,
|
||||
patch: Partial<Omit<ClientInput, 'assigneeId' | 'source'>> & { source?: string | null },
|
||||
) {
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
assertCanWork(actor, client);
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({
|
||||
...patch,
|
||||
...(patch.phone !== undefined ? { phone: normalizePhone(patch.phone) ?? (patch.phone || null) } : {}),
|
||||
...(patch.telegram !== undefined ? { telegram: normalizeTelegram(patch.telegram) } : {}),
|
||||
})
|
||||
.where(eq(clients.id, clientId));
|
||||
await logEvent(tx, client, actor.user.id, 'edited');
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** Удаление карточки клиента вместе с историей и фото: как у объекта — руководитель, со сделкой — только суперадмин. */
|
||||
export async function deleteClient(db: Db, actor: Actor, clientId: number) {
|
||||
if (!isManager(actor)) throw forbidden();
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.clientId, client.id));
|
||||
if (deal) {
|
||||
if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL');
|
||||
await dropDealSide(tx, 'client', client.id);
|
||||
}
|
||||
await tx.delete(events).where(eq(events.clientId, client.id));
|
||||
await tx.delete(clients).where(eq(clients.id, client.id));
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ClientStatusChange {
|
||||
statusId: number;
|
||||
rejectReasonId?: number;
|
||||
/** Обязательна при переводе в «Сделка». leadId — объект из CRM, с которым прошла сделка. */
|
||||
deal?: DealBody & { leadId?: number };
|
||||
}
|
||||
|
||||
export async function changeClientStatus(db: Db, actor: Actor, clientId: number, change: ClientStatusChange) {
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
assertCanWork(actor, client);
|
||||
const statuses = await pipelineOf(tx, actor.company.id, 'client');
|
||||
const from = statuses.find((s) => s.id === client.statusId);
|
||||
const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt);
|
||||
if (!to) throw badRequest('BAD_STATUS');
|
||||
if (to.id === client.statusId) return;
|
||||
if (from?.code === 'deal' && !isManager(actor)) throw forbidden('DEAL_CLOSED');
|
||||
|
||||
let rejectReasonId: number | null = null;
|
||||
if (to.code === 'rejected') {
|
||||
const [reason] = change.rejectReasonId
|
||||
? await tx
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(
|
||||
and(
|
||||
eq(rejectReasons.id, change.rejectReasonId),
|
||||
eq(rejectReasons.companyId, actor.company.id),
|
||||
eq(rejectReasons.pipeline, 'client'),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (!reason) throw badRequest('REJECT_REASON_REQUIRED');
|
||||
rejectReasonId = reason.id;
|
||||
}
|
||||
let deal: DealInput | null = null;
|
||||
if (to.code === 'deal') {
|
||||
if (!change.deal) throw badRequest('COMMISSION_REQUIRED');
|
||||
deal = dealInput(change.deal, 'client');
|
||||
if (change.deal.leadId) {
|
||||
// Объект пары должен быть виден агенту; в «Сделка» он переходит вместе с клиентом.
|
||||
await loadLead(tx, actor, change.deal.leadId);
|
||||
await moveLeadToDeal(tx, actor.company.id, actor.user.id, clientId, change.deal.leadId, deal);
|
||||
}
|
||||
await recordDeal(tx, {
|
||||
companyId: actor.company.id,
|
||||
leadId: change.deal.leadId ?? null,
|
||||
clientId,
|
||||
agentId: client.assigneeId,
|
||||
deal,
|
||||
});
|
||||
} else if (from?.code === 'deal') {
|
||||
await dropDealSide(tx, 'client', clientId);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
await logEvent(tx, client, actor.user.id, to.code === 'deal' ? 'deal' : 'status', {
|
||||
oldValue: from?.name ?? null,
|
||||
newValue: to.name,
|
||||
statusId: to.id,
|
||||
text: deal ? dealText(deal) : null,
|
||||
});
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** Переназначить клиента может только руководитель. Оба агента получают уведомление. */
|
||||
export async function assignClient(db: Db, actor: Actor, clientId: number, toUserId: number) {
|
||||
if (!isManager(actor)) throw forbidden();
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
if (client.assigneeId === toUserId) return;
|
||||
const target = await member(tx, actor.company.id, toUserId);
|
||||
// Клиент переходит в отдел нового ответственного.
|
||||
await tx.update(clients).set({ assigneeId: toUserId, departmentId: target.departmentId }).where(eq(clients.id, clientId));
|
||||
await logEvent(tx, client, actor.user.id, 'assigned', {
|
||||
oldValue: String(client.assigneeId),
|
||||
newValue: String(toUserId),
|
||||
});
|
||||
if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'clientAssigned', [client.name]);
|
||||
if (client.assigneeId !== actor.user.id) {
|
||||
await enqueueText(tx, [client.assigneeId], 'clientReassignedAway', [client.name, target.name]);
|
||||
}
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function addClientComment(db: Db, actor: Actor, clientId: number, text: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
assertCanWork(actor, client);
|
||||
await logEvent(tx, client, actor.user.id, 'comment', { text });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setClientNextAction(db: Db, actor: Actor, clientId: number, at: Date | null, note: string | null) {
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
assertCanWork(actor, client);
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null })
|
||||
.where(eq(clients.id, clientId));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепить объект к клиенту: «предложен» или «показан». Повторный вызов меняет отметку.
|
||||
* Событие пишется и в историю клиента, и в историю объекта.
|
||||
*/
|
||||
export async function linkLead(db: Db, actor: Actor, clientId: number, leadId: number, state: 'offered' | 'shown') {
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
assertCanWork(actor, client);
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
const [before] = await tx
|
||||
.select()
|
||||
.from(clientLeads)
|
||||
.where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId)));
|
||||
if (before?.state === state) return;
|
||||
const shownAt = state === 'shown' ? new Date() : null;
|
||||
await tx
|
||||
.insert(clientLeads)
|
||||
.values({ clientId, leadId, state, shownAt })
|
||||
.onConflictDoUpdate({ target: [clientLeads.clientId, clientLeads.leadId], set: { state, shownAt } });
|
||||
if (!before) await refreshStaleGallery(tx, [leadId]);
|
||||
await logEvent(tx, client, actor.user.id, state, { newValue: lead.title });
|
||||
await tx.insert(events).values({
|
||||
companyId: actor.company.id, leadId, userId: actor.user.id, type: `client_${state}`, newValue: client.name,
|
||||
});
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function unlinkLead(db: Db, actor: Actor, clientId: number, leadId: number) {
|
||||
await db.transaction(async (tx) => {
|
||||
const client = await loadClient(tx, actor, clientId);
|
||||
assertCanWork(actor, client);
|
||||
const [gone] = await tx
|
||||
.delete(clientLeads)
|
||||
.where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId)))
|
||||
.returning();
|
||||
if (!gone) return;
|
||||
const lead = await loadLead(tx, actor, leadId).catch(() => null);
|
||||
await logEvent(tx, client, actor.user.id, 'unlinked', { newValue: lead?.title ?? null });
|
||||
});
|
||||
}
|
||||
|
||||
/** Напоминания о следующем действии по клиентам. Запускается раз в минуту вместе с задачами по лидам. */
|
||||
export async function runClientJobs(db: Db, now = new Date()): Promise<{ reminded: number }> {
|
||||
const due = await db
|
||||
.update(clients)
|
||||
.set({ nextActionNotifiedAt: now })
|
||||
.where(and(sql`${clients.nextActionAt} <= ${now.toISOString()}::timestamptz`, isNull(clients.nextActionNotifiedAt)))
|
||||
.returning();
|
||||
for (const c of due) await enqueueText(db, [c.assigneeId], 'clientReminder', [c.name, c.nextActionNote]);
|
||||
return { reminded: due.length };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
allowedBillingModels,
|
||||
DEFAULT_GRACE_DAYS,
|
||||
DEFAULT_REJECT_REASONS,
|
||||
DEFAULT_STATUSES,
|
||||
PIPELINES,
|
||||
PLAN_SPECS,
|
||||
POOL_RETURN_DEFAULT_HOURS,
|
||||
type BillingModel,
|
||||
type Lang,
|
||||
type Plan,
|
||||
} from '@lr/shared';
|
||||
import { and, count, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { Db, Tx } from '../db/client.ts';
|
||||
import { companies, invites, pipelineStatuses, rejectReasons, users } from '../db/schema.ts';
|
||||
import { newToken } from '../auth/sessions.ts';
|
||||
import { badRequest, conflict, notFound } from '../lib/errors.ts';
|
||||
import { freeSlug } from '../showcase/service.ts';
|
||||
|
||||
export interface NewCompany {
|
||||
name: string;
|
||||
plan: Plan;
|
||||
billingModel: BillingModel;
|
||||
/** Язык, на котором создаются статусы и причины отказа по умолчанию. */
|
||||
lang: Lang;
|
||||
/** Суперадмин включает доступ сразу; при самостоятельной регистрации — после оплаты. */
|
||||
accessEnabled?: boolean;
|
||||
subscriptionEndsAt?: Date | null;
|
||||
}
|
||||
|
||||
/** Создаёт компанию с воронками и причинами отказа по умолчанию. */
|
||||
export async function createCompany(tx: Tx, input: NewCompany) {
|
||||
if (!allowedBillingModels(input.plan).includes(input.billingModel)) {
|
||||
throw badRequest('BILLING_MODEL_NOT_ALLOWED');
|
||||
}
|
||||
const [company] = await tx
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: input.name,
|
||||
plan: input.plan,
|
||||
billingModel: input.billingModel,
|
||||
graceDays: DEFAULT_GRACE_DAYS,
|
||||
// Компания, включённая суперадмином, сразу на модели «%»; при самостоятельном подключении — с оплаты.
|
||||
percentFrom:
|
||||
input.billingModel === 'percent' && input.subscriptionEndsAt
|
||||
? new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10)
|
||||
: null,
|
||||
accessEnabled: input.accessEnabled ?? false,
|
||||
subscriptionEndsAt: input.subscriptionEndsAt ?? null,
|
||||
settings: {
|
||||
agentsSeeAll: false,
|
||||
poolReturnHours: POOL_RETURN_DEFAULT_HOURS,
|
||||
agentRules: { useAgencyBadge: true, phoneThreshold: 3 },
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
// Адрес страниц для клиентов — из названия компании; руководитель может поменять его в настройках.
|
||||
company!.slug = await freeSlug(tx, company!.name, company!.id);
|
||||
await tx.update(companies).set({ slug: company!.slug }).where(eq(companies.id, company!.id));
|
||||
for (const pipeline of PIPELINES) {
|
||||
await tx.insert(pipelineStatuses).values(
|
||||
DEFAULT_STATUSES[pipeline].map((s, i) => ({
|
||||
companyId: company!.id,
|
||||
pipeline,
|
||||
code: s.code ?? null,
|
||||
name: s.name[input.lang],
|
||||
position: i,
|
||||
poolReturn: s.poolReturn ?? false,
|
||||
})),
|
||||
);
|
||||
await tx.insert(rejectReasons).values(
|
||||
DEFAULT_REJECT_REASONS[pipeline].map((r, i) => ({
|
||||
companyId: company!.id,
|
||||
pipeline,
|
||||
code: r.code ?? null,
|
||||
name: r[input.lang],
|
||||
position: i,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return company!;
|
||||
}
|
||||
|
||||
const activeMembers = (companyId: number) =>
|
||||
and(eq(users.companyId, companyId), isNull(users.removedAt));
|
||||
|
||||
export async function memberCount(tx: Tx, companyId: number): Promise<number> {
|
||||
const [row] = await tx.select({ n: count() }).from(users).where(activeMembers(companyId));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export async function createInvite(db: Db, companyId: number, createdById: number) {
|
||||
const [invite] = await db
|
||||
.insert(invites)
|
||||
.values({ token: newToken(), companyId, createdById })
|
||||
.returning();
|
||||
return invite!;
|
||||
}
|
||||
|
||||
export interface JoinResult {
|
||||
companyId: number;
|
||||
/** После этого вступления компания упёрлась в лимит тарифа. */
|
||||
limitReached: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Принимает приглашение: пользователь становится агентом компании.
|
||||
* Лимит участников проверяется под блокировкой строки компании, чтобы два одновременных
|
||||
* вступления не превысили тариф.
|
||||
*/
|
||||
export async function acceptInvite(db: Db, token: string, userId: number): Promise<JoinResult> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [invite] = await tx.select().from(invites).where(eq(invites.token, token));
|
||||
if (!invite || invite.revokedAt) throw notFound('INVITE_NOT_FOUND');
|
||||
const [company] = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, invite.companyId))
|
||||
.for('update');
|
||||
if (!company) throw notFound('INVITE_NOT_FOUND');
|
||||
|
||||
const [user] = await tx.select().from(users).where(eq(users.id, userId));
|
||||
if (!user) throw notFound();
|
||||
if (user.companyId === company.id && !user.removedAt) {
|
||||
return { companyId: company.id, limitReached: false };
|
||||
}
|
||||
if (user.companyId && !user.removedAt) throw conflict('ALREADY_IN_COMPANY');
|
||||
if (user.role === 'superadmin') throw conflict('ALREADY_IN_COMPANY');
|
||||
|
||||
const max = PLAN_SPECS[company.plan as Plan].maxMembers;
|
||||
const current = await memberCount(tx, company.id);
|
||||
if (current >= max) throw conflict('MEMBER_LIMIT_REACHED');
|
||||
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ companyId: company.id, role: invite.role, removedAt: null })
|
||||
.where(eq(users.id, userId));
|
||||
if (invite.role === 'manager') {
|
||||
await tx.update(invites).set({ revokedAt: new Date() }).where(eq(invites.token, token));
|
||||
}
|
||||
await tx
|
||||
.update(companies)
|
||||
.set({ lastActivityAt: sql`now()` })
|
||||
.where(eq(companies.id, company.id));
|
||||
return { companyId: company.id, limitReached: current + 1 >= max };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema.ts';
|
||||
|
||||
export function connect(url: string) {
|
||||
const sql = postgres(url, { onnotice: () => {} });
|
||||
return { db: drizzle(sql, { schema }), close: () => sql.end() };
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof connect>['db'];
|
||||
/** Транзакция или обычное подключение — сервисы принимают оба. */
|
||||
export type Tx = Parameters<Parameters<Db['transaction']>[0]>[0] | Db;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { connect } from './client.ts';
|
||||
|
||||
export const MIGRATIONS_DIR = fileURLToPath(new URL('../../drizzle', import.meta.url));
|
||||
|
||||
if (import.meta.filename === process.argv[1]) {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL не задан');
|
||||
const { db, close } = connect(url);
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
await close();
|
||||
console.log('Миграции применены');
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
import type { AgentPay, AgentRules, CommissionRule, CommissionSettings, FilterParams, Household, ListingFeatures } from '@lr/shared';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// Схема данных (ТЗ, раздел 9). Данные компаний изолированы: у каждой бизнес-таблицы есть
|
||||
// company_id, и каждый запрос API фильтруется по компании пользователя.
|
||||
|
||||
const ts = (name: string) => timestamp(name, { withTimezone: true });
|
||||
const createdAt = ts('created_at').notNull().defaultNow();
|
||||
const money = (name: string) => numeric(name, { precision: 14, scale: 2, mode: 'number' });
|
||||
|
||||
export interface CompanySettings {
|
||||
/** «Агенты видят все лиды компании». */
|
||||
agentsSeeAll: boolean;
|
||||
/** Объявление сняли с List.am — открытые лиды по нему сами уходят в «Отказ» («Уже продано или сдано»). */
|
||||
autoRejectRemoved?: boolean;
|
||||
/** Через сколько часов без смены статуса взятый лид возвращается в пул. */
|
||||
poolReturnHours: number;
|
||||
agentRules: AgentRules;
|
||||
/** Комиссия агентства по умолчанию: для объектов и клиентов, продажи и аренды. */
|
||||
commission?: CommissionSettings;
|
||||
/** Отчёты руководителю в Telegram: за неделю и за месяц. sent* — за какой период отчёт уже ушёл. */
|
||||
reports?: { weekly: boolean; monthly: boolean; sentWeekly?: string; sentMonthly?: string };
|
||||
/** Свой домен для ссылок клиентам (например, arev.yandp.tech); пусто — адрес сервиса. Задаёт суперадмин. */
|
||||
clientHost?: string;
|
||||
/**
|
||||
* Бот компании, который присылает в её группу объекты, взятые в работу с указанной комиссией.
|
||||
* Задаёт суперадмин; токен наружу не отдаётся.
|
||||
*/
|
||||
groupBot?: { token: string; chatId: number; /** Топик группы («Свободные объекты»); пусто — общий чат. */ threadId?: number };
|
||||
/**
|
||||
* Витрина на сайте BarevClub: по ключу сайт берёт объекты компании и присылает заявки на показ.
|
||||
* threadId — топик группы, куда бот группы пишет о новой заявке. Задаёт суперадмин; ключ наружу не отдаётся.
|
||||
*/
|
||||
barevClub?: { key: string; threadId?: number };
|
||||
}
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
/** Короткое имя компании в адресе страниц для клиентов: listcrm…/<slug>/<код>. */
|
||||
slug: text('slug').unique(),
|
||||
/** solo | team | agency */
|
||||
plan: text('plan').notNull(),
|
||||
/** fixed | percent */
|
||||
billingModel: text('billing_model').notNull().default('fixed'),
|
||||
/** Запланированная смена тарифа и модели: вступает в силу с plannedFrom. */
|
||||
plannedPlan: text('planned_plan'),
|
||||
plannedBillingModel: text('planned_billing_model'),
|
||||
plannedFrom: date('planned_from'),
|
||||
subscriptionEndsAt: ts('subscription_ends_at'),
|
||||
graceDays: integer('grace_days').notNull().default(15),
|
||||
/** Ручной выключатель суперадмина. */
|
||||
accessEnabled: boolean('access_enabled').notNull().default(false),
|
||||
setupFeeInvoiced: boolean('setup_fee_invoiced').notNull().default(false),
|
||||
/** С какой даты компания на модели «%»: сделки до неё в счёт по комиссии не попадают. */
|
||||
percentFrom: date('percent_from'),
|
||||
/** Ключ последнего напоминания о подписке — чтобы не слать одно и то же дважды. */
|
||||
billingNotice: text('billing_notice'),
|
||||
settings: jsonb('settings').$type<CompanySettings>().notNull(),
|
||||
lastActivityAt: ts('last_activity_at'),
|
||||
createdAt,
|
||||
});
|
||||
|
||||
/**
|
||||
* Отдел компании: свои сотрудники, фильтры, объекты и клиенты. Сотрудник отдела видит только данные
|
||||
* своего отдела; руководитель и суперадмин — все отделы. Без отделов компания работает как раньше:
|
||||
* у всех данных отдел пустой.
|
||||
*/
|
||||
export const departments = pgTable(
|
||||
'departments',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
name: text('name').notNull(),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('departments_company_idx').on(t.companyId)],
|
||||
);
|
||||
|
||||
/** Отдел записи: null — «без отдела» (общие данные компании). */
|
||||
const departmentId = () => integer('department_id').references(() => departments.id, { onDelete: 'set null' });
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** null — суперадмин либо пользователь, ещё не создавший компанию. */
|
||||
companyId: integer('company_id').references(() => companies.id),
|
||||
/** superadmin | manager | agent */
|
||||
role: text('role').notNull(),
|
||||
name: text('name').notNull(),
|
||||
telegramId: bigint('telegram_id', { mode: 'number' }),
|
||||
telegramUsername: text('telegram_username'),
|
||||
email: text('email'),
|
||||
emailVerifiedAt: ts('email_verified_at'),
|
||||
passwordHash: text('password_hash'),
|
||||
lang: text('lang').notNull().default('ru'),
|
||||
/** Тихие часы по времени Еревана, «HH:MM». */
|
||||
quietFrom: text('quiet_from'),
|
||||
quietTo: text('quiet_to'),
|
||||
notificationsPaused: boolean('notifications_paused').notNull().default(false),
|
||||
/** Одноразовый код привязки Telegram: t.me/<бот>?start=<код>. */
|
||||
telegramLinkToken: text('telegram_link_token'),
|
||||
/** Условия оплаты агента: оклад, процент с его сделок или оклад + процент; план на месяц и бонус за него. Задаёт руководитель. */
|
||||
pay: jsonb('pay').$type<AgentPay>(),
|
||||
/** Мастер первой настройки пройден или пропущен. */
|
||||
onboardedAt: ts('onboarded_at'),
|
||||
/** Удалённый из компании агент: вход закрыт, история сохраняется. */
|
||||
removedAt: ts('removed_at'),
|
||||
/** Отдел сотрудника; null — без отдела. Назначает руководитель. */
|
||||
departmentId: departmentId(),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('users_telegram_uq').on(t.telegramId),
|
||||
uniqueIndex('users_email_uq').on(sql`lower(${t.email})`),
|
||||
index('users_company_idx').on(t.companyId),
|
||||
],
|
||||
);
|
||||
|
||||
export const sessions = pgTable('sessions', {
|
||||
/** SHA-256 от токена из cookie. */
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Суперадмин вошёл в кабинет компании для поддержки. */
|
||||
actingCompanyId: integer('acting_company_id').references(() => companies.id),
|
||||
/** Сессия открыта из Telegram Mini App: тарифу без веб-кабинета доступна только она. */
|
||||
viaTelegram: boolean('via_telegram').notNull().default(false),
|
||||
expiresAt: ts('expires_at').notNull(),
|
||||
createdAt,
|
||||
});
|
||||
|
||||
/** Язык, который человек выбрал в боте до регистрации: на нём бот отвечает и создаётся его аккаунт. */
|
||||
export const botLangs = pgTable('bot_langs', {
|
||||
telegramId: bigint('telegram_id', { mode: 'number' }).primaryKey(),
|
||||
lang: text('lang').notNull(),
|
||||
createdAt,
|
||||
});
|
||||
|
||||
/** Одноразовые ссылки: подтверждение email и восстановление пароля. */
|
||||
export const emailTokens = pgTable('email_tokens', {
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** verify | reset */
|
||||
kind: text('kind').notNull(),
|
||||
expiresAt: ts('expires_at').notNull(),
|
||||
});
|
||||
|
||||
export const invites = pgTable('invites', {
|
||||
token: text('token').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
/** agent — многоразовая ссылка для агентов; manager — одноразовая ссылка руководителю от суперадмина. */
|
||||
role: text('role').notNull().default('agent'),
|
||||
createdById: integer('created_by_id').notNull().references(() => users.id),
|
||||
revokedAt: ts('revoked_at'),
|
||||
createdAt,
|
||||
});
|
||||
|
||||
/** Поля объекта недвижимости — общие для объявления из парсера и для лида. */
|
||||
const propertyColumns = {
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
/** sale | rent */
|
||||
dealType: text('deal_type'),
|
||||
propertyType: text('property_type'),
|
||||
region: text('region'),
|
||||
district: text('district'),
|
||||
address: text('address'),
|
||||
price: money('price'),
|
||||
currency: text('currency'),
|
||||
rooms: integer('rooms'),
|
||||
area: numeric('area', { precision: 10, scale: 2, mode: 'number' }),
|
||||
floor: integer('floor'),
|
||||
floorsTotal: integer('floors_total'),
|
||||
photos: jsonb('photos').$type<string[]>().notNull().default([]),
|
||||
authorName: text('author_name'),
|
||||
/** Нормализованный телефон автора (+374…). */
|
||||
phone: text('phone'),
|
||||
/** Удобства и правила проживания со страницы объявления. */
|
||||
features: jsonb('features').$type<ListingFeatures>(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Общая база объявлений сервиса: всё свежее с list.am (не старше 3 дней на момент находки).
|
||||
* Объявление сохраняется один раз и уже отсюда расходится клиентам по их фильтрам.
|
||||
*/
|
||||
export const listings = pgTable(
|
||||
'listings',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
externalId: text('external_id').notNull(),
|
||||
url: text('url').notNull(),
|
||||
...propertyColumns,
|
||||
isAgency: boolean('is_agency').notNull().default(false),
|
||||
publishedAt: ts('published_at'),
|
||||
/** Когда объявление последний раз «подняли» на list.am: по этому времени объект встаёт наверх. */
|
||||
bumpedAt: ts('bumped_at'),
|
||||
active: boolean('active').notNull().default(true),
|
||||
removedAt: ts('removed_at'),
|
||||
lastCheckedAt: ts('last_checked_at'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('listings_external_uq').on(t.externalId),
|
||||
index('listings_phone_idx').on(t.phone),
|
||||
],
|
||||
);
|
||||
|
||||
export const filters = pgTable(
|
||||
'filters',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
/** null — общий фильтр компании или отдела; иначе личный фильтр агента. */
|
||||
ownerUserId: integer('owner_user_id').references(() => users.id),
|
||||
/** Фильтр отдела: объекты по нему приходят в отдел. У личного фильтра пусто — отдел берётся у владельца. */
|
||||
departmentId: departmentId(),
|
||||
name: text('name').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
params: jsonb('params').$type<FilterParams>().notNull(),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('filters_company_idx').on(t.companyId)],
|
||||
);
|
||||
|
||||
export const pipelineStatuses = pgTable(
|
||||
'pipeline_statuses',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
/** object | client */
|
||||
pipeline: text('pipeline').notNull(),
|
||||
/** Код системного статуса (new, taken, deal, rejected); у пользовательских — null. */
|
||||
code: text('code'),
|
||||
name: text('name').notNull(),
|
||||
position: integer('position').notNull(),
|
||||
/** Взятый лид в этом статусе возвращается в пул по правилу «N часов без смены статуса». */
|
||||
poolReturn: boolean('pool_return').notNull().default(false),
|
||||
/** Удалённый статус скрыт из воронки, но остаётся в истории. */
|
||||
deletedAt: ts('deleted_at'),
|
||||
},
|
||||
(t) => [index('statuses_company_idx').on(t.companyId, t.pipeline)],
|
||||
);
|
||||
|
||||
export const rejectReasons = pgTable('reject_reasons', {
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
pipeline: text('pipeline').notNull(),
|
||||
/** agent — системная причина кнопки «Это агент»; у остальных null. */
|
||||
code: text('code'),
|
||||
name: text('name').notNull(),
|
||||
position: integer('position').notNull(),
|
||||
deletedAt: ts('deleted_at'),
|
||||
});
|
||||
|
||||
/** Лид — объект в воронке компании. Одно объявление может породить лиды в разных компаниях. */
|
||||
export const leads = pgTable(
|
||||
'leads',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
/** null — лид создан вручную. */
|
||||
listingId: integer('listing_id').references(() => listings.id),
|
||||
/** listam | manual */
|
||||
source: text('source').notNull(),
|
||||
url: text('url'),
|
||||
...propertyColumns,
|
||||
/** null — лид в пуле. */
|
||||
assigneeId: integer('assignee_id').references(() => users.id),
|
||||
statusId: integer('status_id').notNull().references(() => pipelineStatuses.id),
|
||||
rejectReasonId: integer('reject_reason_id').references(() => rejectReasons.id),
|
||||
nextActionAt: ts('next_action_at'),
|
||||
nextActionNote: text('next_action_note'),
|
||||
nextActionNotifiedAt: ts('next_action_notified_at'),
|
||||
takenAt: ts('taken_at'),
|
||||
statusChangedAt: ts('status_changed_at').notNull().defaultNow(),
|
||||
/** Когда отправлено предупреждение «лид вернётся в пул». */
|
||||
poolWarnedAt: ts('pool_warned_at'),
|
||||
/** Условие комиссии именно для этого объекта; null — как в настройках компании. */
|
||||
commission: jsonb('commission').$type<CommissionRule>(),
|
||||
/** Текст объекта для страницы клиента: по шаблону или переписанный моделью. */
|
||||
showcaseText: text('showcase_text'),
|
||||
/** Когда для страницы клиента загружен полный набор фото; null — ещё не загружали. */
|
||||
galleryAt: ts('gallery_at'),
|
||||
galleryTries: integer('gallery_tries').notNull().default(0),
|
||||
/** Карточку объекта открывали — нужно догрузить все фото и собрать текст. */
|
||||
galleryWantedAt: ts('gallery_wanted_at'),
|
||||
/** Прошлый лид по тому же объекту — блок «Уже был в работе». */
|
||||
previousLeadId: integer('previous_lead_id'),
|
||||
/** Объявление подняли на list.am: лид встаёт наверх списка. */
|
||||
bumpedAt: ts('bumped_at'),
|
||||
/** С какой комиссией объект последний раз ушёл в группу компании; новая комиссия — новая отправка. */
|
||||
groupPostedCommission: jsonb('group_posted_commission').$type<CommissionRule>(),
|
||||
groupPostedAt: ts('group_posted_at'),
|
||||
createdById: integer('created_by_id').references(() => users.id),
|
||||
/** Отдел, которому принадлежит объект; null — без отдела. */
|
||||
departmentId: departmentId(),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [
|
||||
index('leads_company_status_idx').on(t.companyId, t.statusId),
|
||||
index('leads_company_assignee_idx').on(t.companyId, t.assigneeId),
|
||||
// Одно объявление — один лид на отдел компании: у разных отделов свои объекты.
|
||||
uniqueIndex('leads_company_dept_listing_uq')
|
||||
.on(t.companyId, sql`coalesce(${t.departmentId}, 0)`, t.listingId)
|
||||
.where(sql`${t.previousLeadId} is null and ${t.listingId} is not null`),
|
||||
],
|
||||
);
|
||||
|
||||
/** Клиент — покупатель или арендатор, которого агент заводит вручную. */
|
||||
export const clients = pgTable(
|
||||
'clients',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
name: text('name').notNull(),
|
||||
phone: text('phone'),
|
||||
/** Ник или ссылка в Telegram: связь с клиентом, если телефона нет. */
|
||||
telegram: text('telegram'),
|
||||
/** Срок аренды, который нужен арендатору: short | mid | long (RENT_TERMS). */
|
||||
rentTerm: text('rent_term'),
|
||||
/** buyer | tenant */
|
||||
kind: text('kind').notNull(),
|
||||
/** Запрос клиента: тип объекта, район, бюджет, комнаты, площадь. */
|
||||
request: jsonb('request').$type<Partial<FilterParams>>().notNull().default({}),
|
||||
source: text('source'),
|
||||
/** Кто будет жить: состав, дети, животные. */
|
||||
household: jsonb('household').$type<Household>(),
|
||||
/** Условие комиссии именно для этого клиента; null — как в настройках компании. */
|
||||
commission: jsonb('commission').$type<CommissionRule>(),
|
||||
assigneeId: integer('assignee_id').notNull().references(() => users.id),
|
||||
statusId: integer('status_id').notNull().references(() => pipelineStatuses.id),
|
||||
rejectReasonId: integer('reject_reason_id').references(() => rejectReasons.id),
|
||||
nextActionAt: ts('next_action_at'),
|
||||
nextActionNote: text('next_action_note'),
|
||||
nextActionNotifiedAt: ts('next_action_notified_at'),
|
||||
statusChangedAt: ts('status_changed_at').notNull().defaultNow(),
|
||||
/** Отдел, которому принадлежит клиент; null — без отдела. */
|
||||
departmentId: departmentId(),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('clients_company_idx').on(t.companyId, t.statusId)],
|
||||
);
|
||||
|
||||
/** Избранное: объекты, которые пользователь отметил звёздочкой. У каждого своё. */
|
||||
export const leadFavorites = pgTable(
|
||||
'lead_favorites',
|
||||
{
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.leadId] })],
|
||||
);
|
||||
|
||||
/** Пользователь открывал карточку объекта: по этому работает фильтр «Не просмотренные». */
|
||||
export const leadViews = pgTable(
|
||||
'lead_views',
|
||||
{
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
|
||||
viewedAt: ts('viewed_at').notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.leadId] })],
|
||||
);
|
||||
|
||||
/** Фото к карточке клиента: жильцы, животные, документы. Хранятся в базе и попадают в её резервную копию. */
|
||||
export const clientPhotos = pgTable(
|
||||
'client_photos',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
contentType: text('content_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
/** Содержимое файла в base64. */
|
||||
data: text('data').notNull(),
|
||||
uploadedById: integer('uploaded_by_id').references(() => users.id),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('client_photos_client_idx').on(t.clientId)],
|
||||
);
|
||||
|
||||
/** Объекты, предложенные или показанные клиенту. */
|
||||
export const clientLeads = pgTable(
|
||||
'client_leads',
|
||||
{
|
||||
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
||||
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
|
||||
/** offered | shown */
|
||||
state: text('state').notNull().default('offered'),
|
||||
shownAt: ts('shown_at'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [uniqueIndex('client_leads_uq').on(t.clientId, t.leadId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Сделка. Одна запись на сделку, даже если в ней и объект, и клиент из CRM —
|
||||
* так комиссия не задваивается в аналитике и биллинге.
|
||||
*/
|
||||
export const deals = pgTable(
|
||||
'deals',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
leadId: integer('lead_id').references(() => leads.id),
|
||||
clientId: integer('client_id').references(() => clients.id),
|
||||
/** Кто закрыл сделку. Комиссию между агентами делят ownerAgentId и clientAgentId. */
|
||||
agentId: integer('agent_id').notNull().references(() => users.id),
|
||||
/** Вся комиссия агентства: ownerCommission + clientCommission. По ней считается счёт по тарифу. */
|
||||
commission: money('commission').notNull(),
|
||||
/** Часть с собственника — идёт агенту объекта. */
|
||||
ownerCommission: money('owner_commission').notNull().default(0),
|
||||
/** Часть с покупателя или арендатора — идёт агенту клиента. */
|
||||
clientCommission: money('client_commission').notNull().default(0),
|
||||
/** Агент объекта и агент клиента на момент сделки; если стороны в CRM нет — агент второй стороны. */
|
||||
ownerAgentId: integer('owner_agent_id').notNull().references(() => users.id),
|
||||
clientAgentId: integer('client_agent_id').notNull().references(() => users.id),
|
||||
/** sale | rent — от вида сделки зависит процент агента. */
|
||||
kind: text('kind').notNull().default('sale'),
|
||||
currency: text('currency').notNull(),
|
||||
dealDate: date('deal_date').notNull(),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [
|
||||
index('deals_company_date_idx').on(t.companyId, t.dealDate),
|
||||
uniqueIndex('deals_lead_uq').on(t.leadId),
|
||||
uniqueIndex('deals_client_uq').on(t.clientId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Ручное начисление или удержание агенту за месяц: премия, штраф, аванс. Сумма со знаком,
|
||||
* в валюте условий агента на момент записи. Входит в итог к выплате за этот месяц.
|
||||
*/
|
||||
export const payAdjustments = pgTable(
|
||||
'pay_adjustments',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
/** «ГГГГ-ММ». */
|
||||
month: text('month').notNull(),
|
||||
amount: money('amount').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
note: text('note').notNull(),
|
||||
createdBy: integer('created_by').notNull().references(() => users.id),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('pay_adjustments_user_month_idx').on(t.companyId, t.userId, t.month)],
|
||||
);
|
||||
|
||||
/** Что зафиксировано при выплате: условия и курсы, по которым считался месяц, и перерасчёты прошлых месяцев, вошедшие в него. */
|
||||
export interface PayoutSnapshot {
|
||||
pay: AgentPay | null;
|
||||
/** Сколько единиц валюты в одном долларе. */
|
||||
rates: Record<string, number>;
|
||||
/** Начислено за месяц на момент выплаты — в валюте выплаты. */
|
||||
total: number;
|
||||
recalc: { month: string; amount: number }[];
|
||||
}
|
||||
|
||||
/** Отметка «выплачено» за месяц. После неё месяц закрыт: изменения его сделок уходят перерасчётом в следующий открытый месяц. */
|
||||
export const payouts = pgTable(
|
||||
'payouts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
/** «ГГГГ-ММ». */
|
||||
month: text('month').notNull(),
|
||||
amount: money('amount').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
paidAt: date('paid_at').notNull(),
|
||||
note: text('note'),
|
||||
snapshot: jsonb('snapshot').$type<PayoutSnapshot>().notNull(),
|
||||
createdBy: integer('created_by').notNull().references(() => users.id),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [uniqueIndex('payouts_user_month_uq').on(t.userId, t.month), index('payouts_company_idx').on(t.companyId)],
|
||||
);
|
||||
|
||||
/** История лида и клиента. Только дописывается: API не даёт её менять и удалять. */
|
||||
export const events = pgTable(
|
||||
'events',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
leadId: integer('lead_id').references(() => leads.id),
|
||||
clientId: integer('client_id').references(() => clients.id),
|
||||
/** null — действие системы (создание из парсера, возврат в пул). */
|
||||
userId: integer('user_id').references(() => users.id),
|
||||
/** created | taken | assigned | status | comment | pool_return | deal | blacklisted | edited */
|
||||
type: text('type').notNull(),
|
||||
oldValue: text('old_value'),
|
||||
newValue: text('new_value'),
|
||||
/** Статус, в который перешла карточка (события status, deal, blacklisted) — для аналитики по этапам. */
|
||||
statusId: integer('status_id'),
|
||||
text: text('text'),
|
||||
at: ts('at').notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('events_lead_idx').on(t.leadId), index('events_client_idx').on(t.clientId)],
|
||||
);
|
||||
|
||||
/** Уведомления о лиде в Telegram: нужны, чтобы обновить сообщение («Взял Имя») и считать скорость реакции. */
|
||||
export const leadNotifications = pgTable(
|
||||
'lead_notifications',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
chatId: bigint('chat_id', { mode: 'number' }),
|
||||
messageId: integer('message_id'),
|
||||
/** Сообщение с фото: текст у него правится как подпись. */
|
||||
hasPhoto: boolean('has_photo').notNull().default(false),
|
||||
sentAt: ts('sent_at'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('lead_notifications_lead_idx').on(t.leadId)],
|
||||
);
|
||||
|
||||
export const blacklist = pgTable(
|
||||
'blacklist',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
phone: text('phone').notNull(),
|
||||
addedById: integer('added_by_id').references(() => users.id),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [uniqueIndex('blacklist_uq').on(t.companyId, t.phone)],
|
||||
);
|
||||
|
||||
export const invoices = pgTable(
|
||||
'invoices',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
/** setup | fixed | percent */
|
||||
kind: text('kind').notNull(),
|
||||
periodStart: date('period_start'),
|
||||
periodEnd: date('period_end'),
|
||||
amount: money('amount').notNull(),
|
||||
currency: text('currency').notNull().default('USD'),
|
||||
/** Расшифровка по сделкам для модели «%». */
|
||||
details: jsonb('details').$type<unknown>(),
|
||||
/** pending | paid | canceled */
|
||||
status: text('status').notNull().default('pending'),
|
||||
paidAt: ts('paid_at'),
|
||||
/** online | manual | auto (нулевой счёт по модели «%») */
|
||||
paidMethod: text('paid_method'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('invoices_company_idx').on(t.companyId)],
|
||||
);
|
||||
|
||||
export const payments = pgTable('payments', {
|
||||
id: serial('id').primaryKey(),
|
||||
invoiceId: integer('invoice_id').notNull().references(() => invoices.id),
|
||||
provider: text('provider').notNull(),
|
||||
externalId: text('external_id'),
|
||||
amount: money('amount').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
/** pending | succeeded | failed */
|
||||
status: text('status').notNull(),
|
||||
createdAt,
|
||||
});
|
||||
|
||||
/** Сообщения суперадмина пользователям в Telegram. */
|
||||
export const adminMessages = pgTable('admin_messages', {
|
||||
id: serial('id').primaryKey(),
|
||||
text: text('text').notNull(),
|
||||
/** all | company | managers | user */
|
||||
audience: text('audience').notNull(),
|
||||
companyId: integer('company_id').references(() => companies.id),
|
||||
userId: integer('user_id').references(() => users.id),
|
||||
recipients: integer('recipients').notNull().default(0),
|
||||
/** Сколько из получателей без привязанного Telegram — им сообщение не дойдёт. */
|
||||
unreachable: integer('unreachable').notNull().default(0),
|
||||
sentAt: ts('sent_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const parserRuns = pgTable('parser_runs', {
|
||||
id: serial('id').primaryKey(),
|
||||
startedAt: ts('started_at').notNull().defaultNow(),
|
||||
finishedAt: ts('finished_at'),
|
||||
/** running | ok | failed */
|
||||
status: text('status').notNull().default('running'),
|
||||
foundNew: integer('found_new').notNull().default(0),
|
||||
errors: text('errors'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Объявления, которые парсер проверил и не взял в базу (старше 3 дней). Хранится только ID,
|
||||
* чтобы не перечитывать страницу объявления при каждом «поднятии» в списке.
|
||||
*/
|
||||
export const seenListings = pgTable('seen_listings', {
|
||||
externalId: text('external_id').primaryKey(),
|
||||
seenAt: ts('seen_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/** Какие категории list.am уже прочитаны хотя бы раз: первый проход только запоминает объявления. */
|
||||
export const parserState = pgTable('parser_state', {
|
||||
categoryId: integer('category_id').primaryKey(),
|
||||
baselineAt: ts('baseline_at').notNull().defaultNow(),
|
||||
/** Порядок обычных объявлений в списке на прошлом проходе: по сдвигу в нём видно, что объявление подняли. */
|
||||
topIds: jsonb('top_ids').$type<string[]>(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Очередь сообщений Telegram. Воркер отправляет их с учётом лимитов Bot API и повторяет при ошибке.
|
||||
* kind: lead — уведомление о лиде с кнопкой «Беру»; edit — обновить такое уведомление; text — обычный текст.
|
||||
*/
|
||||
export const outbox = pgTable(
|
||||
'outbox',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
kind: text('kind').notNull(),
|
||||
/** null — получатель без аккаунта (написал в поддержку, не привязав Telegram): чат указан в payload.chatId. */
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
leadId: integer('lead_id').references(() => leads.id, { onDelete: 'cascade' }),
|
||||
payload: jsonb('payload').$type<Record<string, unknown>>().notNull().default({}),
|
||||
attempts: integer('attempts').notNull().default(0),
|
||||
runAt: ts('run_at').notNull().defaultNow(),
|
||||
doneAt: ts('done_at'),
|
||||
error: text('error'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('outbox_due_idx').on(t.runAt).where(sql`${t.doneAt} is null`)],
|
||||
);
|
||||
|
||||
/** Настройки сервиса, которые суперадмин меняет в админке: одна строка на раздел (key = 'parser'). */
|
||||
export const settings = pgTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: jsonb('value').$type<Record<string, unknown>>().notNull(),
|
||||
updatedAt: ts('updated_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Обращение в поддержку: человек написал боту. Пока обращение открыто, все его сообщения идут в него;
|
||||
* суперадмин отвечает из админки и закрывает кнопкой «Проблема решена».
|
||||
*/
|
||||
export const supportTickets = pgTable(
|
||||
'support_tickets',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** Чат Telegram, из которого пишут. */
|
||||
chatId: bigint('chat_id', { mode: 'number' }).notNull(),
|
||||
/** null — Telegram не привязан ни к одному аккаунту. */
|
||||
userId: integer('user_id').references(() => users.id),
|
||||
/** Имя и ник из Telegram — чтобы понимать, кто пишет, даже без аккаунта. */
|
||||
tgName: text('tg_name'),
|
||||
tgUsername: text('tg_username'),
|
||||
/** open | closed */
|
||||
status: text('status').notNull().default('open'),
|
||||
/** Есть сообщения, которые поддержка ещё не открывала. */
|
||||
unread: boolean('unread').notNull().default(true),
|
||||
lastMessageAt: ts('last_message_at').notNull().defaultNow(),
|
||||
closedAt: ts('closed_at'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [
|
||||
index('support_tickets_status_idx').on(t.status, t.lastMessageAt),
|
||||
uniqueIndex('support_tickets_open_uq').on(t.chatId).where(sql`${t.status} = 'open'`),
|
||||
],
|
||||
);
|
||||
|
||||
export const supportMessages = pgTable(
|
||||
'support_messages',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
ticketId: integer('ticket_id').notNull().references(() => supportTickets.id, { onDelete: 'cascade' }),
|
||||
/** true — ответ поддержки, false — сообщение пользователя. */
|
||||
fromSupport: boolean('from_support').notNull().default(false),
|
||||
authorId: integer('author_id').references(() => users.id),
|
||||
text: text('text'),
|
||||
/** Вложение из Telegram: файл хранится у Telegram, здесь только его идентификатор. */
|
||||
fileId: text('file_id'),
|
||||
/** photo | file */
|
||||
fileKind: text('file_kind'),
|
||||
fileName: text('file_name'),
|
||||
at: ts('at').notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('support_messages_ticket_idx').on(t.ticketId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Постоянная ссылка агента на анкету в клиентском боте: t.me/<бот>?start=<token>.
|
||||
* Каждый, кто заполнит анкету по ней, становится клиентом этого агента.
|
||||
*/
|
||||
export const intakeLinks = pgTable(
|
||||
'intake_links',
|
||||
{
|
||||
token: text('token').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [uniqueIndex('intake_links_user_uq').on(t.userId)],
|
||||
);
|
||||
|
||||
/** Заполнение анкеты одним человеком: на каком он шаге и что уже ответил. */
|
||||
export const intakeSessions = pgTable(
|
||||
'intake_sessions',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
linkToken: text('link_token').notNull().references(() => intakeLinks.token),
|
||||
/** Чат соискателя в клиентском боте. */
|
||||
chatId: bigint('chat_id', { mode: 'number' }).notNull(),
|
||||
tgName: text('tg_name'),
|
||||
tgUsername: text('tg_username'),
|
||||
lang: text('lang').notNull().default('ru'),
|
||||
/** Текущий вопрос анкеты; done — анкета заполнена. */
|
||||
step: text('step').notNull(),
|
||||
answers: jsonb('answers').$type<Record<string, unknown>>().notNull().default({}),
|
||||
/** Клиент, созданный по анкете. */
|
||||
clientId: integer('client_id').references(() => clients.id, { onDelete: 'set null' }),
|
||||
completedAt: ts('completed_at'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [index('intake_sessions_chat_idx').on(t.chatId)],
|
||||
);
|
||||
|
||||
/** Страница подборки для клиента: объекты на выбор и запись на показ. Открывается по секретной ссылке без входа. */
|
||||
export const clientPages = pgTable(
|
||||
'client_pages',
|
||||
{
|
||||
token: text('token').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
||||
/** Кто создал ссылку — ему приходят уведомления о записях на показ. */
|
||||
createdById: integer('created_by_id').notNull().references(() => users.id),
|
||||
revokedAt: ts('revoked_at'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [uniqueIndex('client_pages_client_uq').on(t.clientId)],
|
||||
);
|
||||
|
||||
/** Запись клиента на показ объекта: день выбирает сам клиент на странице подборки. */
|
||||
export const viewings = pgTable(
|
||||
'viewings',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
||||
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
|
||||
day: date('day').notNull(),
|
||||
note: text('note'),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [uniqueIndex('viewings_uq').on(t.clientId, t.leadId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Телефоны собственника, которые агенты внесли по объявлению: по одному варианту от компании.
|
||||
* В общей базе хранятся все варианты; чем больше компаний указали один и тот же номер, тем он надёжнее.
|
||||
*/
|
||||
export const listingPhones = pgTable(
|
||||
'listing_phones',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
listingId: integer('listing_id').notNull().references(() => listings.id, { onDelete: 'cascade' }),
|
||||
companyId: integer('company_id').notNull().references(() => companies.id),
|
||||
userId: integer('user_id').references(() => users.id),
|
||||
phone: text('phone').notNull(),
|
||||
createdAt,
|
||||
},
|
||||
(t) => [uniqueIndex('listing_phones_uq').on(t.listingId, t.companyId), index('listing_phones_phone_idx').on(t.listingId, t.phone)],
|
||||
);
|
||||
@@ -0,0 +1,197 @@
|
||||
import { CURRENCIES, type DealKind } from '@lr/shared';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Tx } from '../db/client.ts';
|
||||
import { clientLeads, clients, deals, events, leads } from '../db/schema.ts';
|
||||
import { badRequest, conflict } from '../lib/errors.ts';
|
||||
import { pipelineOf } from '../pipelines/service.ts';
|
||||
|
||||
// Сделка — одна запись, даже когда в ней участвуют и объект, и клиент из CRM: комиссия вводится
|
||||
// один раз и не задваивается в аналитике и биллинге. Комиссия состоит из двух частей: с собственника
|
||||
// (идёт агенту объекта) и с покупателя или арендатора (идёт агенту клиента).
|
||||
|
||||
/** Сделка в запросе. `commission` — прежний формат одной суммой: она относится к стороне, с которой закрывают сделку. */
|
||||
export const DealBody = z.object({
|
||||
ownerCommission: z.number().min(0).optional(),
|
||||
clientCommission: z.number().min(0).optional(),
|
||||
commission: z.number().positive().optional(),
|
||||
currency: z.enum(CURRENCIES),
|
||||
date: z.iso.date(),
|
||||
});
|
||||
export type DealBody = z.infer<typeof DealBody>;
|
||||
|
||||
export interface DealInput {
|
||||
ownerCommission: number;
|
||||
clientCommission: number;
|
||||
/** Вся комиссия агентства по сделке. */
|
||||
commission: number;
|
||||
currency: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** Части комиссии из запроса. `side` — с какой стороны закрывают сделку: туда идёт сумма без разбивки. */
|
||||
export function dealInput(body: DealBody, side: 'owner' | 'client'): DealInput {
|
||||
const split = body.ownerCommission !== undefined || body.clientCommission !== undefined;
|
||||
const ownerCommission = split ? (body.ownerCommission ?? 0) : side === 'owner' ? (body.commission ?? 0) : 0;
|
||||
const clientCommission = split ? (body.clientCommission ?? 0) : side === 'client' ? (body.commission ?? 0) : 0;
|
||||
const commission = round2(ownerCommission + clientCommission);
|
||||
if (!(commission > 0)) throw badRequest('COMMISSION_REQUIRED');
|
||||
return { ownerCommission, clientCommission, commission, currency: body.currency, date: body.date };
|
||||
}
|
||||
|
||||
export const dealText = (d: Pick<DealInput, 'commission' | 'currency'>) => `${d.commission} ${d.currency}`;
|
||||
|
||||
/** Сделка в карточке объекта и клиента: части комиссии и кому они идут. `names` — имена участников по ID. */
|
||||
export function dealView(d: typeof deals.$inferSelect, names: Map<string, string>) {
|
||||
return {
|
||||
id: d.id,
|
||||
commission: d.commission,
|
||||
ownerCommission: d.ownerCommission,
|
||||
clientCommission: d.clientCommission,
|
||||
ownerAgentName: names.get(String(d.ownerAgentId)) ?? null,
|
||||
clientAgentName: names.get(String(d.clientAgentId)) ?? null,
|
||||
currency: d.currency,
|
||||
date: d.dealDate,
|
||||
kind: d.kind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Кому идут части комиссии и вид сделки. Часть с собственника — агенту объекта, часть с клиента —
|
||||
* агенту клиента; если одной из сторон в CRM нет, обе части получает агент второй стороны.
|
||||
*/
|
||||
async function dealSides(tx: Tx, leadId: number | null, clientId: number | null, fallbackAgentId: number) {
|
||||
const [lead] = leadId !== null ? await tx.select({ assigneeId: leads.assigneeId, dealType: leads.dealType }).from(leads).where(eq(leads.id, leadId)) : [];
|
||||
const [client] = clientId !== null ? await tx.select({ assigneeId: clients.assigneeId, kind: clients.kind }).from(clients).where(eq(clients.id, clientId)) : [];
|
||||
const kind: DealKind = lead?.dealType ? (lead.dealType === 'rent' ? 'rent' : 'sale') : client?.kind === 'tenant' ? 'rent' : 'sale';
|
||||
return {
|
||||
ownerAgentId: lead?.assigneeId ?? client?.assigneeId ?? fallbackAgentId,
|
||||
clientAgentId: client?.assigneeId ?? lead?.assigneeId ?? fallbackAgentId,
|
||||
kind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Записывает сделку для объекта, клиента или пары. Если у сторон уже были отдельные записи
|
||||
* (каждую закрыли без пары), они сливаются в одну.
|
||||
*/
|
||||
export async function recordDeal(
|
||||
tx: Tx,
|
||||
v: { companyId: number; leadId: number | null; clientId: number | null; agentId: number; deal: DealInput },
|
||||
) {
|
||||
if (v.leadId === null && v.clientId === null) throw new Error('Сделка без объекта и клиента');
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(deals)
|
||||
.where(
|
||||
and(
|
||||
eq(deals.companyId, v.companyId),
|
||||
or(
|
||||
v.leadId !== null ? eq(deals.leadId, v.leadId) : undefined,
|
||||
v.clientId !== null ? eq(deals.clientId, v.clientId) : undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Сторона уже закрыта сделкой с другой парой: сначала ту сделку нужно отменить.
|
||||
for (const r of rows) {
|
||||
const otherClient = v.clientId !== null && r.clientId !== null && r.clientId !== v.clientId;
|
||||
const otherLead = v.leadId !== null && r.leadId !== null && r.leadId !== v.leadId;
|
||||
if (otherClient || otherLead) throw conflict('DEAL_HAS_PAIR');
|
||||
}
|
||||
const [keep, extra] = rows;
|
||||
const leadId = v.leadId ?? keep?.leadId ?? null;
|
||||
const clientId = v.clientId ?? keep?.clientId ?? null;
|
||||
const values = {
|
||||
commission: v.deal.commission,
|
||||
ownerCommission: v.deal.ownerCommission,
|
||||
clientCommission: v.deal.clientCommission,
|
||||
currency: v.deal.currency,
|
||||
dealDate: v.deal.date,
|
||||
...(await dealSides(tx, leadId, clientId, v.agentId)),
|
||||
};
|
||||
if (extra) await tx.delete(deals).where(eq(deals.id, extra.id));
|
||||
if (keep) {
|
||||
await tx.update(deals).set({ ...values, leadId, clientId }).where(eq(deals.id, keep.id));
|
||||
return keep.id;
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(deals)
|
||||
.values({ companyId: v.companyId, leadId, clientId, agentId: v.agentId, ...values })
|
||||
.returning({ id: deals.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сторона вышла из статуса «Сделка». Если сделка была парной, запись остаётся за второй стороной
|
||||
* и её агент получает обе части комиссии; иначе запись удаляется, чтобы комиссия не попала
|
||||
* в аналитику и счёт.
|
||||
*/
|
||||
export async function dropDealSide(tx: Tx, side: 'lead' | 'client', id: number) {
|
||||
const column = side === 'lead' ? deals.leadId : deals.clientId;
|
||||
const [row] = await tx.select().from(deals).where(eq(column, id));
|
||||
if (!row) return;
|
||||
const other = side === 'lead' ? row.clientId : row.leadId;
|
||||
if (other === null) await tx.delete(deals).where(eq(deals.id, row.id));
|
||||
else {
|
||||
await tx
|
||||
.update(deals)
|
||||
.set(side === 'lead' ? { leadId: null, ownerAgentId: row.clientAgentId } : { clientId: null, clientAgentId: row.ownerAgentId })
|
||||
.where(eq(deals.id, row.id));
|
||||
}
|
||||
}
|
||||
|
||||
/** Пара для сделки должна быть прикреплена: объект — к клиенту. */
|
||||
async function assertLinked(tx: Tx, clientId: number, leadId: number) {
|
||||
const [link] = await tx
|
||||
.select()
|
||||
.from(clientLeads)
|
||||
.where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId)));
|
||||
if (!link) throw badRequest('PAIR_NOT_LINKED');
|
||||
}
|
||||
|
||||
/** Клиент из пары переходит в «Сделка» вместе с объектом. */
|
||||
export async function moveClientToDeal(tx: Tx, companyId: number, userId: number, leadId: number, clientId: number, deal: DealInput) {
|
||||
await assertLinked(tx, clientId, leadId);
|
||||
const [client] = await tx
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(eq(clients.id, clientId), eq(clients.companyId, companyId)))
|
||||
.for('update');
|
||||
if (!client) throw badRequest('PAIR_NOT_LINKED');
|
||||
const statuses = await pipelineOf(tx, companyId, 'client');
|
||||
const to = statuses.find((s) => s.code === 'deal')!;
|
||||
if (client.statusId === to.id) return;
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ statusId: to.id, rejectReasonId: null, statusChangedAt: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
await tx.insert(events).values({
|
||||
companyId, clientId, userId, type: 'deal',
|
||||
oldValue: statuses.find((s) => s.id === client.statusId)?.name ?? null, newValue: to.name, statusId: to.id, text: dealText(deal),
|
||||
});
|
||||
}
|
||||
|
||||
/** Объект из пары переходит в «Сделка» вместе с клиентом. Объект должен быть в работе, не в пуле. */
|
||||
export async function moveLeadToDeal(tx: Tx, companyId: number, userId: number, clientId: number, leadId: number, deal: DealInput) {
|
||||
await assertLinked(tx, clientId, leadId);
|
||||
const [lead] = await tx
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.companyId, companyId)))
|
||||
.for('update');
|
||||
if (!lead) throw badRequest('PAIR_NOT_LINKED');
|
||||
if (lead.assigneeId === null) throw badRequest('TAKE_FIRST');
|
||||
const statuses = await pipelineOf(tx, companyId, 'object');
|
||||
const to = statuses.find((s) => s.code === 'deal')!;
|
||||
if (lead.statusId === to.id) return;
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ statusId: to.id, rejectReasonId: null, statusChangedAt: new Date(), poolWarnedAt: null })
|
||||
.where(eq(leads.id, leadId));
|
||||
await tx.insert(events).values({
|
||||
companyId, leadId, userId, type: 'deal',
|
||||
oldValue: statuses.find((s) => s.id === lead.statusId)?.name ?? null, newValue: to.name, statusId: to.id, text: dealText(deal),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { listings } from './db/schema.ts';
|
||||
import { runIngest, type PagedSource } from './ingest/service.ts';
|
||||
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
|
||||
import { loadParserSettings, saveParserSettings } from './parser/settings.ts';
|
||||
import type { SourcedListing } from './parser/source.ts';
|
||||
import { testApp } from './testing.ts';
|
||||
|
||||
// Глубокий проход: догоняем объявления, которые вышли, пока парсер стоял, и ушли со страницы 1.
|
||||
|
||||
const RENT = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'rent')!;
|
||||
let ext = 995_000_000;
|
||||
const card = (daysAgo: number): SourcedListing => {
|
||||
const id = String(++ext);
|
||||
return {
|
||||
externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '2-комн. квартира', dealType: 'rent',
|
||||
propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', price: 300_000, currency: 'AMD', rooms: 2,
|
||||
area: 50, floor: 3, floorsTotal: 9, isAgency: false, photoUrls: [], publishedAt: new Date(Date.now() - daysAgo * 86_400_000),
|
||||
};
|
||||
};
|
||||
|
||||
/** Список категории постранично, по 2 объявления на странице. */
|
||||
let list: SourcedListing[] = [];
|
||||
const source: PagedSource = {
|
||||
name: 'fake',
|
||||
item: async () => null,
|
||||
listPage: async (category, page = 1) => (category.id === RENT.id ? list.slice((page - 1) * 2, page * 2) : []),
|
||||
inspect: async (c) => c,
|
||||
};
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
beforeAll(async () => { t = await testApp('deep_pass', source); });
|
||||
afterAll(() => t.close());
|
||||
|
||||
const inBase = async (items: SourcedListing[]) =>
|
||||
(await t.db.select({ id: listings.externalId }).from(listings).where(inArray(listings.externalId, items.map((i) => i.externalId)))).map((r) => r.id).sort();
|
||||
|
||||
describe('глубокий проход парсера', () => {
|
||||
it('подбирает свежие объявления из глубины списка, старые пропускает, затем выключается', async () => {
|
||||
await saveParserSettings(t.db, { categories: [RENT.id], maxPages: 5, freshDays: 3 });
|
||||
const known = [card(5), card(5)];
|
||||
list = known;
|
||||
await runIngest({ db: t.db, source }); // первый проход по категории — только запоминает
|
||||
await runIngest({ db: t.db, source });
|
||||
|
||||
// Пока парсер стоял, вышли объявления; сверху снова известные — обычный проход дальше страницы 1 не идёт.
|
||||
const missed = [card(1), card(2)];
|
||||
const stale = card(10);
|
||||
list = [...known, ...missed, stale];
|
||||
await runIngest({ db: t.db, source });
|
||||
expect(await inBase(missed)).toEqual([]);
|
||||
|
||||
await saveParserSettings(t.db, { deepPass: true });
|
||||
await runIngest({ db: t.db, source });
|
||||
expect(await inBase(missed)).toEqual(missed.map((m) => m.externalId).sort());
|
||||
expect(await inBase([stale])).toEqual([]);
|
||||
expect((await loadParserSettings(t.db)).deepPass).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import { leads, listings, users } from './db/schema.ts';
|
||||
import { testApp } from './testing.ts';
|
||||
|
||||
// Неактуальные объекты, «Отправить в бот», избранное и «Не просмотренные», отделы, цена в АМД и ДОЛ.
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
let tgId = 50_000;
|
||||
let extId = 7_000_000;
|
||||
|
||||
beforeAll(async () => { t = await testApp('departments'); });
|
||||
afterAll(() => t.close());
|
||||
|
||||
type Api = ReturnType<typeof t.client>;
|
||||
interface Member { api: Api; id: number; chat: number | null }
|
||||
|
||||
/** Компания с руководителем и агентами; Telegram привязан у всех, кроме перечисленных в noTelegram. */
|
||||
const company = async (agents = 2, noTelegram: number[] = []) => {
|
||||
// Объявления прошлых сценариев «состариваем»: иначе новый фильтр получил бы их из базы.
|
||||
await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null });
|
||||
const [admin] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
|
||||
.returning();
|
||||
const sa = t.client(await createSession(t.db, admin!.id));
|
||||
const created = await sa.post('/api/admin/companies', {
|
||||
name: 'Агентство', plan: 'agency', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
const join = async (name: string, token: string, linked = true): Promise<Member> => {
|
||||
const api = await t.register(name);
|
||||
await api.post(`/api/invites/${token}/accept`);
|
||||
const id = (await api.get('/api/me')).body.user.id as number;
|
||||
const chat = linked ? ++tgId : null;
|
||||
if (chat) await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
|
||||
return { api, id, chat };
|
||||
};
|
||||
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
|
||||
const invite = (await boss.api.post('/api/invites')).body.token as string;
|
||||
const team: Member[] = [];
|
||||
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite, !noTelegram.includes(i)));
|
||||
return { sa, boss, agents: team, id: created.body.id as number };
|
||||
};
|
||||
|
||||
/** Свежее объявление в общей базе: его получит новый фильтр. */
|
||||
const addListing = async (over: Partial<typeof listings.$inferInsert> = {}) => {
|
||||
const id = String(++extId);
|
||||
const [row] = await t.db
|
||||
.insert(listings)
|
||||
.values({
|
||||
externalId: id, url: `https://www.list.am/item/${id}`, title: `Квартира ${id}`, dealType: 'sale', propertyType: 'apartment',
|
||||
region: 'Ереван', district: 'Кентрон', price: 100_000, currency: 'USD', rooms: 2, area: 60, isAgency: false,
|
||||
photos: ['https://img.list.am/f/1.webp'], publishedAt: new Date(), ...over,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
};
|
||||
|
||||
const titles = async (api: Api, query = 'tab=all') => ((await api.get(`/api/leads?${query}`)).body as { title: string }[]).map((l) => l.title).sort();
|
||||
|
||||
describe('неактуальные объекты', () => {
|
||||
it('снятые с List.am и закрытые объекты не попадают в списки, поиск и подбор, но остаются в «Базе»', async () => {
|
||||
const c = await company(1);
|
||||
const live = await addListing({ title: 'Живой объект' });
|
||||
const gone = await addListing({ title: 'Снятый объект' });
|
||||
await addListing({ title: 'Отказной объект' });
|
||||
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
|
||||
expect(await titles(c.boss.api)).toEqual(['Живой объект', 'Отказной объект', 'Снятый объект']);
|
||||
|
||||
// Объявление сняли с List.am; второй объект закрыли отказом.
|
||||
await t.db.update(listings).set({ active: false, removedAt: new Date() }).where(eq(listings.id, gone.id));
|
||||
const all = (await c.boss.api.get('/api/leads?tab=all')).body as { id: number; title: string }[];
|
||||
const rejectedId = all.find((l) => l.title === 'Отказной объект')!.id;
|
||||
await c.boss.api.post(`/api/leads/${rejectedId}/take`);
|
||||
const pipeline = (await c.boss.api.get('/api/pipelines/object')).body;
|
||||
const rejected = pipeline.statuses.find((s: { code: string }) => s.code === 'rejected');
|
||||
expect((await c.boss.api.post(`/api/leads/${rejectedId}/status`, { statusId: rejected.id, rejectReasonId: pipeline.reasons[0].id })).status).toBe(200);
|
||||
|
||||
expect(await titles(c.boss.api)).toEqual(['Живой объект']);
|
||||
expect(await titles(c.boss.api, 'tab=all&q=Снятый')).toEqual([]);
|
||||
expect(await titles(c.agents[0]!.api, 'tab=pool')).toEqual(['Живой объект']);
|
||||
expect((await c.boss.api.get('/api/leads-summary')).body).toMatchObject({ all: 1, pool: 1 });
|
||||
|
||||
// В подбор клиенту снятый объект не попадает.
|
||||
const client = await c.boss.api.post('/api/clients', { name: 'Покупатель', kind: 'buyer', request: {} });
|
||||
const suggested = (await c.boss.api.get(`/api/clients/${client.body.id}/suggestions`)).body as { title: string }[];
|
||||
expect(suggested.map((s) => s.title)).toEqual(['Живой объект']);
|
||||
|
||||
// Данные не удалены: снятый объект — в «Базе», карточка открывается.
|
||||
const removed = (await c.boss.api.get('/api/archive/leads?result=removed')).body as { title: string }[];
|
||||
expect(removed.map((l) => l.title)).toEqual(['Снятый объект']);
|
||||
const archive = (await c.boss.api.get('/api/archive/leads')).body as { title: string }[];
|
||||
expect(archive.map((l) => l.title).sort()).toEqual(['Отказной объект', 'Снятый объект']);
|
||||
const [goneLead] = await t.db.select().from(leads).where(eq(leads.listingId, gone.id));
|
||||
expect((await c.boss.api.get(`/api/leads/${goneLead!.id}`)).status).toBe(200);
|
||||
void live;
|
||||
});
|
||||
});
|
||||
|
||||
describe('«Отправить в бот»', () => {
|
||||
it('объект уходит в личный чат того, кто нажал; без привязки Telegram — понятная ошибка', async () => {
|
||||
const c = await company(2, [1]);
|
||||
await addListing({ title: 'Для бота' });
|
||||
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
|
||||
const [lead] = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body as { id: number }[];
|
||||
|
||||
const before = t.groupSent.length;
|
||||
expect((await c.agents[0]!.api.post(`/api/leads/${lead!.id}/bot`)).status).toBe(200);
|
||||
const sent = t.groupSent.slice(before);
|
||||
// Фото альбомом и два текста — все в чат нажавшего, никому больше.
|
||||
expect(sent.map((m) => m.kind)).toEqual(['album', 'text', 'text']);
|
||||
expect(new Set(sent.map((m) => m.chatId))).toEqual(new Set([c.agents[0]!.chat]));
|
||||
expect(sent[1]!.text).toContain('Для бота');
|
||||
|
||||
// Другой агент нажал ту же кнопку — объект пришёл ему, а не первому.
|
||||
const mark = t.groupSent.length;
|
||||
await c.boss.api.post(`/api/leads/${lead!.id}/bot`);
|
||||
expect(new Set(t.groupSent.slice(mark).map((m) => m.chatId))).toEqual(new Set([c.boss.chat]));
|
||||
|
||||
// Telegram не привязан.
|
||||
const res = await c.agents[1]!.api.post(`/api/leads/${lead!.id}/bot`);
|
||||
expect(res).toMatchObject({ status: 400, body: { code: 'TELEGRAM_NOT_LINKED' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('избранное и «Не просмотренные»', () => {
|
||||
it('фильтры работают у каждого своим и вместе с остальными фильтрами', async () => {
|
||||
const c = await company(1);
|
||||
await addListing({ title: 'Дешёвая', price: 50_000 });
|
||||
await addListing({ title: 'Дорогая', price: 300_000 });
|
||||
await addListing({ title: 'Средняя', price: 120_000 });
|
||||
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
|
||||
const agent = c.agents[0]!.api;
|
||||
const pool = (await agent.get('/api/leads?tab=pool')).body as { id: number; title: string; favorite: boolean; seen: boolean }[];
|
||||
const id = (title: string) => pool.find((l) => l.title === title)!.id;
|
||||
expect(pool.every((l) => !l.favorite && !l.seen)).toBe(true);
|
||||
|
||||
await agent.post(`/api/leads/${id('Дешёвая')}/favorite`, { on: true });
|
||||
await agent.post(`/api/leads/${id('Дорогая')}/favorite`, { on: true });
|
||||
expect(await titles(agent, 'tab=pool&favorite=1')).toEqual(['Дешёвая', 'Дорогая']);
|
||||
// Вместе с ценой.
|
||||
expect(await titles(agent, 'tab=pool&favorite=1&priceFrom=100000')).toEqual(['Дорогая']);
|
||||
// Избранное своё у каждого.
|
||||
expect(await titles(c.boss.api, 'tab=all&favorite=1')).toEqual([]);
|
||||
|
||||
// Открытая карточка больше не «непросмотренная» — только для того, кто её открыл.
|
||||
expect((await agent.get(`/api/leads/${id('Средняя')}`)).body.favorite).toBe(false);
|
||||
expect((await agent.get(`/api/leads/${id('Дорогая')}`)).body.favorite).toBe(true);
|
||||
expect(await titles(agent, 'tab=pool&unseen=1')).toEqual(['Дешёвая']);
|
||||
expect(await titles(agent, 'tab=pool&unseen=1&favorite=1')).toEqual(['Дешёвая']);
|
||||
expect(await titles(c.boss.api, 'tab=all&unseen=1')).toEqual(['Дешёвая', 'Дорогая', 'Средняя']);
|
||||
|
||||
await agent.post(`/api/leads/${id('Дешёвая')}/favorite`, { on: false });
|
||||
expect(await titles(agent, 'tab=pool&favorite=1')).toEqual(['Дорогая']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('цена в АМД и ДОЛ', () => {
|
||||
it('границы в выбранной валюте; цены объектов пересчитываются по курсу', async () => {
|
||||
const c = await company(0);
|
||||
// Курс по умолчанию: 385 драмов за доллар.
|
||||
await addListing({ title: 'В долларах', price: 100_000, currency: 'USD' }); // 38 500 000 ֏
|
||||
await addListing({ title: 'В драмах', price: 30_000_000, currency: 'AMD' }); // ≈ $77 922
|
||||
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
|
||||
const api = c.boss.api;
|
||||
expect(await titles(api, 'tab=all&priceFrom=80000&priceCurrency=USD')).toEqual(['В долларах']);
|
||||
expect(await titles(api, 'tab=all&priceTo=80000&priceCurrency=USD')).toEqual(['В драмах']);
|
||||
expect(await titles(api, 'tab=all&priceFrom=35000000&priceCurrency=AMD')).toEqual(['В долларах']);
|
||||
expect(await titles(api, 'tab=all&priceFrom=29000000&priceTo=31000000&priceCurrency=AMD')).toEqual(['В драмах']);
|
||||
expect(await titles(api, 'tab=all&priceFrom=1000&priceCurrency=AMD')).toEqual(['В долларах', 'В драмах']);
|
||||
expect((await api.get('/api/rates')).body).toMatchObject({ USD: 1, AMD: 385 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('отделы', () => {
|
||||
it('у отдела свои фильтры, объекты и клиенты; сотрудник не видит данные другого отдела', async () => {
|
||||
const c = await company(3);
|
||||
const [a1, a2, free] = c.agents as [Member, Member, Member];
|
||||
// Отделы создаёт и наполняет только руководитель.
|
||||
expect((await a1.api.post('/api/departments', { name: 'Свой' })).status).toBe(403);
|
||||
const sale = (await c.boss.api.post('/api/departments', { name: 'Продажа' })).body.id as number;
|
||||
const rent = (await c.boss.api.post('/api/departments', { name: 'Аренда' })).body.id as number;
|
||||
expect((await a1.api.put(`/api/members/${a2.id}/department`, { departmentId: sale })).status).toBe(403);
|
||||
expect((await c.boss.api.put(`/api/members/${a1.id}/department`, { departmentId: sale })).status).toBe(200);
|
||||
expect((await c.boss.api.put(`/api/members/${a2.id}/department`, { departmentId: rent })).status).toBe(200);
|
||||
expect((await a1.api.get('/api/me')).body.user).toMatchObject({ departmentId: sale, departmentName: 'Продажа' });
|
||||
const members = (await c.boss.api.get('/api/members')).body as { id: number; departmentId: number | null }[];
|
||||
expect(members.find((m) => m.id === a1.id)!.departmentId).toBe(sale);
|
||||
|
||||
// Фильтры отделов задаёт руководитель; агент видит только фильтры своего отдела.
|
||||
await addListing({ title: 'Продажа 1', dealType: 'sale' });
|
||||
await addListing({ title: 'Аренда 1', dealType: 'rent', price: 800 });
|
||||
expect((await a1.api.post('/api/filters', { name: 'x', scope: 'department', departmentId: sale, params: {} })).status).toBe(403);
|
||||
await c.boss.api.post('/api/filters', { name: 'Продажи', scope: 'department', departmentId: sale, params: { dealType: 'sale', ownersOnly: false } });
|
||||
await c.boss.api.post('/api/filters', { name: 'Аренда', scope: 'department', departmentId: rent, params: { dealType: 'rent', ownersOnly: false } });
|
||||
const f1 = (await a1.api.get('/api/filters')).body as { name: string; scope: string; editable: boolean }[];
|
||||
expect(f1).toEqual([expect.objectContaining({ name: 'Продажи', scope: 'department', editable: false })]);
|
||||
expect(((await c.boss.api.get('/api/filters')).body as unknown[]).length).toBe(2);
|
||||
|
||||
// Объекты пришли каждому отделу по его фильтру и не пересекаются.
|
||||
expect(await titles(a1.api, 'tab=pool')).toEqual(['Продажа 1']);
|
||||
expect(await titles(a2.api, 'tab=pool')).toEqual(['Аренда 1']);
|
||||
// Сотрудник без отдела объекты отделов не видит; руководитель видит все и может выбрать отдел.
|
||||
expect(await titles(free.api, 'tab=pool')).toEqual([]);
|
||||
expect(await titles(c.boss.api)).toEqual(['Аренда 1', 'Продажа 1']);
|
||||
expect(await titles(c.boss.api, `tab=all&departmentId=${rent}`)).toEqual(['Аренда 1']);
|
||||
|
||||
// Чужой объект нельзя открыть, взять, отправить себе в бот или добавить в избранное.
|
||||
const rentLead = ((await a2.api.get('/api/leads?tab=pool')).body as { id: number }[])[0]!.id;
|
||||
expect((await a1.api.get(`/api/leads/${rentLead}`)).status).toBe(404);
|
||||
expect((await a1.api.post(`/api/leads/${rentLead}/take`)).status).toBe(404);
|
||||
expect((await a1.api.post(`/api/leads/${rentLead}/bot`)).status).toBe(404);
|
||||
expect((await a1.api.post(`/api/leads/${rentLead}/favorite`, { on: true })).status).toBe(404);
|
||||
|
||||
// Одно объявление под фильтрами двух отделов — у каждого отдела своя карточка.
|
||||
await c.boss.api.post('/api/filters', { name: 'Продажи 2', scope: 'department', departmentId: rent, params: { dealType: 'sale', ownersOnly: false } });
|
||||
expect(await titles(a2.api, 'tab=pool')).toEqual(['Аренда 1', 'Продажа 1']);
|
||||
expect(await titles(a1.api, 'tab=pool')).toEqual(['Продажа 1']);
|
||||
const saleIn = async (api: Api) => ((await api.get('/api/leads?tab=pool&q=Продажа')).body as { id: number }[])[0]!.id;
|
||||
expect(await saleIn(a1.api)).not.toBe(await saleIn(a2.api));
|
||||
|
||||
// Клиенты отдела: агент видит клиентов своего отдела, но не чужого (видимость «видят все» включена).
|
||||
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
|
||||
const own = await a1.api.post('/api/clients', { name: 'Клиент продажи', kind: 'buyer', request: {} });
|
||||
const other = await a2.api.post('/api/clients', { name: 'Клиент аренды', kind: 'tenant', request: {} });
|
||||
const clientNames = async (api: Api) => ((await api.get('/api/clients?tab=all')).body as { name: string }[]).map((x) => x.name).sort();
|
||||
expect(await clientNames(a1.api)).toEqual(['Клиент продажи']);
|
||||
expect(await clientNames(a2.api)).toEqual(['Клиент аренды']);
|
||||
expect(await clientNames(c.boss.api)).toEqual(['Клиент аренды', 'Клиент продажи']);
|
||||
expect((await a1.api.get(`/api/clients/${other.body.id}`)).status).toBe(404);
|
||||
// С «видят все» агент видит все объекты, но только своего отдела.
|
||||
expect(await titles(a1.api)).toEqual(['Продажа 1']);
|
||||
// Подбор клиенту — только из объектов его отдела.
|
||||
const sug = (await c.boss.api.get(`/api/clients/${own.body.id}/suggestions`)).body as { id: number }[];
|
||||
expect(sug.map((s) => s.id)).toEqual([await saleIn(a1.api)]);
|
||||
|
||||
// Агента перевели в другой отдел: его объекты в работе и клиенты переходят с ним.
|
||||
await a1.api.post(`/api/leads/${await saleIn(a1.api)}/take`);
|
||||
const taken = ((await a1.api.get('/api/leads?tab=mine')).body as { id: number }[])[0]!.id;
|
||||
const moved = await c.boss.api.put(`/api/members/${a1.id}/department`, { departmentId: null });
|
||||
// В «без отдела» карточки по этому объявлению нет — объект переходит.
|
||||
expect(moved.body).toMatchObject({ ok: true, kept: 0 });
|
||||
expect(((await a1.api.get('/api/leads?tab=mine')).body as { id: number }[]).map((l) => l.id)).toEqual([taken]);
|
||||
expect(await clientNames(a1.api)).toEqual(['Клиент продажи']);
|
||||
expect(await clientNames(a2.api)).toEqual(['Клиент аренды']);
|
||||
|
||||
// Непустой отдел не удаляется; пустой — удаляется вместе с фильтрами.
|
||||
expect((await c.boss.api.del(`/api/departments/${rent}`)).body).toMatchObject({ code: 'DEPARTMENT_NOT_EMPTY' });
|
||||
const empty = (await c.boss.api.post('/api/departments', { name: 'Пустой' })).body.id as number;
|
||||
await c.boss.api.post('/api/filters', { name: 'Пустой', scope: 'department', departmentId: empty, params: {} });
|
||||
expect((await c.boss.api.del(`/api/departments/${empty}`)).status).toBe(200);
|
||||
expect(((await c.boss.api.get('/api/filters')).body as { name: string }[]).map((f) => f.name)).not.toContain('Пустой');
|
||||
const list = (await c.boss.api.get('/api/departments')).body as { name: string; members: number }[];
|
||||
expect(list.map((d) => [d.name, d.members])).toEqual([['Аренда', 1], ['Продажа', 0]]);
|
||||
expect(((await a2.api.get('/api/departments')).body as { name: string }[]).map((d) => d.name)).toEqual(['Аренда']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { and, asc, eq, inArray, isNull, not, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clients, departments, events, filters, leads, users } from '../db/schema.ts';
|
||||
import type { Guards } from '../leads/routes.ts';
|
||||
import { badRequest, conflict, notFound } from '../lib/errors.ts';
|
||||
import { inDepartment } from '../leads/service.ts';
|
||||
|
||||
// Отделы компании. Создаёт, переименовывает и удаляет отделы, назначает и снимает сотрудников руководитель
|
||||
// (и суперадмин в кабинете компании). У отдела свои фильтры, объекты и клиенты: сотрудник видит только
|
||||
// данные своего отдела, руководитель — все.
|
||||
|
||||
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||
const Name = z.object({ name: z.string().trim().min(1).max(80) });
|
||||
|
||||
export function registerDepartmentRoutes(app: FastifyInstance, db: Db, g: Guards) {
|
||||
type Ctx = ReturnType<Guards['member']>;
|
||||
|
||||
const own = async (ctx: Ctx, id: number) => {
|
||||
const [d] = await db.select().from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id)));
|
||||
if (!d) throw notFound();
|
||||
return d;
|
||||
};
|
||||
|
||||
/** Руководитель видит все отделы, сотрудник — только свой. */
|
||||
app.get('/api/departments', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const count = (table: string, column: string) =>
|
||||
sql<number>`(select count(*)::int from ${sql.raw(table)} x where x.${sql.raw(column)} = "departments"."id"${sql.raw(table === 'users' ? ' and x.removed_at is null' : '')})`;
|
||||
return db
|
||||
.select({
|
||||
id: departments.id,
|
||||
name: departments.name,
|
||||
members: count('users', 'department_id'),
|
||||
filters: count('filters', 'department_id'),
|
||||
leads: count('leads', 'department_id'),
|
||||
clients: count('clients', 'department_id'),
|
||||
})
|
||||
.from(departments)
|
||||
.where(
|
||||
and(
|
||||
eq(departments.companyId, ctx.company.id),
|
||||
ctx.role === 'manager' ? undefined : ctx.user.departmentId ? eq(departments.id, ctx.user.departmentId) : sql`false`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(departments.name), asc(departments.id));
|
||||
});
|
||||
|
||||
app.post('/api/departments', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { name } = Name.parse(req.body);
|
||||
const [row] = await db.insert(departments).values({ companyId: ctx.company.id, name }).returning({ id: departments.id });
|
||||
return { id: row!.id };
|
||||
});
|
||||
|
||||
app.patch('/api/departments/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const d = await own(ctx, Id.parse(req.params).id);
|
||||
const { name } = Name.parse(req.body);
|
||||
await db.update(departments).set({ name }).where(eq(departments.id, d.id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Удалить можно отдел без сотрудников, клиентов и объектов в работе. Объекты из его пула, которые никто
|
||||
* не брал, удаляются вместе с ним (объявления остаются в общей базе), как и его фильтры: данные отдела
|
||||
* не смешиваются с данными компании.
|
||||
*/
|
||||
app.delete('/api/departments/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const d = await own(ctx, Id.parse(req.params).id);
|
||||
await db.transaction(async (tx) => {
|
||||
const [busy] = await tx
|
||||
.select({
|
||||
users: sql<number>`(select count(*)::int from users u where u.department_id = ${d.id} and u.removed_at is null)`,
|
||||
clients: sql<number>`(select count(*)::int from clients c where c.department_id = ${d.id})`,
|
||||
worked: sql<number>`(select count(*)::int from leads l join pipeline_statuses ps on ps.id = l.status_id
|
||||
where l.department_id = ${d.id} and (l.assignee_id is not null or l.taken_at is not null or ps.code is distinct from 'new'))`,
|
||||
})
|
||||
.from(sql`(select 1) as one`);
|
||||
if (busy && (busy.users || busy.clients || busy.worked)) throw conflict('DEPARTMENT_NOT_EMPTY');
|
||||
const pool = (await tx.select({ id: leads.id }).from(leads).where(eq(leads.departmentId, d.id))).map((l) => l.id);
|
||||
if (pool.length) {
|
||||
await tx.update(leads).set({ previousLeadId: null }).where(inArray(leads.previousLeadId, pool));
|
||||
await tx.delete(events).where(inArray(events.leadId, pool));
|
||||
await tx.delete(leads).where(inArray(leads.id, pool));
|
||||
}
|
||||
await tx.delete(filters).where(eq(filters.departmentId, d.id));
|
||||
await tx.delete(departments).where(eq(departments.id, d.id));
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Назначить сотрудника в отдел или снять с отдела (departmentId = null). Объекты и клиенты, которые он
|
||||
* ведёт, переходят вместе с ним — иначе он перестал бы их видеть. Объект не переносится, если в новом
|
||||
* отделе уже есть карточка по тому же объявлению: его можно передать другому сотруднику старого отдела.
|
||||
*/
|
||||
app.put('/api/members/:id/department', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { departmentId } = z.object({ departmentId: z.number().int().positive().nullable() }).parse(req.body);
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.id, id), eq(users.companyId, ctx.company.id), isNull(users.removedAt)));
|
||||
if (!user) throw notFound();
|
||||
if (departmentId !== null) await own(ctx, departmentId).catch(() => { throw badRequest('BAD_DEPARTMENT'); });
|
||||
if ((user.departmentId ?? null) === departmentId) return { ok: true, kept: 0 };
|
||||
const kept = await db.transaction(async (tx) => {
|
||||
await tx.update(users).set({ departmentId }).where(eq(users.id, id));
|
||||
const target = departmentId ?? 0;
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ departmentId })
|
||||
.where(
|
||||
and(
|
||||
eq(leads.companyId, ctx.company.id),
|
||||
eq(leads.assigneeId, id),
|
||||
sql`not (${leads.listingId} is not null and ${leads.previousLeadId} is null and exists (
|
||||
select 1 from leads o where o.company_id = ${leads.companyId} and coalesce(o.department_id, 0) = ${target}
|
||||
and o.listing_id = ${leads.listingId} and o.previous_lead_id is null and o.id <> ${leads.id}))`,
|
||||
),
|
||||
);
|
||||
const [left] = await tx
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(leads)
|
||||
.where(and(eq(leads.companyId, ctx.company.id), eq(leads.assigneeId, id), not(inDepartment(leads.departmentId, departmentId))));
|
||||
await tx.update(clients).set({ departmentId }).where(and(eq(clients.companyId, ctx.company.id), eq(clients.assigneeId, id)));
|
||||
return left?.n ?? 0;
|
||||
});
|
||||
// kept — сколько объектов осталось в прежнем отделе (там уже есть карточка того же объявления).
|
||||
return { ok: true, kept };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { expectedCommission, type CommissionRule, type Currency } from '@lr/shared';
|
||||
import { and, eq, isNotNull, sql } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { companies, leads, pipelineStatuses, users } from '../db/schema.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
|
||||
import { renderShowcaseText } from '../showcase/text.ts';
|
||||
import { esc } from '../telegram/texts.ts';
|
||||
import { badRequest } from '../lib/errors.ts';
|
||||
|
||||
// Группа компании в Telegram: объект, который агент взял в работу и для которого указал комиссию,
|
||||
// уходит туда своим ботом компании — все фото, описание для клиента, кто ведёт объект и комиссия.
|
||||
// Комиссию поменяли — объект уходит ещё раз.
|
||||
|
||||
/** Бот группы: токен, группа и, если в группе есть топики, номер нужного топика. */
|
||||
export type GroupBot = { token: string; chatId: number; threadId?: number };
|
||||
|
||||
/** Отправка в группу. В тестах подменяется записывающей заглушкой. */
|
||||
export interface GroupSender {
|
||||
/** Фото альбомами по 10 штук. */
|
||||
album(bot: GroupBot, photos: Buffer[]): Promise<void>;
|
||||
text(bot: GroupBot, html: string): Promise<void>;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Вызов Bot API. В группу Telegram пускает около 20 сообщений в минуту (альбом из 10 фото — это 10):
|
||||
* на «подождите N секунд» ждём и повторяем тот же вызов, чтобы фото не задвоились.
|
||||
*/
|
||||
async function call(token: string, method: string, body: FormData | Record<string, unknown>) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
||||
method: 'POST',
|
||||
...(body instanceof FormData ? { body } : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
const data = (await res.json()) as { ok: boolean; description?: string; parameters?: { retry_after?: number } };
|
||||
if (data.ok) return;
|
||||
const wait = data.parameters?.retry_after;
|
||||
if (res.status === 429 && wait && attempt < 5) {
|
||||
await sleep(Math.min(wait, 120) * 1000 + 500);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Telegram ${method}: ${data.description ?? res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const telegramGroupSender: GroupSender = {
|
||||
async album({ token, chatId, threadId }, photos) {
|
||||
for (let i = 0; i < photos.length; i += 10) {
|
||||
const chunk = photos.slice(i, i + 10);
|
||||
const form = new FormData();
|
||||
form.set('chat_id', String(chatId));
|
||||
if (threadId) form.set('message_thread_id', String(threadId));
|
||||
// Одно фото альбомом не отправить — оно уходит обычным сообщением.
|
||||
if (chunk.length === 1) {
|
||||
form.set('photo', new Blob([new Uint8Array(chunk[0]!)], { type: 'image/jpeg' }), 'photo.jpg');
|
||||
await call(token, 'sendPhoto', form);
|
||||
continue;
|
||||
}
|
||||
form.set('media', JSON.stringify(chunk.map((_, k) => ({ type: 'photo', media: `attach://p${k}` }))));
|
||||
chunk.forEach((data, k) => form.set(`p${k}`, new Blob([new Uint8Array(data)], { type: 'image/jpeg' }), `p${k}.jpg`));
|
||||
await call(token, 'sendMediaGroup', form);
|
||||
}
|
||||
},
|
||||
async text({ token, chatId, threadId }, html) {
|
||||
await call(token, 'sendMessage', { chat_id: chatId, ...(threadId ? { message_thread_id: threadId } : {}), text: html, parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
|
||||
},
|
||||
};
|
||||
|
||||
const SYMBOL: Record<string, string> = { USD: '$', AMD: '֏', RUB: '₽', EUR: '€' };
|
||||
const money = (amount: number, currency: string) => {
|
||||
// Разряды — обычным пробелом: так сумма одинаково выглядит в любом клиенте Telegram.
|
||||
const n = Math.round(amount).toLocaleString('ru-RU').replace(/[\u00a0\u202f]/g, ' ');
|
||||
return currency === 'AMD' || currency === 'RUB' ? `${n} ${SYMBOL[currency]}` : `${SYMBOL[currency] ?? ''}${n}`;
|
||||
};
|
||||
|
||||
/** «50 % (≈ 175 000 ֏)», «$1 000», «$200 + 2 % (≈ $2 600)». */
|
||||
export function commissionLine(rule: CommissionRule, price: number | null, currency: string | null): string {
|
||||
const terms =
|
||||
rule.mode === 'percent' ? `${rule.value} %` : rule.mode === 'fixed' ? money(rule.value, rule.currency) : `${money(rule.fixed ?? 0, rule.currency)} + ${rule.value} %`;
|
||||
const expected = rule.mode === 'fixed' ? null : expectedCommission(rule, price, currency as Currency | null);
|
||||
return expected ? `${terms} (≈ ${money(expected.amount, expected.currency)})` : terms;
|
||||
}
|
||||
|
||||
type GroupLead = Pick<typeof leads.$inferSelect, 'title' | 'showcaseText' | 'price' | 'currency' | 'url'> & Parameters<typeof renderShowcaseText>[0];
|
||||
|
||||
/**
|
||||
* Сообщения в группу после фото: описание объекта (название и текст для клиента) и отдельно —
|
||||
* кто дозвонился, комиссия и ссылка.
|
||||
*/
|
||||
export function groupTexts(lead: GroupLead, agentName: string | null, commission: CommissionRule | null, resent = false): [string, string] {
|
||||
const about = [
|
||||
resent ? '🔁 <b>Комиссия изменилась</b>' : null,
|
||||
`<b>${esc(lead.title)}</b>`,
|
||||
'',
|
||||
esc(lead.showcaseText ?? renderShowcaseText(lead)),
|
||||
];
|
||||
const info = [
|
||||
`👤 Дозвонился: <b>${esc(agentName ?? '—')}</b>`,
|
||||
`💰 Комиссия: <b>${commission ? esc(commissionLine(commission, lead.price, lead.currency)) : 'не указана'}</b>`,
|
||||
lead.url ? `🔗 ${esc(lead.url)}` : null,
|
||||
];
|
||||
const join = (lines: (string | null)[]) => lines.filter((line) => line !== null).join('\n').slice(0, 4096);
|
||||
return [join(about), join(info)];
|
||||
}
|
||||
|
||||
/** Фото и текст ещё готовятся (открытие карточки ставит их в очередь) — ждём до 10 минут, потом отправляем как есть. */
|
||||
const GALLERY_WAIT_MS = 10 * 60_000;
|
||||
|
||||
type Lead = typeof leads.$inferSelect;
|
||||
type PostDeps = { db: Db; config: Config; sender?: GroupSender; fetchImage?: ImageFetcher; now?: Date };
|
||||
|
||||
/**
|
||||
* Объект в группу: фото альбомами, описание, отдельно — кто дозвонился и комиссия. Отмечает, с какой
|
||||
* комиссией объект ушёл (своей, из карточки): автоматика с той же комиссией его больше не пришлёт.
|
||||
*/
|
||||
async function postLead(deps: PostDeps, bot: GroupBot, lead: Lead, agentName: string | null, commission: CommissionRule | null, resent = false) {
|
||||
const { db, now = new Date() } = deps;
|
||||
await deliverLead(deps, bot, lead, agentName, commission, resent);
|
||||
await db.update(leads).set({ groupPostedCommission: lead.commission, groupPostedAt: now }).where(eq(leads.id, lead.id));
|
||||
}
|
||||
|
||||
/** Фото объекта альбомами и два сообщения: описание для клиента и кто ведёт объект, комиссия, ссылка. */
|
||||
async function deliverLead({ config, sender = telegramGroupSender, fetchImage }: PostDeps, bot: GroupBot, lead: Lead, agentName: string | null, commission: CommissionRule | null, resent = false) {
|
||||
const photos: Buffer[] = [];
|
||||
for (const url of lead.photos.filter((u) => /^https:\/\/(?:img|s)\.list\.am\//.test(u))) {
|
||||
const photo = await croppedPhoto(config, url, fetchImage).catch(() => null);
|
||||
if (photo) photos.push(photo);
|
||||
}
|
||||
if (photos.length) await sender.album(bot, photos);
|
||||
for (const text of groupTexts(lead, agentName, commission, resent)) await sender.text(bot, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Отправить в бот»: объект в том же виде, что и в группу, уходит основным ботом в личный чат
|
||||
* того, кто нажал кнопку. Получатель — Telegram, привязанный к его аккаунту; выбрать другого нельзя.
|
||||
*/
|
||||
export async function sendLeadToUser(deps: PostDeps, company: typeof companies.$inferSelect, lead: Lead, user: Pick<typeof users.$inferSelect, 'telegramId'>) {
|
||||
const token = deps.config.telegramBotToken;
|
||||
if (!token) throw badRequest('TELEGRAM_DISABLED');
|
||||
if (!user.telegramId) throw badRequest('TELEGRAM_NOT_LINKED');
|
||||
const [agent] = lead.assigneeId ? await deps.db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [];
|
||||
const commission = lead.commission ?? company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'] ?? null;
|
||||
try {
|
||||
await deliverLead(deps, { token, chatId: user.telegramId }, lead, agent?.name ?? null, commission);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[bot] объект ${lead.id}, отправка себе: ${message}`);
|
||||
// Бот заблокирован или чат с ним не начат: человеку нужно открыть бота и нажать «Старт».
|
||||
if (/forbidden|chat not found|bot was blocked|user is deactivated/i.test(message)) throw badRequest('BOT_CHAT_UNAVAILABLE');
|
||||
throw badRequest('BOT_SEND_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Отправить в Telegram» в карточке объекта: отправка сразу, с тем, что уже есть. Комиссия —
|
||||
* из карточки, иначе из настроек компании.
|
||||
*/
|
||||
export async function sendLeadToGroup(deps: PostDeps, company: typeof companies.$inferSelect, lead: Lead) {
|
||||
const bot = company.settings.groupBot;
|
||||
if (!bot?.chatId) throw badRequest('GROUP_BOT_NOT_SET');
|
||||
const [agent] = lead.assigneeId ? await deps.db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [];
|
||||
const commission = lead.commission ?? company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'] ?? null;
|
||||
try {
|
||||
await postLead(deps, bot, lead, agent?.name ?? null, commission);
|
||||
} catch (error) {
|
||||
console.error(`[group] объект ${lead.id}, ручная отправка: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw badRequest('GROUP_SEND_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
/** Неудачная отправка (нет доступа к группе, сбой сети) повторяется не раньше чем через 10 минут. */
|
||||
const failedAt = new Map<number, number>();
|
||||
|
||||
/** Раз в 20 секунд: объекты в работе, чья комиссия ещё не уходила в группу компании в таком виде. */
|
||||
export async function runGroupPosts({ db, config, sender = telegramGroupSender, fetchImage, now = new Date(), pause = 3_000 }: { db: Db; config: Config; sender?: GroupSender; fetchImage?: ImageFetcher; now?: Date; pause?: number }) {
|
||||
const rows = await db
|
||||
.select({ lead: leads, company: companies, agentName: users.name })
|
||||
.from(leads)
|
||||
.innerJoin(companies, eq(companies.id, leads.companyId))
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.innerJoin(users, eq(users.id, leads.assigneeId))
|
||||
.where(
|
||||
and(
|
||||
sql`${companies.settings}->'groupBot'->>'chatId' is not null`,
|
||||
isNotNull(leads.commission),
|
||||
// В работе: взят и ещё не закрыт сделкой или отказом.
|
||||
sql`coalesce(${pipelineStatuses.code}, '') not in ('new', 'rejected', 'deal')`,
|
||||
sql`${leads.commission} is distinct from ${leads.groupPostedCommission}`,
|
||||
),
|
||||
)
|
||||
// Очередь: раньше всех — объекты, которые ждут дольше.
|
||||
.orderBy(sql`${leads.galleryWantedAt} asc nulls last`, leads.id)
|
||||
.limit(5);
|
||||
let sent = 0;
|
||||
for (const { lead, company, agentName } of rows) {
|
||||
const bot = company.settings.groupBot!;
|
||||
if (now.getTime() - (failedAt.get(lead.id) ?? 0) < 10 * 60_000) continue;
|
||||
const waiting = lead.listingId && !lead.galleryAt && lead.galleryTries < 3;
|
||||
if (waiting) {
|
||||
if (!lead.galleryWantedAt) {
|
||||
await db.update(leads).set({ galleryWantedAt: now }).where(eq(leads.id, lead.id));
|
||||
continue;
|
||||
}
|
||||
if (now.getTime() - lead.galleryWantedAt.getTime() < GALLERY_WAIT_MS) continue;
|
||||
}
|
||||
try {
|
||||
await postLead({ db, config, sender, fetchImage, now }, bot, lead, agentName, lead.commission!, lead.groupPostedCommission !== null);
|
||||
failedAt.delete(lead.id);
|
||||
sent++;
|
||||
// Пауза между объектами: сообщения разных объектов не идут вплотную и не упираются в лимит группы.
|
||||
if (pause) await sleep(pause);
|
||||
} catch (error) {
|
||||
failedAt.set(lead.id, now.getTime());
|
||||
// Повторим на следующем проходе.
|
||||
console.error(`[group] объект ${lead.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import {
|
||||
accessState,
|
||||
canWrite,
|
||||
matchesFilter,
|
||||
normalizePhone,
|
||||
type DealType,
|
||||
type MatchableListing,
|
||||
type ListingFeatures,
|
||||
} from '@lr/shared';
|
||||
import { and, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { blacklist, companies, filters, leads, listings, parserRuns, parserState, pipelineStatuses, seenListings, users } from '../db/schema.ts';
|
||||
import { createLeadFromListing, inDepartment, rejectRemovedListingLeads, sameDepartment } from '../leads/service.ts';
|
||||
import { LISTAM_CATEGORIES, type ListAmCard, type ListAmCategory } from '../parser/listam-source.ts';
|
||||
import { loadParserSettings, saveParserSettings } from '../parser/settings.ts';
|
||||
import type { SourcedListing } from '../parser/source.ts';
|
||||
import { enqueueText } from '../telegram/outbox.ts';
|
||||
|
||||
// Конвейер: страницы категорий list.am → свежие объявления в общую базу → фильтры компаний → лиды.
|
||||
|
||||
/** Источник, читающий категории постранично (ListAmListingSource). */
|
||||
export interface PagedSource {
|
||||
readonly name: string;
|
||||
listPage(category: ListAmCategory, page?: number): Promise<ListAmCard[]>;
|
||||
/**
|
||||
* Дата размещения и, если объявление свежее, его детали. `publishedAt` пуст, если даты на
|
||||
* странице нет. Бросает ошибку, если страницу прочитать не удалось.
|
||||
*/
|
||||
inspect(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise<SourcedListing>;
|
||||
/** Есть ли объявление на сайте; undefined — понять не удалось. Нужен для отметки «Снято с List.am». */
|
||||
/** Все фото объявления — для страницы клиента. */
|
||||
gallery?(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }>;
|
||||
isAlive?(externalId: string): Promise<boolean | undefined>;
|
||||
/** Объявление целиком по номеру; null — его нет на сайте. Нужен, чтобы агент добавил объект ссылкой. */
|
||||
item?(externalId: string): Promise<SourcedListing | null>;
|
||||
/** Ключ ридера из настроек: undefined — ключ сервера по умолчанию, null — без ключа. */
|
||||
configure?(apiKey: string | null | undefined): void;
|
||||
}
|
||||
|
||||
type Listing = typeof listings.$inferSelect;
|
||||
|
||||
export interface IngestReport {
|
||||
status: 'ok' | 'failed';
|
||||
categories: number;
|
||||
foundNew: number;
|
||||
/** Объявления из базы, которые подняли на list.am. */
|
||||
bumped: number;
|
||||
leads: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обычный список list.am отсортирован по «поднятию»: всё, что оказалось наверху с прошлого прохода,
|
||||
* только что опубликовали или подняли, и оно идёт в базу. Блок «Топ» ротируется, а первый проход
|
||||
* застаёт объявления, появившиеся до подключения, — там в базу попадают только размещённые не раньше
|
||||
* чем 3 дня назад (по дате «Размещено» на странице объявления).
|
||||
*/
|
||||
export const FRESH_DAYS = 3;
|
||||
/**
|
||||
* ID объявлений list.am растут по порядку, примерно на 4 000 в день (замер 23.09–08.10.2026).
|
||||
* Если ID отстаёт от самого большого из виденных больше чем на 20 000 (~5 дней), объявление
|
||||
* заведомо старше 3 дней — страницу объявления ради даты можно не читать.
|
||||
*/
|
||||
const IDS_PER_DAY = 4_000;
|
||||
const oldIdGap = (freshDays: number) => (freshDays + 2) * IDS_PER_DAY;
|
||||
/** У list.am две нумерации: старая (до 900 млн, закончилась в сентябре 2026) и новая «995…». */
|
||||
const idRange = (n: number) => (n >= 900_000_000 ? 1 : 0);
|
||||
/** Сколько раз подряд страница объявления может не отдать дату, прежде чем судить по ID. */
|
||||
const DATE_ATTEMPTS = 3;
|
||||
const dateMisses = new Map<string, number>();
|
||||
|
||||
/** Дата известна с точностью до дня (начало дня по Еревану), поэтому сравниваем по дням. */
|
||||
export const isFresh = (publishedAt: Date, now = Date.now(), freshDays = FRESH_DAYS) =>
|
||||
now - publishedAt.getTime() < (freshDays + 1) * 86_400_000;
|
||||
|
||||
/**
|
||||
* Какие объявления подняли с прошлого прохода. Обычные объявления в списке отсортированы по
|
||||
* «поднятию»: те, что не поднимали, стоят в прежнем порядке. Объявление, оказавшееся выше того,
|
||||
* что на прошлом проходе стояло выше него, подняли. Без прошлого порядка понять это нельзя.
|
||||
*/
|
||||
export function bumpedIds(order: string[], previous: string[]): Set<string> {
|
||||
const before = new Map(previous.map((id, i) => [id, i]));
|
||||
const bumped = new Set<string>();
|
||||
if (!before.size) return bumped;
|
||||
let below = Infinity;
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const at = before.get(order[i]!) ?? Infinity;
|
||||
if (at > below) bumped.add(order[i]!);
|
||||
below = Math.min(below, at);
|
||||
}
|
||||
return bumped;
|
||||
}
|
||||
|
||||
/** Сколько объявлений из начала списка категории помнится до следующего прохода. */
|
||||
const ORDER_KEEP = 200;
|
||||
|
||||
interface ActiveFilter {
|
||||
filter: typeof filters.$inferSelect;
|
||||
company: typeof companies.$inferSelect;
|
||||
}
|
||||
|
||||
/** Включённые фильтры компаний с действующим доступом: только по ним парсер что-то ищет. */
|
||||
async function activeFilters(db: Db): Promise<ActiveFilter[]> {
|
||||
const rows = await db
|
||||
.select({ filter: filters, company: companies })
|
||||
.from(filters)
|
||||
.innerJoin(companies, eq(companies.id, filters.companyId))
|
||||
.where(eq(filters.enabled, true));
|
||||
return rows.filter((r) => canWrite(accessState(r.company)));
|
||||
}
|
||||
|
||||
const toRow = (item: SourcedListing) => ({
|
||||
externalId: item.externalId,
|
||||
url: item.sourceUrl,
|
||||
title: item.title,
|
||||
description: item.description ?? null,
|
||||
dealType: item.dealType ?? null,
|
||||
propertyType: item.propertyType ?? null,
|
||||
region: item.region ?? null,
|
||||
district: item.district ?? null,
|
||||
address: item.address ?? null,
|
||||
price: item.price ?? null,
|
||||
currency: item.currency,
|
||||
rooms: item.rooms ?? null,
|
||||
area: item.area ?? null,
|
||||
floor: item.floor ?? null,
|
||||
floorsTotal: item.floorsTotal ?? null,
|
||||
photos: item.photoUrls,
|
||||
authorName: item.ownerName ?? null,
|
||||
phone: normalizePhone(item.phone),
|
||||
isAgency: item.isAgency,
|
||||
publishedAt: item.publishedAt ?? null,
|
||||
features: item.features ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Объявление, которое агент добавил ссылкой, а в общей базе его нет: страница читается с list.am
|
||||
* и объявление сохраняется в общую базу. Возвращает null, если объявления на сайте нет.
|
||||
*/
|
||||
export async function importListing(db: Db, source: PagedSource, externalId: string): Promise<Listing | null> {
|
||||
const settings = await loadParserSettings(db);
|
||||
source.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null);
|
||||
const item = await source.item!(externalId);
|
||||
if (!item) return null;
|
||||
await db.insert(listings).values(toRow(item)).onConflictDoNothing();
|
||||
const [row] = await db.select().from(listings).where(eq(listings.externalId, externalId));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прошлый лид компании по тому же объекту, опубликованному заново под другим номером:
|
||||
* совпали адрес и телефон автора. Если телефон неизвестен — адрес и характеристики, и прошлое
|
||||
* объявление при этом снято с сайта.
|
||||
*/
|
||||
async function similarPrevious(db: Db, companyId: number, departmentId: number | null, listing: Listing): Promise<number | null> {
|
||||
if (!listing.address?.trim()) return null;
|
||||
const sameSpecs = sql`(${leads.propertyType} is not distinct from ${listing.propertyType} and ${leads.dealType} is not distinct from ${listing.dealType}
|
||||
and ${leads.rooms} is not distinct from ${listing.rooms} and ${leads.area} is not distinct from ${listing.area}
|
||||
and ${leads.floor} is not distinct from ${listing.floor} and not ${listings.active})`;
|
||||
const [row] = await db
|
||||
.select({ id: leads.id })
|
||||
.from(leads)
|
||||
.innerJoin(listings, eq(listings.id, leads.listingId))
|
||||
.where(
|
||||
and(
|
||||
eq(leads.companyId, companyId),
|
||||
inDepartment(leads.departmentId, departmentId),
|
||||
ne(leads.listingId, listing.id),
|
||||
sql`lower(trim(${leads.address})) = lower(trim(${listing.address}))`,
|
||||
listing.phone
|
||||
? sql`(${leads.phone} = ${listing.phone} or (${leads.phone} is null and ${sameSpecs}))`
|
||||
: sameSpecs,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(leads.id))
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сопоставляет объявление с фильтрами и создаёт лиды. У каждого отдела компании свой лид: объект
|
||||
* приходит в отдел, чей фильтр сработал (общий фильтр компании — в «без отдела», личный фильтр — в отдел
|
||||
* владельца). Уведомление получают сотрудники этого отдела, если сработал общий фильтр или фильтр отдела,
|
||||
* и владелец сработавшего личного фильтра.
|
||||
* `republished` — объявление сняли и опубликовали снова под тем же номером: компании, у которой
|
||||
* прошлый лид закрыт сделкой или отказом, объект приходит новым лидом со ссылкой на прошлый.
|
||||
*/
|
||||
export async function distribute(db: Db, listing: Listing, active: ActiveFilter[], notify = true, republished = false): Promise<number> {
|
||||
const matchable: MatchableListing = { ...listing, dealType: listing.dealType as DealType | null, currency: listing.currency as MatchableListing['currency'] };
|
||||
const byCompany = new Map<number, ActiveFilter[]>();
|
||||
for (const a of active) byCompany.set(a.company.id, [...(byCompany.get(a.company.id) ?? []), a]);
|
||||
|
||||
let created = 0;
|
||||
for (const [companyId, list] of byCompany) {
|
||||
const company = list[0]!.company;
|
||||
let blacklisted = false;
|
||||
let activeForPhone = 0;
|
||||
if (listing.phone) {
|
||||
const [b] = await db
|
||||
.select({ id: blacklist.id })
|
||||
.from(blacklist)
|
||||
.where(and(eq(blacklist.companyId, companyId), eq(blacklist.phone, listing.phone)));
|
||||
blacklisted = Boolean(b);
|
||||
const [n] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(listings)
|
||||
.where(and(eq(listings.phone, listing.phone), eq(listings.active, true)));
|
||||
activeForPhone = n?.n ?? 0;
|
||||
}
|
||||
const ctx = { rules: company.settings.agentRules, blacklisted, activeListingsForPhone: activeForPhone };
|
||||
const matched = list.filter((a) => matchesFilter(matchable, a.filter.params, ctx));
|
||||
if (!matched.length) continue;
|
||||
|
||||
const members = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
|
||||
const departmentOf = new Map(members.map((u) => [u.id, u.departmentId ?? null]));
|
||||
/** Отдел → сработал общий фильтр (компании или отдела) и чьи личные фильтры сработали. */
|
||||
const targets = new Map<number | null, { common: boolean; personal: Set<number> }>();
|
||||
for (const { filter } of matched) {
|
||||
// Личный фильтр удалённого сотрудника объектов не приносит.
|
||||
if (filter.ownerUserId !== null && !departmentOf.has(filter.ownerUserId)) continue;
|
||||
const department = filter.ownerUserId === null ? (filter.departmentId ?? null) : departmentOf.get(filter.ownerUserId)!;
|
||||
const target = targets.get(department) ?? { common: false, personal: new Set<number>() };
|
||||
if (filter.ownerUserId === null) target.common = true;
|
||||
else target.personal.add(filter.ownerUserId);
|
||||
targets.set(department, target);
|
||||
}
|
||||
|
||||
for (const [departmentId, { common, personal }] of targets) {
|
||||
const recipients = notify
|
||||
? members.filter((u) => (common && sameDepartment(u.departmentId, departmentId)) || personal.has(u.id))
|
||||
: [];
|
||||
const [last] = await db
|
||||
.select({ id: leads.id, code: pipelineStatuses.code })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.where(and(eq(leads.companyId, companyId), inDepartment(leads.departmentId, departmentId), eq(leads.listingId, listing.id)))
|
||||
.orderBy(desc(leads.id))
|
||||
.limit(1);
|
||||
// По этому объявлению у отдела уже есть лид. Новый нужен, только если объявление
|
||||
// опубликовали заново, а прошлый лид закрыт.
|
||||
if (last && !(republished && (last.code === 'deal' || last.code === 'rejected'))) continue;
|
||||
const previousLeadId = last?.id ?? (await similarPrevious(db, companyId, departmentId, listing));
|
||||
|
||||
const lead = await db.transaction((tx) => createLeadFromListing(tx, companyId, listing, recipients, previousLeadId, departmentId));
|
||||
if (lead) created++;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Сколько объявлений из базы просматривается при создании или изменении фильтра. */
|
||||
const BACKFILL_LIMIT = 2000;
|
||||
|
||||
/**
|
||||
* Новый или изменённый фильтр сразу получает подходящие объявления из общей базы:
|
||||
* они появляются в пуле компании без уведомлений в Telegram (объявления не новые).
|
||||
* Возвращает число созданных лидов.
|
||||
*/
|
||||
export async function backfillFilter(db: Db, filterId: number, now = Date.now()): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ filter: filters, company: companies })
|
||||
.from(filters)
|
||||
.innerJoin(companies, eq(companies.id, filters.companyId))
|
||||
.where(eq(filters.id, filterId));
|
||||
if (!row || !row.filter.enabled || !canWrite(accessState(row.company))) return 0;
|
||||
// В базе лежат объявления не старше 3 дней на момент находки или поднятия; со временем они стареют,
|
||||
// поэтому свежесть проверяется ещё раз.
|
||||
const { freshDays } = await loadParserSettings(db);
|
||||
const since = new Date(now - (freshDays + 1) * 86_400_000);
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(listings)
|
||||
.where(and(eq(listings.active, true), sql`coalesce(${listings.bumpedAt}, ${listings.publishedAt}, ${listings.createdAt}) >= ${since.toISOString()}::timestamptz`))
|
||||
.orderBy(desc(listings.id))
|
||||
.limit(BACKFILL_LIMIT);
|
||||
let created = 0;
|
||||
for (const listing of candidates) created += await distribute(db, listing, [row], false);
|
||||
return created;
|
||||
}
|
||||
|
||||
export interface IngestDeps {
|
||||
db: Db;
|
||||
source: PagedSource;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Один проход парсера. Читаются все категории недвижимости list.am: свежие объявления
|
||||
* складываются в общую базу сервиса независимо от того, есть ли под них фильтр, и уже из базы
|
||||
* расходятся клиентам по фильтрам.
|
||||
*/
|
||||
export async function runIngest({ db, source, log = () => {} }: IngestDeps): Promise<IngestReport> {
|
||||
const active = await activeFilters(db);
|
||||
const settings = await loadParserSettings(db);
|
||||
source.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null);
|
||||
const categories = LISTAM_CATEGORIES.filter((c) => settings.categories.includes(c.id));
|
||||
const isRecent = (d: Date, at: number) => isFresh(d, at, settings.freshDays);
|
||||
const deep = settings.deepPass;
|
||||
if (deep) log(`Глубокий проход: до ${settings.maxPages} стр., свежесть ${settings.freshDays} дн.`);
|
||||
const [run] = await db.insert(parserRuns).values({}).returning();
|
||||
const report: IngestReport = { status: 'ok', categories: categories.length, foundNew: 0, bumped: 0, leads: 0, errors: [] };
|
||||
const state = new Map((await db.select().from(parserState)).map((s) => [s.categoryId, s]));
|
||||
const [max] = await db
|
||||
.select({
|
||||
a: sql<string | null>`max(external_id::bigint) filter (where external_id::bigint < 900000000)`,
|
||||
b: sql<string | null>`max(external_id::bigint) filter (where external_id::bigint >= 900000000)`,
|
||||
})
|
||||
.from(listings)
|
||||
.where(sql`external_id ~ '^[0-9]{1,15}$'`);
|
||||
const highest = [Number(max?.a ?? 0), Number(max?.b ?? 0)];
|
||||
const now = Date.now();
|
||||
const skip = (externalId: string) => db.insert(seenListings).values({ externalId }).onConflictDoNothing();
|
||||
|
||||
// Страницы категорий запрашиваются сразу все: источник сам выдерживает темп запросов.
|
||||
const results = await Promise.allSettled(
|
||||
categories.map(async (category) => {
|
||||
// Первый проход по категории наполняет базу без лидов: эти объявления появились
|
||||
// на сайте до подключения, клиентам о них уведомления не нужны.
|
||||
const baseline = !state.has(category.id);
|
||||
const previous = state.get(category.id)?.topIds ?? [];
|
||||
/** Обычные объявления этого прохода в порядке списка. */
|
||||
const order: string[] = [];
|
||||
let read = false;
|
||||
for (let page = 1; page <= (baseline ? 1 : settings.maxPages); page++) {
|
||||
const cards = await source.listPage(category, page);
|
||||
if (!cards.length) break;
|
||||
read = true;
|
||||
const ids = cards.map((c) => c.externalId);
|
||||
const inBase = new Map(
|
||||
(await db.select().from(listings).where(inArray(listings.externalId, ids))).map((r) => [r.externalId, r] as const),
|
||||
);
|
||||
const seen = new Set(
|
||||
(await db.select({ id: seenListings.externalId }).from(seenListings).where(inArray(seenListings.externalId, ids))).map((r) => r.id),
|
||||
);
|
||||
// Снятое объявление снова в списке — его опубликовали заново.
|
||||
const back = await db
|
||||
.update(listings)
|
||||
.set({ active: true, lastCheckedAt: new Date() })
|
||||
.where(and(inArray(listings.externalId, ids), eq(listings.active, false)))
|
||||
.returning();
|
||||
for (const row of back) if (!baseline) report.leads += await distribute(db, row, active, true, true);
|
||||
for (const n of cards.map((c) => Number(c.externalId)).filter(Number.isFinite)) {
|
||||
if (n > highest[idRange(n)]!) highest[idRange(n)] = n;
|
||||
}
|
||||
const regular = cards.filter((c) => !c.promoted);
|
||||
order.push(...regular.map((c) => c.externalId));
|
||||
const bumped = baseline ? new Set<string>() : bumpedIds(order, previous);
|
||||
/** Обычные объявления страницы, которые новые для списка: опубликованы или подняты с прошлого прохода. */
|
||||
let moved = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
const isBumped = !card.promoted && bumped.has(card.externalId);
|
||||
const row = inBase.get(card.externalId);
|
||||
if (row) {
|
||||
if (!isBumped) continue;
|
||||
// Объявление из базы подняли: объект встаёт наверх в базе и у компаний, где по нему есть лид.
|
||||
moved++;
|
||||
report.bumped++;
|
||||
// Время поднятия ставит база — по тем же часам, что и время создания лидов: по ним сортируется список.
|
||||
const [lifted] = await db.update(listings).set({ bumpedAt: sql`now()` }).where(eq(listings.id, row.id)).returning();
|
||||
await db.update(leads).set({ bumpedAt: lifted!.bumpedAt }).where(eq(leads.listingId, row.id));
|
||||
// Компании, у которых лида по нему нет, а фильтр подходит, получают его как новый.
|
||||
report.leads += await distribute(db, lifted!, active);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(card.externalId)) {
|
||||
if (!isBumped) continue;
|
||||
// Старое объявление, которое раньше пропустили, подняли — теперь оно актуально.
|
||||
await db.delete(seenListings).where(eq(seenListings.externalId, card.externalId));
|
||||
}
|
||||
if (!card.promoted) moved++;
|
||||
// Новое для списка обычное объявление после первого прохода опубликовали или подняли только что:
|
||||
// оно идёт в базу, как бы давно его ни разместили. Блок «Топ» ротируется — там судим по дате.
|
||||
// В глубоком проходе объявления из глубины списка — не «только что опубликованные»: судим по дате.
|
||||
const anyAge = !baseline && !card.promoted && !deep;
|
||||
const id = Number(card.externalId);
|
||||
if (!anyAge && Number.isFinite(id) && id < highest[idRange(id)]! - oldIdGap(settings.freshDays)) {
|
||||
// Заведомо старое объявление, которое «подняли» в списке.
|
||||
await skip(card.externalId);
|
||||
continue;
|
||||
}
|
||||
let full: SourcedListing;
|
||||
let problem = 'на странице нет даты размещения';
|
||||
try {
|
||||
full = await source.inspect(card, (d) => anyAge || isRecent(d, now));
|
||||
} catch (error) {
|
||||
full = card;
|
||||
problem = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
if (!full.publishedAt) {
|
||||
// Страницу не удалось прочитать или она пришла без даты. Пробуем ещё на следующих
|
||||
// проходах, затем берём данные из списка и судим по ID: он уже прошёл проверку
|
||||
// «не старше ~5 дней».
|
||||
const misses = (dateMisses.get(card.externalId) ?? 0) + 1;
|
||||
if (misses < DATE_ATTEMPTS) {
|
||||
dateMisses.set(card.externalId, misses);
|
||||
report.errors.push(`${card.sourceUrl}: ${problem}, попытка ${misses}`);
|
||||
continue;
|
||||
}
|
||||
} else if (!anyAge && !isRecent(full.publishedAt, now)) {
|
||||
await skip(card.externalId);
|
||||
continue;
|
||||
}
|
||||
dateMisses.delete(card.externalId);
|
||||
const old = full.publishedAt && !isRecent(full.publishedAt, now);
|
||||
const [created] = await db.insert(listings).values({ ...toRow(full), bumpedAt: old ? sql`now()` : null }).onConflictDoNothing().returning();
|
||||
if (!created) continue;
|
||||
report.foundNew++;
|
||||
if (!baseline) report.leads += await distribute(db, created, active);
|
||||
}
|
||||
// На странице остались объявления, стоящие на прежних местах, — дальше всё уже было.
|
||||
if (!regular.length || (!deep && moved < regular.length)) break;
|
||||
}
|
||||
if (read) {
|
||||
const topIds = order.slice(0, ORDER_KEEP);
|
||||
await db
|
||||
.insert(parserState)
|
||||
.values({ categoryId: category.id, topIds })
|
||||
.onConflictDoUpdate({ target: parserState.categoryId, set: { topIds } });
|
||||
}
|
||||
}),
|
||||
);
|
||||
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
|
||||
for (const f of failures) report.errors.push(f.reason instanceof Error ? f.reason.message : String(f.reason));
|
||||
if (categories.length && failures.length === categories.length) report.status = 'failed';
|
||||
|
||||
await db
|
||||
.update(parserRuns)
|
||||
.set({
|
||||
finishedAt: new Date(),
|
||||
status: report.status,
|
||||
foundNew: report.foundNew,
|
||||
errors: report.errors.length ? report.errors.slice(0, 50).join('\n') : null,
|
||||
})
|
||||
.where(eq(parserRuns.id, run!.id));
|
||||
log(`Парсер: в базу ${report.foundNew}, лидов ${report.leads}, ошибок ${report.errors.length}`);
|
||||
|
||||
if (report.status === 'failed') await alertSuperadmins(db, report.errors[0] ?? 'источник недоступен');
|
||||
else if (deep) await saveParserSettings(db, { deepPass: false });
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Алерт суперадминам в Telegram при сбое парсера — не чаще раза в час. */
|
||||
async function alertSuperadmins(db: Db, error: string) {
|
||||
const [recent] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(parserRuns)
|
||||
.where(and(eq(parserRuns.status, 'failed'), gte(parserRuns.startedAt, new Date(Date.now() - 3_600_000))));
|
||||
if ((recent?.n ?? 0) > 1) return;
|
||||
const admins = await db.select({ id: users.id }).from(users).where(eq(users.role, 'superadmin'));
|
||||
await enqueueText(db, admins.map((a) => a.id), 'parserFailed', [error.slice(0, 300)]);
|
||||
}
|
||||
|
||||
|
||||
/** Через сколько часов после находки объявление проверяется на list.am первый раз. */
|
||||
const FIRST_CHECK_HOURS = 4;
|
||||
|
||||
/**
|
||||
* Проверка объектов из CRM на list.am: снятое объявление получает отметку и дату снятия.
|
||||
* За запуск проверяется небольшая пачка — запросы идут в общей очереди с парсером; каждое
|
||||
* объявление проверяется раз в сутки, первый раз — через FIRST_CHECK_HOURS после находки.
|
||||
* Сначала идут объекты, которые кто-то брал в работу.
|
||||
*/
|
||||
export async function runRemovalCheck({ db, source, now = new Date() }: IngestDeps & { now?: Date }) {
|
||||
const report = { checked: 0, removed: 0 };
|
||||
const settings = await loadParserSettings(db);
|
||||
if (!source.isAlive || !settings.removalCheck) return report;
|
||||
const RECHECK_HOURS = settings.recheckHours;
|
||||
const limit = settings.removalBatch;
|
||||
const due = new Date(now.getTime() - RECHECK_HOURS * 3_600_000).toISOString();
|
||||
// Свежие объявления чаще всего снимают в первые сутки, поэтому первая проверка — раньше повторных.
|
||||
const firstDue = new Date(now.getTime() - Math.min(FIRST_CHECK_HOURS, RECHECK_HOURS) * 3_600_000).toISOString();
|
||||
const worked = sql<boolean>`exists (select 1 from leads l where l.listing_id = "listings"."id" and (l.assignee_id is not null or l.taken_at is not null))`;
|
||||
const batch = await db
|
||||
.select({ id: listings.id, externalId: listings.externalId })
|
||||
.from(listings)
|
||||
.where(
|
||||
and(
|
||||
eq(listings.active, true),
|
||||
sql`exists (select 1 from leads l where l.listing_id = "listings"."id")`,
|
||||
sql`(${listings.lastCheckedAt} <= ${due}::timestamptz or (${listings.lastCheckedAt} is null and ${listings.createdAt} <= ${firstDue}::timestamptz))`,
|
||||
),
|
||||
)
|
||||
// Из ни разу не проверенных первыми идут самые новые: их чаще всего уже сняли, пока очередь доходит.
|
||||
.orderBy(desc(worked), sql`${listings.lastCheckedAt} nulls first`, desc(listings.id))
|
||||
.limit(limit);
|
||||
for (const row of batch) {
|
||||
const alive = await source.isAlive(row.externalId).catch(() => undefined);
|
||||
report.checked++;
|
||||
if (alive === false) {
|
||||
report.removed++;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(listings).set({ active: false, removedAt: now, lastCheckedAt: now }).where(eq(listings.id, row.id));
|
||||
await rejectRemovedListingLeads(tx, row.id);
|
||||
});
|
||||
} else {
|
||||
// Не удалось понять — повторим через час, а не через сутки.
|
||||
const checkedAt = alive ? now : new Date(now.getTime() - (RECHECK_HOURS - 1) * 3_600_000);
|
||||
await db.update(listings).set({ lastCheckedAt: checkedAt }).where(eq(listings.id, row.id));
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { parserRuns, settings, users } from '../db/schema.ts';
|
||||
import { loadParserSettings } from '../parser/settings.ts';
|
||||
import { enqueueRaw } from '../telegram/outbox.ts';
|
||||
|
||||
// Сторож парсера: если парсер выключен или давно не запускался, суперадминам приходит сообщение
|
||||
// в Telegram; пока проблема не ушла — напоминание раз в несколько часов, после — «снова работает».
|
||||
// 20.10 парсер простоял почти двое суток выключенным, и это заметили только по пропавшему объекту.
|
||||
|
||||
/** Сколько минут без нового прохода считается «стоит». Глубокий проход идёт ~10 минут. */
|
||||
export const STALL_MINUTES = 20;
|
||||
const REMIND_HOURS = 6;
|
||||
|
||||
export interface WatchdogState {
|
||||
/** Когда отправлено последнее сообщение о простое; null — парсер в порядке. */
|
||||
alertedAt: Date | null;
|
||||
}
|
||||
|
||||
const ago = (from: Date, now: Date) => {
|
||||
const min = Math.round((now.getTime() - from.getTime()) / 60_000);
|
||||
return min < 120 ? `${min} мин` : `${Math.round(min / 60)} ч`;
|
||||
};
|
||||
|
||||
/** Что не так с парсером, или null, если всё в порядке. */
|
||||
export async function parserProblem(db: Db, now = new Date()): Promise<string | null> {
|
||||
const parser = await loadParserSettings(db);
|
||||
const stall = STALL_MINUTES * 60_000;
|
||||
if (!parser.enabled) {
|
||||
const [row] = await db.select({ at: settings.updatedAt }).from(settings).where(eq(settings.key, 'parser'));
|
||||
const since = row?.at ?? now;
|
||||
// Только что выключили руками — даём время включить обратно.
|
||||
return now.getTime() - since.getTime() >= stall ? `выключен в настройках (уже ${ago(since, now)})` : null;
|
||||
}
|
||||
const [last] = await db.select({ at: parserRuns.startedAt }).from(parserRuns).orderBy(desc(parserRuns.id)).limit(1);
|
||||
if (!last) return null;
|
||||
return now.getTime() - last.at.getTime() >= stall ? `не запускался ${ago(last.at, now)}` : null;
|
||||
}
|
||||
|
||||
/** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */
|
||||
export async function runParserWatchdog(db: Db, state: WatchdogState, now = new Date()): Promise<void> {
|
||||
const problem = await parserProblem(db, now);
|
||||
const remind = state.alertedAt && now.getTime() - state.alertedAt.getTime() >= REMIND_HOURS * 3_600_000;
|
||||
if (problem && (!state.alertedAt || remind)) {
|
||||
await notify(db, `⚠️ Парсер стоит: ${problem}. Новые объекты не приходят.\nВключить — Админка → Парсер.`);
|
||||
state.alertedAt = now;
|
||||
} else if (!problem && state.alertedAt) {
|
||||
await notify(db, '✅ Парсер снова работает.');
|
||||
state.alertedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function notify(db: Db, text: string) {
|
||||
const admins = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), isNull(users.removedAt)));
|
||||
await enqueueRaw(db, admins.map((a) => a.id), text);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { normalizePhone, RENT_TERMS, YEREVAN_DISTRICTS, type ClientRequest, type Household, type RentTerm } from '@lr/shared';
|
||||
import { and, desc, eq, isNotNull, sql } from 'drizzle-orm';
|
||||
import { newToken } from '../auth/sessions.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { statusByCode } from '../pipelines/service.ts';
|
||||
import type { Keyboard, TelegramApi, TgUpdate } from '../telegram/client.ts';
|
||||
import { enqueueRaw } from '../telegram/outbox.ts';
|
||||
import { esc } from '../telegram/texts.ts';
|
||||
import { intakeTexts, LANGUAGE_BUTTONS, LANGUAGE_QUESTION } from './texts.ts';
|
||||
|
||||
// Клиентский бот: соискатель переходит по ссылке агента и отвечает на вопросы анкеты.
|
||||
// По готовой анкете в CRM появляется клиент, закреплённый за автором ссылки, а автору в основной
|
||||
// бот приходит сводка с вопросом «Показать подходящие объекты?».
|
||||
|
||||
type User = typeof users.$inferSelect;
|
||||
type Session = typeof intakeSessions.$inferSelect;
|
||||
|
||||
/** Ссылка агента на анкету; одна на агента, создаётся при первом запросе. null — клиентский бот не настроен. */
|
||||
export async function intakeLink(db: Db, config: Config, user: User): Promise<string | null> {
|
||||
if (!config.clientBotToken || !config.clientBotUsername || !user.companyId) return null;
|
||||
const [link] = await db
|
||||
.insert(intakeLinks)
|
||||
.values({ token: newToken().slice(0, 24), companyId: user.companyId, userId: user.id })
|
||||
.onConflictDoUpdate({ target: intakeLinks.userId, set: { companyId: user.companyId } })
|
||||
.returning();
|
||||
return `https://t.me/${config.clientBotUsername}?start=${link!.token}`;
|
||||
}
|
||||
|
||||
/** Скачивает фото, присланное в клиентский бот. null — не получилось или файл слишком большой. */
|
||||
export type FileDownloader = (fileId: string) => Promise<{ data: Buffer; type: string } | null>;
|
||||
|
||||
export const telegramDownloader = (token: string): FileDownloader => async (fileId) => {
|
||||
const info = (await (await fetch(`https://api.telegram.org/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`)).json()) as {
|
||||
ok: boolean;
|
||||
result?: { file_path?: string; file_size?: number };
|
||||
};
|
||||
if (!info.ok || !info.result?.file_path || (info.result.file_size ?? 0) > 5 * 1024 * 1024) return null;
|
||||
const res = await fetch(`https://api.telegram.org/file/bot${token}/${info.result.file_path}`);
|
||||
if (!res.ok) return null;
|
||||
return { data: Buffer.from(await res.arrayBuffer()), type: 'image/jpeg' };
|
||||
};
|
||||
|
||||
export interface IntakeDeps {
|
||||
db: Db;
|
||||
/** Клиентский бот. */
|
||||
tg: TelegramApi;
|
||||
config: Config;
|
||||
download?: FileDownloader;
|
||||
}
|
||||
|
||||
interface Answers {
|
||||
deal?: 'rent' | 'sale';
|
||||
type?: string;
|
||||
place?: string;
|
||||
budget?: { amount: number; currency: 'USD' | 'AMD' | 'RUB' | 'EUR' } | null;
|
||||
rooms?: string;
|
||||
/** Срок аренды: short | mid | long. */
|
||||
term?: RentTerm;
|
||||
who?: 'alone' | 'friend' | 'couple';
|
||||
children?: number;
|
||||
childrenAge?: string;
|
||||
pets?: number;
|
||||
petsAge?: string;
|
||||
name?: string;
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
const STEPS = ['deal', 'type', 'place', 'budget', 'rooms', 'term', 'who', 'children', 'childrenAge', 'pets', 'petsAge', 'name', 'phone'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
/** Следующий вопрос: жильцов спрашиваем только у арендаторов, возраст — только если дети или животные есть. */
|
||||
function nextStep(after: Step | null, a: Answers): Step | 'done' {
|
||||
for (const step of STEPS.slice(after ? STEPS.indexOf(after) + 1 : 0)) {
|
||||
if (a.deal === 'sale' && ['term', 'who', 'children', 'childrenAge', 'pets', 'petsAge'].includes(step)) continue;
|
||||
if (step === 'childrenAge' && !a.children) continue;
|
||||
if (step === 'petsAge' && !a.pets) continue;
|
||||
return step;
|
||||
}
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/** «600 000 драм», «1500$», «2000 usd» → сумма и валюта. */
|
||||
export function parseBudget(text: string, deal: 'rent' | 'sale' = 'rent'): Answers['budget'] | undefined {
|
||||
const digits = text.replace(/[\s.,'’]/g, '').match(/\d{2,12}/)?.[0];
|
||||
if (!digits) return undefined;
|
||||
const amount = Number(digits);
|
||||
const t = text.toLowerCase();
|
||||
const currency = /usd|\$|долл|դոլար/.test(t)
|
||||
? 'USD'
|
||||
: /amd|драм|֏|դրամ/.test(t)
|
||||
? 'AMD'
|
||||
: /rub|руб|₽/.test(t)
|
||||
? 'RUB'
|
||||
: /eur|евро|€/.test(t)
|
||||
? 'EUR'
|
||||
: // Валюта не названа: по величине суммы. Аренда от 20 000 и покупка от 5 млн — это драмы.
|
||||
amount >= (deal === 'rent' ? 20_000 : 5_000_000)
|
||||
? 'AMD'
|
||||
: 'USD';
|
||||
return { amount, currency };
|
||||
}
|
||||
|
||||
function toRequest(a: Answers): Partial<ClientRequest> {
|
||||
const rooms = a.rooms === '4' ? [4, 5, 6] : a.rooms && a.rooms !== 'any' ? [Number(a.rooms)] : [];
|
||||
const district = a.place && a.place !== 'any' && a.place !== 'yerevan' ? a.place : null;
|
||||
return {
|
||||
propertyTypes: a.type && a.type !== 'any' ? [a.type as 'apartment'] : [],
|
||||
...(a.place && a.place !== 'any' ? { region: 'Ереван' } : {}),
|
||||
districts: district ? [district] : [],
|
||||
...(a.budget ? { priceTo: a.budget.amount, priceCurrency: a.budget.currency } : {}),
|
||||
rooms,
|
||||
};
|
||||
}
|
||||
|
||||
function toHousehold(a: Answers): Household | null {
|
||||
if (a.deal !== 'rent') return null;
|
||||
const h: Household = {
|
||||
...(a.who ? { who: a.who } : {}),
|
||||
...(a.children ? { children: { count: a.children, age: a.childrenAge ?? '' } } : {}),
|
||||
...(a.pets ? { pets: { count: a.pets, age: a.petsAge ?? '' } } : {}),
|
||||
};
|
||||
return Object.keys(h).length ? h : null;
|
||||
}
|
||||
|
||||
const btn = (text: string, step: Step, value: string) => ({ text, callback: `q:${step}:${value}` });
|
||||
|
||||
function question(step: Step, s: Session): { text: string; keyboard?: Keyboard } {
|
||||
const t = intakeTexts(s.lang);
|
||||
switch (step) {
|
||||
case 'deal':
|
||||
return { text: t.deal, keyboard: [[btn(t.rent, step, 'rent'), btn(t.sale, step, 'sale')]] };
|
||||
case 'type':
|
||||
return { text: t.type, keyboard: [[btn(t.apartment, step, 'apartment'), btn(t.house, step, 'house')], [btn(t.commercial, step, 'commercial'), btn(t.any, step, 'any')]] };
|
||||
case 'place': {
|
||||
const rows: Keyboard = [[btn(t.yerevan, step, 'yerevan'), btn(t.anywhere, step, 'any')]];
|
||||
for (let i = 0; i < YEREVAN_DISTRICTS.length; i += 2) {
|
||||
rows.push(YEREVAN_DISTRICTS.slice(i, i + 2).map((d) => btn(d, step, d)));
|
||||
}
|
||||
return { text: t.place, keyboard: rows };
|
||||
}
|
||||
case 'budget':
|
||||
return { text: t.budget, keyboard: [[btn(t.skip, step, 'skip')]] };
|
||||
case 'rooms':
|
||||
return { text: t.rooms, keyboard: [[btn('1', step, '1'), btn('2', step, '2'), btn('3', step, '3')], [btn(t.rooms4, step, '4'), btn(t.any, step, 'any')]] };
|
||||
case 'term':
|
||||
return { text: t.term, keyboard: [[btn(t.termShort, step, 'short'), btn(t.termMid, step, 'mid')], [btn(t.termLong, step, 'long')]] };
|
||||
case 'who':
|
||||
return { text: t.who, keyboard: [[btn(t.alone, step, 'alone'), btn(t.friend, step, 'friend')], [btn(t.couple, step, 'couple')]] };
|
||||
case 'children':
|
||||
return { text: t.children, keyboard: [[btn(t.none, step, '0'), btn('1', step, '1'), btn('2', step, '2'), btn(t.more3, step, '3')]] };
|
||||
case 'childrenAge':
|
||||
return { text: t.childrenAge };
|
||||
case 'pets':
|
||||
return { text: t.pets, keyboard: [[btn(t.none, step, '0'), btn('1', step, '1'), btn(t.more2, step, '2')]] };
|
||||
case 'petsAge':
|
||||
return { text: t.petsAge };
|
||||
case 'name':
|
||||
return { text: t.name, keyboard: s.tgName ? [[btn(t.keepName(s.tgName.slice(0, 30)), step, 'keep')]] : undefined };
|
||||
case 'phone':
|
||||
return { text: t.phone, keyboard: [[btn(t.skip, step, 'skip')]] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Ответ на текущий вопрос. undefined — ответ не подошёл (вторым значением — что сказать). */
|
||||
function accept(step: Step, s: Session, value: string, viaButton: boolean): { patch: Partial<Answers> } | { error: string } {
|
||||
const t = intakeTexts(s.lang);
|
||||
const a = s.answers as Answers;
|
||||
const buttonOnly = { error: t.useButtons };
|
||||
switch (step) {
|
||||
case 'deal':
|
||||
return viaButton && (value === 'rent' || value === 'sale') ? { patch: { deal: value } } : buttonOnly;
|
||||
case 'type':
|
||||
return viaButton ? { patch: { type: value } } : buttonOnly;
|
||||
case 'place':
|
||||
return viaButton ? { patch: { place: value } } : buttonOnly;
|
||||
case 'budget': {
|
||||
if (viaButton) return { patch: { budget: null } };
|
||||
const budget = parseBudget(value, a.deal);
|
||||
return budget ? { patch: { budget } } : { error: t.badBudget };
|
||||
}
|
||||
case 'rooms':
|
||||
return viaButton ? { patch: { rooms: value } } : buttonOnly;
|
||||
case 'term':
|
||||
return viaButton && (RENT_TERMS as readonly string[]).includes(value) ? { patch: { term: value as RentTerm } } : buttonOnly;
|
||||
case 'who':
|
||||
return viaButton ? { patch: { who: value as 'alone' } } : buttonOnly;
|
||||
case 'children':
|
||||
return viaButton ? { patch: { children: Number(value) || 0 } } : buttonOnly;
|
||||
case 'childrenAge':
|
||||
return { patch: { childrenAge: value.slice(0, 100) } };
|
||||
case 'pets':
|
||||
return viaButton ? { patch: { pets: Number(value) || 0 } } : buttonOnly;
|
||||
case 'petsAge':
|
||||
return { patch: { petsAge: value.slice(0, 100) } };
|
||||
case 'name':
|
||||
return { patch: { name: (viaButton ? (s.tgName ?? 'Telegram') : value).slice(0, 120) } };
|
||||
case 'phone': {
|
||||
if (viaButton) return { patch: { phone: null } };
|
||||
return value.replace(/\D/g, '').length >= 6 ? { patch: { phone: value.slice(0, 40) } } : { error: t.badPhone };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Анкета заполнена: клиент в CRM и сводка автору ссылки в основной бот. */
|
||||
async function complete(db: Db, s: Session, a: Answers) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [link] = await tx.select().from(intakeLinks).where(eq(intakeLinks.token, s.linkToken));
|
||||
const [owner] = await tx.select().from(users).where(eq(users.id, link!.userId));
|
||||
// Тот же человек уже заполнял анкету этого агента — обновляем его карточку, а не плодим вторую.
|
||||
const [earlier] = await tx
|
||||
.select({ clientId: intakeSessions.clientId })
|
||||
.from(intakeSessions)
|
||||
.where(and(eq(intakeSessions.chatId, s.chatId), eq(intakeSessions.linkToken, s.linkToken), isNotNull(intakeSessions.clientId)))
|
||||
.orderBy(desc(intakeSessions.id))
|
||||
.limit(1);
|
||||
const fields = {
|
||||
name: a.name ?? s.tgName ?? 'Telegram',
|
||||
phone: a.phone ? (normalizePhone(a.phone) ?? a.phone) : null,
|
||||
// Telegram — из чата анкеты: связаться с клиентом можно и без телефона.
|
||||
telegram: s.tgUsername ? `@${s.tgUsername}` : null,
|
||||
rentTerm: a.deal === 'sale' ? null : (a.term ?? null),
|
||||
kind: a.deal === 'sale' ? 'buyer' : 'tenant',
|
||||
request: toRequest(a),
|
||||
household: toHousehold(a),
|
||||
};
|
||||
let clientId = earlier?.clientId ?? null;
|
||||
if (clientId) {
|
||||
await tx.update(clients).set(fields).where(eq(clients.id, clientId));
|
||||
await tx.insert(events).values({ companyId: link!.companyId, clientId, userId: null, type: 'edited' });
|
||||
} else {
|
||||
const fresh = await statusByCode(tx, link!.companyId, 'new', 'client');
|
||||
const [client] = await tx
|
||||
.insert(clients)
|
||||
.values({
|
||||
...fields, companyId: link!.companyId, assigneeId: link!.userId, statusId: fresh.id, source: 'Анкета в Telegram',
|
||||
// Клиент попадает в отдел агента, чья это ссылка.
|
||||
departmentId: owner?.departmentId ?? null,
|
||||
})
|
||||
.returning();
|
||||
clientId = client!.id;
|
||||
await tx.insert(events).values({ companyId: link!.companyId, clientId, userId: null, type: 'created' });
|
||||
}
|
||||
await tx.update(intakeSessions).set({ step: 'done', answers: a as Record<string, unknown>, clientId, completedAt: new Date() }).where(eq(intakeSessions.id, s.id));
|
||||
await tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, link!.companyId));
|
||||
// Сводка автору ссылки с вопросом, показать ли клиенту подходящие объекты.
|
||||
await tx.insert(outbox).values({
|
||||
kind: 'text',
|
||||
userId: link!.userId,
|
||||
payload: { key: 'intakeDone', args: [fields.name, fields.kind, clientId], buttons: [{ key: 'intakeMatch', callback: `match:${clientId}` }, { key: 'intakeLater', callback: `nomatch:${clientId}` }] },
|
||||
});
|
||||
return { clientId, owner: owner! };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиент написал в бот после анкеты (например, прислал телефон по просьбе агента). Сообщение — в историю
|
||||
* карточки, номер — в карточку, если его там нет; агенту и суперадминам — уведомление в основной бот.
|
||||
*/
|
||||
async function saveReply(db: Db, clientId: number, reply: string): Promise<boolean> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [client] = await tx.select().from(clients).where(eq(clients.id, clientId));
|
||||
if (!client) return false;
|
||||
const message = reply.slice(0, 1000);
|
||||
const phone = message.replace(/\D/g, '').length >= 6 ? normalizePhone(message) : null;
|
||||
const phoneSaved = Boolean(phone && !client.phone);
|
||||
if (phoneSaved) await tx.update(clients).set({ phone }).where(eq(clients.id, clientId));
|
||||
await tx.insert(events).values({ companyId: client.companyId, clientId, userId: null, type: 'comment', text: `Клиент написал в бот анкеты: ${message}` });
|
||||
const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), sql`${users.removedAt} is null`));
|
||||
const note = [`💬 ${client.name} (клиент #${clientId}) написал(а) в бот анкеты:`, message, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n');
|
||||
await enqueueRaw(tx, [...new Set([client.assigneeId, ...admins.map((a) => a.id)])], note);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Обрабатывает одно обновление клиентского бота. */
|
||||
export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, update: TgUpdate): Promise<void> {
|
||||
const from = update.message?.from ?? update.callback_query?.from;
|
||||
if (!from) return;
|
||||
const chatId = update.message?.chat.id ?? update.callback_query?.message?.chat.id ?? from.id;
|
||||
if (chatId !== from.id) return;
|
||||
const lang = from.language_code === 'hy' ? 'hy' : from.language_code === 'en' ? 'en' : 'ru';
|
||||
const t = intakeTexts(lang);
|
||||
const text = update.message?.text?.trim() ?? '';
|
||||
const ask = async (s: Session, step: Step) => {
|
||||
const q = question(step, s);
|
||||
await tg.sendMessage(chatId, esc(q.text), q.keyboard);
|
||||
};
|
||||
const askLanguage = () =>
|
||||
tg.sendMessage(chatId, LANGUAGE_QUESTION, [LANGUAGE_BUTTONS.map((b) => ({ text: b.text, callback: `q:lang:${b.lang}` }))]);
|
||||
|
||||
// Переход по ссылке агента: начинаем анкету заново.
|
||||
const start = /^\/start(?:\s+(\S+))?$/.exec(text);
|
||||
if (start) {
|
||||
if (!start[1]) return void (await tg.sendMessage(chatId, t.noLink));
|
||||
const [row] = await db
|
||||
.select({ link: intakeLinks, agent: users.name, company: companies.name })
|
||||
.from(intakeLinks)
|
||||
.innerJoin(users, eq(users.id, intakeLinks.userId))
|
||||
.innerJoin(companies, eq(companies.id, intakeLinks.companyId))
|
||||
.where(and(eq(intakeLinks.token, start[1]), sql`${users.removedAt} is null`));
|
||||
if (!row) return void (await tg.sendMessage(chatId, t.badLink));
|
||||
const [session] = await db
|
||||
.insert(intakeSessions)
|
||||
.values({
|
||||
linkToken: row.link.token,
|
||||
chatId,
|
||||
tgName: [from.first_name, from.last_name].filter(Boolean).join(' ') || null,
|
||||
tgUsername: from.username ?? null,
|
||||
lang,
|
||||
// Первый вопрос — язык: по настройке Telegram его не угадать надёжно.
|
||||
step: 'lang',
|
||||
})
|
||||
.returning();
|
||||
void session;
|
||||
return void (await askLanguage());
|
||||
}
|
||||
|
||||
const [session] = await db.select().from(intakeSessions).where(eq(intakeSessions.chatId, chatId)).orderBy(desc(intakeSessions.id)).limit(1);
|
||||
if (!session) {
|
||||
if (update.callback_query) await tg.answerCallback(update.callback_query.id);
|
||||
return void (await tg.sendMessage(chatId, t.noLink));
|
||||
}
|
||||
// Выбор языка: первым шагом анкеты или позже командой /lang — тогда анкета продолжается с того же вопроса.
|
||||
if (text === '/lang') return void (await askLanguage());
|
||||
const picked = /^q:lang:(ru|hy|en)$/.exec(update.callback_query?.data ?? '')?.[1];
|
||||
if (picked) {
|
||||
await tg.answerCallback(update.callback_query!.id);
|
||||
const first = session.step === 'lang';
|
||||
const [updated] = await db
|
||||
.update(intakeSessions)
|
||||
.set({ lang: picked, ...(first ? { step: 'deal' } : {}) })
|
||||
.where(eq(intakeSessions.id, session.id))
|
||||
.returning();
|
||||
const lt = intakeTexts(picked);
|
||||
if (first) {
|
||||
const [row] = await db
|
||||
.select({ agent: users.name, company: companies.name })
|
||||
.from(intakeLinks)
|
||||
.innerJoin(users, eq(users.id, intakeLinks.userId))
|
||||
.innerJoin(companies, eq(companies.id, intakeLinks.companyId))
|
||||
.where(eq(intakeLinks.token, session.linkToken));
|
||||
await tg.sendMessage(chatId, esc(lt.hello(row?.company ?? '', row?.agent ?? '')));
|
||||
return ask(updated!, 'deal');
|
||||
}
|
||||
await tg.sendMessage(chatId, esc(lt.langSet));
|
||||
return updated!.step === 'done' ? undefined : ask(updated!, updated!.step as Step);
|
||||
}
|
||||
if (session.step === 'lang') {
|
||||
if (update.callback_query) await tg.answerCallback(update.callback_query.id);
|
||||
return void (await askLanguage());
|
||||
}
|
||||
const st = intakeTexts(session.lang);
|
||||
|
||||
// После анкеты: фото добавляются к карточке клиента.
|
||||
if (session.step === 'done') {
|
||||
if (update.callback_query) return tg.answerCallback(update.callback_query.id);
|
||||
const photo = update.message?.photo?.at(-1)?.file_id;
|
||||
const reply = update.message?.contact?.phone_number ?? text;
|
||||
if (!photo && reply && session.clientId) {
|
||||
return void (await tg.sendMessage(chatId, (await saveReply(db, session.clientId, reply)) ? st.replySaved : st.after));
|
||||
}
|
||||
if (!photo || !session.clientId) return void (await tg.sendMessage(chatId, st.after));
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, session.clientId));
|
||||
if (!client) return void (await tg.sendMessage(chatId, st.after));
|
||||
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, client.id));
|
||||
if (n >= 10) return void (await tg.sendMessage(chatId, st.photoLimit));
|
||||
const file = await download?.(photo).catch(() => null);
|
||||
if (!file || file.data.length > 5 * 1024 * 1024) return void (await tg.sendMessage(chatId, st.photoFail));
|
||||
await db.insert(clientPhotos).values({
|
||||
clientId: client.id, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'),
|
||||
});
|
||||
return void (await tg.sendMessage(chatId, st.photoSaved));
|
||||
}
|
||||
|
||||
const step = session.step as Step;
|
||||
let value = text;
|
||||
let viaButton = false;
|
||||
if (update.callback_query) {
|
||||
const m = /^q:([a-zA-Z]+):(.+)$/.exec(update.callback_query.data ?? '');
|
||||
await tg.answerCallback(update.callback_query.id);
|
||||
// Нажата кнопка старого вопроса — молча игнорируем.
|
||||
if (!m || m[1] !== step) return;
|
||||
value = m[2]!;
|
||||
viaButton = true;
|
||||
} else if (!value) {
|
||||
return ask(session, step);
|
||||
}
|
||||
|
||||
const result = accept(step, session, value, viaButton);
|
||||
if ('error' in result) return void (await tg.sendMessage(chatId, esc(result.error)));
|
||||
const answers = { ...(session.answers as Answers), ...result.patch };
|
||||
const next = nextStep(step, answers);
|
||||
if (next !== 'done') {
|
||||
const [updated] = await db.update(intakeSessions).set({ step: next, answers: answers as Record<string, unknown> }).where(eq(intakeSessions.id, session.id)).returning();
|
||||
return ask(updated!, next);
|
||||
}
|
||||
const { owner } = await complete(db, session, answers);
|
||||
await tg.sendMessage(chatId, esc(st.done(owner.name)));
|
||||
}
|
||||
|
||||
/** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */
|
||||
export async function seekerChat(db: Db, clientId: number): Promise<{ chatId: number; lang: string } | null> {
|
||||
const [s] = await db
|
||||
.select({ chatId: intakeSessions.chatId, lang: intakeSessions.lang })
|
||||
.from(intakeSessions)
|
||||
.where(eq(intakeSessions.clientId, clientId))
|
||||
.orderBy(desc(intakeSessions.id))
|
||||
.limit(1);
|
||||
return s ?? null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Lang } from '@lr/shared';
|
||||
|
||||
// Тексты клиентского бота: анкета соискателя жилья.
|
||||
|
||||
const ru = {
|
||||
hello: (company: string, agent: string) => `Здравствуйте! Это анкета агентства «${company}». Ответьте на несколько вопросов — и агент ${agent} подберёт для вас варианты.`,
|
||||
noLink: 'Этот бот работает по ссылке от вашего агента. Попросите агента прислать ссылку на анкету.',
|
||||
badLink: 'Ссылка на анкету устарела. Попросите агента прислать новую.',
|
||||
deal: 'Что вы ищете?', rent: 'Снять', sale: 'Купить',
|
||||
type: 'Какой объект нужен?', apartment: 'Квартира', house: 'Дом', commercial: 'Коммерческая', any: 'Не важно',
|
||||
place: 'Где ищете?', yerevan: 'Весь Ереван', anywhere: 'Вся Армения',
|
||||
budget: 'Какой максимальный бюджет? Напишите сумму и валюту, например: 600000 AMD или 1500 USD.', badBudget: 'Не понял сумму. Напишите число и валюту, например: 1500 USD.',
|
||||
rooms: 'Сколько комнат?', rooms4: '4 и больше',
|
||||
term: 'На какой срок нужна аренда?', termShort: 'До 6 месяцев', termMid: '6–12 месяцев', termLong: 'От года',
|
||||
who: 'Кто будет жить?', alone: 'Один', friend: 'С другом', couple: 'С партнёром (пара)',
|
||||
children: 'Будут ли жить дети?', none: 'Нет', more3: '3 и больше',
|
||||
childrenAge: 'Сколько лет детям? Например: 3 и 7 лет.',
|
||||
pets: 'Есть ли домашние животные?', more2: '2 и больше',
|
||||
petsAge: 'Кто это и сколько лет? Например: кот, 2 года.',
|
||||
name: 'Как вас зовут?', keepName: (n: string) => `Оставить «${n}»`,
|
||||
phone: 'Оставьте номер телефона, чтобы агент мог с вами связаться.', badPhone: 'Похоже, это не номер телефона. Напишите его цифрами, например: 091 123 456.',
|
||||
skip: 'Пропустить',
|
||||
done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`,
|
||||
photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.',
|
||||
replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.',
|
||||
after: 'Анкета уже у агента — он свяжется с вами. Чтобы заполнить её заново, откройте ссылку от агента ещё раз.',
|
||||
useButtons: 'Выберите вариант кнопкой под сообщением.',
|
||||
langSet: 'Язык: русский. Сменить язык — команда /lang.',
|
||||
selection: (n: number, agent: string, url: string) => `Агент ${agent} подобрал для вас варианты: ${n}. Посмотрите и запишитесь на показ:\n${url}`,
|
||||
};
|
||||
|
||||
type Texts = typeof ru;
|
||||
|
||||
const hy: Texts = {
|
||||
hello: (company, agent) => `Բարև Ձեզ։ Սա «${company}» գործակալության հարցաթերթիկն է։ Պատասխանեք մի քանի հարցի, և գործակալ ${agent}-ը կընտրի Ձեզ համար տարբերակներ։`,
|
||||
noLink: 'Այս բոտն աշխատում է Ձեր գործակալի հղումով։ Խնդրեք գործակալին ուղարկել հարցաթերթիկի հղումը։',
|
||||
badLink: 'Հարցաթերթիկի հղումը հնացել է։ Խնդրեք գործակալին ուղարկել նորը։',
|
||||
deal: 'Ի՞նչ եք փնտրում', rent: 'Վարձել', sale: 'Գնել',
|
||||
type: 'Ի՞նչ օբյեկտ է պետք', apartment: 'Բնակարան', house: 'Տուն', commercial: 'Կոմերցիոն', any: 'Կարևոր չէ',
|
||||
place: 'Որտե՞ղ եք փնտրում', yerevan: 'Ամբողջ Երևանը', anywhere: 'Ամբողջ Հայաստանը',
|
||||
budget: 'Ո՞րն է առավելագույն բյուջեն։ Գրեք գումարը և արժույթը, օրինակ՝ 600000 AMD կամ 1500 USD։', badBudget: 'Գումարը չհասկացա։ Գրեք թիվը և արժույթը, օրինակ՝ 1500 USD։',
|
||||
rooms: 'Քանի՞ սենյակ', rooms4: '4 և ավելի',
|
||||
term: 'Ի՞նչ ժամկետով է պետք վարձակալությունը', termShort: 'Մինչև 6 ամիս', termMid: '6–12 ամիս', termLong: 'Մեկ տարուց ավելի',
|
||||
who: 'Ո՞վ է ապրելու', alone: 'Մենակ', friend: 'Ընկերոջ հետ', couple: 'Զուգընկերոջ հետ (զույգ)',
|
||||
children: 'Երեխաներ ապրելո՞ւ են', none: 'Ոչ', more3: '3 և ավելի',
|
||||
childrenAge: 'Քանի՞ տարեկան են երեխաները։ Օրինակ՝ 3 և 7 տարեկան։',
|
||||
pets: 'Ընտանի կենդանիներ ունե՞ք', more2: '2 և ավելի',
|
||||
petsAge: 'Ո՞վ է և քանի՞ տարեկան։ Օրինակ՝ կատու, 2 տարեկան։',
|
||||
name: 'Ինչպե՞ս է Ձեր անունը', keepName: (n) => `Թողնել «${n}»`,
|
||||
phone: 'Թողեք հեռախոսահամարը, որպեսզի գործակալը կապվի Ձեզ հետ։', badPhone: 'Կարծես սա հեռախոսահամար չէ։ Գրեք թվերով, օրինակ՝ 091 123 456։',
|
||||
skip: 'Բաց թողնել',
|
||||
done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`,
|
||||
photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։',
|
||||
replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։',
|
||||
after: 'Հարցաթերթիկն արդեն գործակալի մոտ է, նա կկապվի Ձեզ հետ։ Նորից լրացնելու համար կրկին բացեք գործակալի հղումը։',
|
||||
useButtons: 'Ընտրեք տարբերակը հաղորդագրության տակի կոճակով։',
|
||||
langSet: 'Լեզուն՝ հայերեն։ Լեզուն փոխելու համար՝ /lang հրամանը։',
|
||||
selection: (n, agent, url) => `Գործակալ ${agent}-ը Ձեզ համար ընտրել է տարբերակներ՝ ${n}։ Դիտեք և գրանցվեք ցուցադրության.\n${url}`,
|
||||
};
|
||||
|
||||
const en: Texts = {
|
||||
hello: (company, agent) => `Hello! This is the questionnaire of “${company}”. Answer a few questions and agent ${agent} will pick options for you.`,
|
||||
noLink: 'This bot works by a link from your agent. Ask the agent to send you the questionnaire link.',
|
||||
badLink: 'The questionnaire link has expired. Ask the agent for a new one.',
|
||||
deal: 'What are you looking for?', rent: 'Rent', sale: 'Buy',
|
||||
type: 'What kind of property?', apartment: 'Apartment', house: 'House', commercial: 'Commercial', any: 'Any',
|
||||
place: 'Where are you looking?', yerevan: 'All of Yerevan', anywhere: 'All of Armenia',
|
||||
budget: 'What is your maximum budget? Write the amount and currency, e.g. 600000 AMD or 1500 USD.', badBudget: 'I did not get the amount. Write a number and currency, e.g. 1500 USD.',
|
||||
rooms: 'How many rooms?', rooms4: '4 or more',
|
||||
term: 'How long do you need the rental for?', termShort: 'Up to 6 months', termMid: '6–12 months', termLong: 'A year or more',
|
||||
who: 'Who will live there?', alone: 'Alone', friend: 'With a friend', couple: 'With a partner (couple)',
|
||||
children: 'Will children live there?', none: 'No', more3: '3 or more',
|
||||
childrenAge: 'How old are the children? E.g. 3 and 7 years.',
|
||||
pets: 'Do you have pets?', more2: '2 or more',
|
||||
petsAge: 'Who is it and how old? E.g. cat, 2 years.',
|
||||
name: 'What is your name?', keepName: (n) => `Keep “${n}”`,
|
||||
phone: 'Leave a phone number so the agent can contact you.', badPhone: 'That does not look like a phone number. Write it in digits, e.g. 091 123 456.',
|
||||
skip: 'Skip',
|
||||
done: (agent) => `Thank you, the questionnaire has been sent. Agent ${agent} will contact you.\n\nIf you like, send a photo here — of yourself or your pet — it will be added to the questionnaire.`,
|
||||
photoSaved: 'The photo was added to the questionnaire.', photoLimit: 'There are already 10 photos attached.', photoFail: 'Could not save the photo. Try another one, up to 5 MB.',
|
||||
replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.',
|
||||
after: 'The agent already has your questionnaire and will contact you. To fill it in again, open the agent’s link once more.',
|
||||
useButtons: 'Choose an option with a button under the message.',
|
||||
langSet: 'Language: English. To change it, use /lang.',
|
||||
selection: (n, agent, url) => `Agent ${agent} has picked options for you: ${n}. Have a look and book a viewing:\n${url}`,
|
||||
};
|
||||
|
||||
/** Вопрос о языке задаётся до того, как язык известен, — поэтому сразу на трёх. */
|
||||
export const LANGUAGE_QUESTION = 'Выберите язык · Ընտրեք լեզուն · Choose your language';
|
||||
export const LANGUAGE_BUTTONS: { lang: Lang; text: string }[] = [
|
||||
{ lang: 'ru', text: 'Русский' },
|
||||
{ lang: 'hy', text: 'Հայերեն' },
|
||||
{ lang: 'en', text: 'English' },
|
||||
];
|
||||
|
||||
const all: Record<Lang, Texts> = { ru, hy, en };
|
||||
export const intakeTexts = (lang: string | null | undefined): Texts => all[(lang as Lang) ?? 'ru'] ?? ru;
|
||||
@@ -0,0 +1,715 @@
|
||||
import { eq, gt, sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import { blacklist, clients, companies, deals, events, leads, listings, outbox, parserRuns, seenListings, sessions, users } from './db/schema.ts';
|
||||
import { bumpedIds, isFresh, runIngest, type PagedSource } from './ingest/service.ts';
|
||||
import { runLeadJobs } from './leads/service.ts';
|
||||
import { runGroupPosts, type GroupSender } from './group/service.ts';
|
||||
import { testImage } from './testing.ts';
|
||||
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
|
||||
import type { SourcedListing } from './parser/source.ts';
|
||||
import { handleUpdate } from './telegram/bot.ts';
|
||||
import { TelegramError } from './telegram/client.ts';
|
||||
import { inQuietHours, processOutbox } from './telegram/outbox.ts';
|
||||
import { fakeTelegram, testApp } from './testing.ts';
|
||||
|
||||
// Сценарии второго этапа: парсер → фильтры → лиды, уведомления в Telegram, «Беру», пул.
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
let tgId = 1000;
|
||||
let extId = 1_000_000;
|
||||
|
||||
/** Управляемый источник: что лежит в `pages`, то и «опубликовано» на list.am. */
|
||||
const pages = new Map<number, SourcedListing[]>();
|
||||
let sourceDown = false;
|
||||
let detailsDown = false;
|
||||
/** Объявления, которые «есть на list.am», но ещё не попали в общую базу: их агент добавляет ссылкой. */
|
||||
const items = new Map<string, SourcedListing>();
|
||||
const source: PagedSource = {
|
||||
name: 'fake',
|
||||
item: async (externalId) => items.get(externalId) ?? null,
|
||||
listPage: async (category, page = 1) => {
|
||||
if (sourceDown) throw new Error('Ридер ответил 503');
|
||||
return page === 1 ? (pages.get(category.id) ?? []) : [];
|
||||
},
|
||||
inspect: async (card) => {
|
||||
if (detailsDown) throw new Error('Ридер ответил 429');
|
||||
return { ...card, description: 'Светлая квартира', publishedAt: card.publishedAt ?? new Date(Date.now() - 40_000) };
|
||||
},
|
||||
};
|
||||
const SALE = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
|
||||
const listing = (over: Partial<SourcedListing> = {}): SourcedListing => {
|
||||
const id = String(++extId);
|
||||
return {
|
||||
externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '3-комн. квартира', dealType: 'sale',
|
||||
propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', price: 150_000, currency: 'USD', rooms: 3,
|
||||
area: 80, floor: 4, floorsTotal: 9, isAgency: false, photoUrls: ['https://img.list.am/f/1.webp'], ...over,
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => { t = await testApp('leads', source); });
|
||||
afterAll(() => t.close());
|
||||
|
||||
const tg = fakeTelegram();
|
||||
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
|
||||
const publish = async (...items: SourcedListing[]) => {
|
||||
pages.set(SALE.id, [...items, ...(pages.get(SALE.id) ?? [])]);
|
||||
const report = await runIngest({ db: t.db, source });
|
||||
await flush();
|
||||
return report!;
|
||||
};
|
||||
|
||||
interface Member { api: ReturnType<typeof t.client>; id: number; chat: number; name: string }
|
||||
|
||||
const superadmin = async () => {
|
||||
const [u] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x'), telegramId: ++tgId })
|
||||
.returning();
|
||||
return { api: t.client(await createSession(t.db, u!.id)), chat: u!.telegramId! };
|
||||
};
|
||||
|
||||
/** Компания с руководителем и агентами; у всех привязан Telegram. */
|
||||
const company = async (agents = 2, plan: 'solo' | 'team' = 'team', keepBase = false) => {
|
||||
// Объявления из прошлых сценариев «состариваем»: иначе новый фильтр получил бы их из базы.
|
||||
if (!keepBase) await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null });
|
||||
const sa = await superadmin();
|
||||
const created = await sa.api.post('/api/admin/companies', {
|
||||
name: 'Агентство', plan, billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
const join = async (name: string, token: string): Promise<Member> => {
|
||||
const api = await t.register(name);
|
||||
await api.post(`/api/invites/${token}/accept`);
|
||||
const id = (await api.get('/api/me')).body.user.id as number;
|
||||
const chat = ++tgId;
|
||||
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
|
||||
return { api, id, chat, name };
|
||||
};
|
||||
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
|
||||
const invite = agents ? ((await boss.api.post('/api/invites')).body.token as string) : '';
|
||||
const team: Member[] = [];
|
||||
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite));
|
||||
await flush();
|
||||
return { sa, boss, agents: team, id: created.body.id as number };
|
||||
};
|
||||
|
||||
const commonFilter = (m: Member, params: object = {}) =>
|
||||
m.api.post('/api/filters', { name: 'Кентрон', scope: 'company', params: { districts: ['Кентрон'], ...params } });
|
||||
|
||||
/** Первый проход по категории только запоминает объявления — делаем его заранее. */
|
||||
const baseline = () => runIngest({ db: t.db, source });
|
||||
|
||||
describe('парсер → фильтры → лиды', () => {
|
||||
it('первый проход наполняет базу без лидов; новое объявление становится лидом в пуле', async () => {
|
||||
const c = await company(2);
|
||||
await commonFilter(c.boss, { dealType: 'sale', propertyTypes: ['apartment'] });
|
||||
const old = listing({ publishedAt: new Date(Date.now() - 10 * 86_400_000) });
|
||||
pages.set(SALE.id, [listing(), listing(), old]);
|
||||
const first = await runIngest({ db: t.db, source });
|
||||
await flush();
|
||||
// Читаются все 10 категорий; в базу попали два свежих объявления, старое — нет.
|
||||
expect(first).toMatchObject({ status: 'ok', categories: 10, foundNew: 2, leads: 0 });
|
||||
expect(await t.db.select().from(listings).where(gt(listings.publishedAt, new Date(Date.now() - 86_400_000)))).toHaveLength(2);
|
||||
expect((await t.db.select().from(seenListings)).map((r) => r.externalId)).toEqual([old.externalId]);
|
||||
expect((await c.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
|
||||
|
||||
const before = tg.messages.length;
|
||||
const report = await publish(listing({ title: 'Новая квартира на Абовяна', address: 'ул. Абовяна 10' }));
|
||||
expect(report).toMatchObject({ foundNew: 1, leads: 1 });
|
||||
const pool = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body;
|
||||
expect(pool).toHaveLength(1);
|
||||
expect(pool[0]).toMatchObject({ title: 'Новая квартира на Абовяна', assigneeId: null, source: 'listam', description: 'Светлая квартира' });
|
||||
|
||||
// Общий фильтр: уведомление всем участникам, с фото, ценой, районом и кнопкой «Беру».
|
||||
const sent = tg.messages.slice(before);
|
||||
expect(sent.map((m) => m.chatId).sort()).toEqual([c.boss.chat, ...c.agents.map((a) => a.chat)].sort());
|
||||
const m = sent[0]!;
|
||||
expect(m.photo).toBe('https://img.list.am/f/1.webp');
|
||||
expect(m.text).toContain('$150 000');
|
||||
expect(m.text).toContain('Кентрон, ул. Абовяна 10');
|
||||
expect(m.text).toContain('3 комн. · 80 м² · этаж 4/9');
|
||||
expect(m.text).toContain('https://www.list.am/item/');
|
||||
expect(tg.hasButton(m, 'take:')).toBe(true);
|
||||
|
||||
// Повторный проход того же объявления лид не дублирует.
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0, leads: 0 });
|
||||
});
|
||||
|
||||
it('объявление попадает в общую базу, даже если под него нет ни одного фильтра', async () => {
|
||||
await baseline();
|
||||
const before = (await t.db.select().from(listings)).length;
|
||||
const report = await publish(listing({ district: 'Нубарашен', price: 9_999_999 }));
|
||||
expect(report.foundNew).toBe(1);
|
||||
expect((await t.db.select().from(listings)).length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('новый фильтр сразу получает подходящие объявления из базы — в пул, без уведомлений', async () => {
|
||||
await baseline();
|
||||
await publish(listing({ district: 'Шенгавит', price: 70_000 }), listing({ district: 'Шенгавит', price: 900_000 }));
|
||||
const c = await company(1, 'team', true);
|
||||
const before = tg.messages.length;
|
||||
const made = await c.boss.api.post('/api/filters', { name: 'Шенгавит', scope: 'company', params: { districts: ['Шенгавит'], priceTo: 100_000 } });
|
||||
expect(made.body.fromBase).toBe(1);
|
||||
await flush();
|
||||
expect(tg.messages.length).toBe(before);
|
||||
const pool = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body;
|
||||
expect(pool).toMatchObject([{ district: 'Шенгавит', price: 70_000, assigneeId: null }]);
|
||||
|
||||
// Фильтр расширили — догружается остальное; уже созданный лид не дублируется.
|
||||
const wider = await c.boss.api.patch(`/api/filters/${made.body.id}`, { params: { districts: ['Шенгавит'] } });
|
||||
expect(wider.body.fromBase).toBe(1);
|
||||
expect((await c.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(2);
|
||||
// Выключенный фильтр ничего не догружает — и правка названия его не включает обратно.
|
||||
expect((await c.boss.api.patch(`/api/filters/${made.body.id}`, { enabled: false })).body.fromBase).toBe(0);
|
||||
await c.boss.api.patch(`/api/filters/${made.body.id}`, { name: 'Шенгавит, все' });
|
||||
expect((await c.boss.api.get('/api/filters')).body).toMatchObject([{ name: 'Шенгавит, все', enabled: false }]);
|
||||
});
|
||||
|
||||
it('свежесть: в базу идут объявления, размещённые не раньше чем 3 дня назад', () => {
|
||||
const now = Date.UTC(2026, 9, 8, 12); // 8 октября, 16:00 по Еревану
|
||||
const day = (d: number) => new Date(Date.UTC(2026, 9, d) - 4 * 3_600_000); // «Размещено d.10.2026»
|
||||
expect(isFresh(day(8), now)).toBe(true);
|
||||
expect(isFresh(day(5), now)).toBe(true);
|
||||
expect(isFresh(day(4), now)).toBe(false);
|
||||
});
|
||||
|
||||
it('порядок списка: поднятое объявление встаёт наверх, по нему нет лишних лидов', () => {
|
||||
expect(bumpedIds(['5', '1', '2', '3', '4'], ['1', '2', '3', '4', '5'])).toEqual(new Set(['5']));
|
||||
// Сдвиг вниз — не поднятие. Незнакомое сверху тоже помечается: новое оно или поднятое, решает база.
|
||||
expect(bumpedIds(['9', '1', '2', '3'], ['1', '2', '3', '4'])).toEqual(new Set(['9']));
|
||||
expect(bumpedIds(['1', '2', '3'], ['0', '1', '2', '3'])).toEqual(new Set());
|
||||
// Объявление, которого на прошлом проходе в начале списка не было, оказалось выше прежних.
|
||||
expect(bumpedIds(['7', '1', '2'], ['1', '2'])).toEqual(new Set(['7']));
|
||||
// Без прошлого порядка поднятие не определить.
|
||||
expect(bumpedIds(['2', '1'], [])).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('поднятое объявление: старое идёт в базу лидом, знакомое встаёт наверх; «Топ» — по дате', async () => {
|
||||
const c = await company(0);
|
||||
await commonFilter(c.boss);
|
||||
await baseline();
|
||||
const list = async () => (await c.boss.api.get('/api/leads?tab=all')).body as { id: number; url: string; bumpedAt: string | null }[];
|
||||
|
||||
// Объявление недельной давности, которое автор поднял: в базе его не было — теперь это лид.
|
||||
const old = listing({ publishedAt: new Date(Date.now() - 7 * 86_400_000) });
|
||||
// Лиды получают и компании из других сценариев, поэтому смотрим свой список.
|
||||
expect(await publish(old)).toMatchObject({ foundNew: 1 });
|
||||
expect((await list())[0]).toMatchObject({ url: old.sourceUrl, bumpedAt: expect.any(String) });
|
||||
// Даже с ID далеко ниже последних.
|
||||
expect(await publish(listing({ externalId: '10', sourceUrl: 'https://www.list.am/item/10' }))).toMatchObject({ foundNew: 1 });
|
||||
|
||||
// Объявление из базы ушло вниз, затем его подняли: лид встаёт наверх, второго лида нет.
|
||||
const known = listing();
|
||||
await publish(known);
|
||||
await publish(listing());
|
||||
expect((await list())[0]!.url).not.toBe(known.sourceUrl);
|
||||
pages.set(SALE.id, [known, ...pages.get(SALE.id)!.filter((x) => x.externalId !== known.externalId)]);
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0, bumped: 1, leads: 0 });
|
||||
expect((await list())[0]).toMatchObject({ url: known.sourceUrl, bumpedAt: expect.any(String) });
|
||||
// Следующий проход то же поднятие повторно не считает.
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ bumped: 0 });
|
||||
|
||||
// Блок «Топ» ротируется: старое объявление оттуда в базу не идёт.
|
||||
const top = listing({ promoted: true, publishedAt: new Date(Date.now() - 7 * 86_400_000) });
|
||||
pages.set(SALE.id, [...pages.get(SALE.id)!, top]);
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0 });
|
||||
expect((await t.db.select().from(seenListings)).map((r) => r.externalId)).toContain(top.externalId);
|
||||
// А поднятое в обычном списке ранее пропущенное объявление — идёт.
|
||||
pages.set(SALE.id, [{ ...top, promoted: false }, ...pages.get(SALE.id)!.filter((x) => x.externalId !== top.externalId)]);
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 1 });
|
||||
});
|
||||
|
||||
it('страницу объявления не удалось прочитать — оно повторяется на следующем проходе', async () => {
|
||||
const c = await company(0);
|
||||
await commonFilter(c.boss);
|
||||
await baseline();
|
||||
const count = async () => (await c.boss.api.get('/api/leads?tab=all')).body.length as number;
|
||||
detailsDown = true;
|
||||
const failed = await publish(listing());
|
||||
expect(failed).toMatchObject({ foundNew: 0 });
|
||||
expect(failed.errors[0]).toContain('429');
|
||||
detailsDown = false;
|
||||
// Лиды получают и компании из других сценариев, поэтому считаем только свою.
|
||||
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 1 });
|
||||
expect(await count()).toBe(1);
|
||||
});
|
||||
|
||||
it('личный фильтр: уведомление получает только его владелец; пауза и тихие часы', async () => {
|
||||
const c = await company(3);
|
||||
const [a1, a2, a3] = c.agents as [Member, Member, Member];
|
||||
await baseline();
|
||||
await a1.api.post('/api/filters', { name: 'Мой', scope: 'personal', params: { districts: ['Арабкир'] } });
|
||||
await a2.api.post('/api/filters', { name: 'Мой', scope: 'personal', params: { districts: ['Арабкир'] } });
|
||||
await a2.api.patch('/api/me', { notificationsPaused: true });
|
||||
expect((await a3.api.post('/api/filters', { name: 'Общий', scope: 'company', params: {} })).status).toBe(403);
|
||||
|
||||
const before = tg.messages.length;
|
||||
await publish(listing({ district: 'Арабкир' }));
|
||||
expect(tg.messages.slice(before).map((m) => m.chatId)).toEqual([a1.chat]);
|
||||
// Лид в пуле виден всем, даже тем, кто не получил уведомление.
|
||||
expect((await a3.api.get('/api/leads?tab=pool')).body).toHaveLength(1);
|
||||
|
||||
const at = (h: number) => new Date(Date.UTC(2026, 9, 8, h - 4, 30)); // h — час по Еревану
|
||||
expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(23))).toBe(true);
|
||||
expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(7))).toBe(true);
|
||||
expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(12))).toBe(false);
|
||||
expect(inQuietHours({ quietFrom: '13:00', quietTo: '14:00' }, at(13))).toBe(true);
|
||||
expect(inQuietHours({ quietFrom: null, quietTo: null }, at(13))).toBe(false);
|
||||
});
|
||||
|
||||
it('«только собственники»: метка агентства, порог N и чёрный список не создают лид', async () => {
|
||||
const c = await company(0);
|
||||
await commonFilter(c.boss);
|
||||
await baseline();
|
||||
const count = async () => (await c.boss.api.get('/api/leads?tab=all')).body.length as number;
|
||||
|
||||
await publish(listing({ isAgency: true }));
|
||||
expect(await count()).toBe(0);
|
||||
|
||||
// Порог N = 3: четвёртое активное объявление с одного номера — уже агент.
|
||||
const phone = '+37491000111';
|
||||
await publish(listing({ phone }), listing({ phone }), listing({ phone }));
|
||||
expect(await count()).toBe(3);
|
||||
await publish(listing({ phone }));
|
||||
expect(await count()).toBe(3);
|
||||
|
||||
await t.db.insert(blacklist).values({ companyId: c.id, phone: '+37477000222' });
|
||||
await publish(listing({ phone: '077 000 222' }));
|
||||
expect(await count()).toBe(3);
|
||||
|
||||
// «Все объявления» — агентские тоже приходят.
|
||||
// Такой фильтр сразу забирает из базы три отсеянных выше объявления.
|
||||
const all = await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { districts: ['Кентрон'], ownersOnly: false } });
|
||||
expect(all.body.fromBase).toBe(3);
|
||||
await publish(listing({ isAgency: true }));
|
||||
expect(await count()).toBe(7);
|
||||
});
|
||||
|
||||
it('одно объявление порождает лиды в разных компаниях; компания без доступа лидов не получает', async () => {
|
||||
const a = await company(0);
|
||||
const b = await company(0);
|
||||
const off = await company(0);
|
||||
for (const c of [a, b, off]) await commonFilter(c.boss);
|
||||
await t.db.update(companies).set({ subscriptionEndsAt: new Date(Date.now() - 16 * 86_400_000) }).where(eq(companies.id, off.id));
|
||||
await baseline();
|
||||
await publish(listing());
|
||||
expect((await a.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(1);
|
||||
expect((await b.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(1);
|
||||
expect((await off.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
|
||||
// Чужой лид недоступен по ID.
|
||||
const idA = (await a.boss.api.get('/api/leads?tab=pool')).body[0].id;
|
||||
expect((await b.boss.api.get(`/api/leads/${idA}`)).status).toBe(404);
|
||||
expect((await b.boss.api.post(`/api/leads/${idA}/take`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('сбой источника: запуск помечен ошибкой, суперадмин получает алерт в Telegram', async () => {
|
||||
const c = await company(0);
|
||||
await commonFilter(c.boss);
|
||||
sourceDown = true;
|
||||
const report = await runIngest({ db: t.db, source });
|
||||
sourceDown = false;
|
||||
await flush();
|
||||
expect(report).toMatchObject({ status: 'failed' });
|
||||
expect(tg.inbox(c.sa.chat).at(-1)!.text).toContain('Ридер ответил 503');
|
||||
const [run] = await t.db.select().from(parserRuns).where(eq(parserRuns.status, 'failed'));
|
||||
expect(run!.errors).toContain('503');
|
||||
expect((await c.sa.api.get('/api/admin/parser')).body).toMatchObject({ enabled: true, source: 'fake' });
|
||||
});
|
||||
});
|
||||
|
||||
/** У каждого лида свой номер: иначе сработал бы порог «больше 3 объявлений — агент». */
|
||||
let phoneSeq = 100000;
|
||||
let lastPhone = '';
|
||||
const OWNER_PHONE = () => (lastPhone = `+37493${++phoneSeq}`);
|
||||
|
||||
describe('«Беру» и пул', () => {
|
||||
const withLead = async (agents = 2) => {
|
||||
const c = await company(agents);
|
||||
await commonFilter(c.boss);
|
||||
await baseline();
|
||||
await publish(listing({ phone: OWNER_PHONE() }));
|
||||
const leadId = (await c.boss.api.get('/api/leads?tab=pool')).body[0].id as number;
|
||||
return { ...c, leadId };
|
||||
};
|
||||
|
||||
it('при одновременном нажатии лид получает один агент, второй видит «Уже взят»', async () => {
|
||||
const c = await withLead(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const results = await Promise.all([a1.api.post(`/api/leads/${c.leadId}/take`), a2.api.post(`/api/leads/${c.leadId}/take`)]);
|
||||
expect(results.map((r) => r.status).sort()).toEqual([200, 409]);
|
||||
expect(results.find((r) => r.status === 409)!.body.code).toBe('ALREADY_TAKEN');
|
||||
const winner = results[0]!.status === 200 ? a1 : a2;
|
||||
const loser = winner === a1 ? a2 : a1;
|
||||
await flush();
|
||||
|
||||
// У победителя — «Вы взяли», у остальных — «Взял(а) Имя», кнопка «Беру» исчезла у всех.
|
||||
const msg = (m: Member) => tg.inbox(m.chat).at(-1)!;
|
||||
expect(msg(winner).text).toContain('Вы взяли');
|
||||
expect(msg(loser).text).toContain(`Взял(а) ${winner.name}`);
|
||||
expect(msg(c.boss).text).toContain(`Взял(а) ${winner.name}`);
|
||||
for (const m of [winner, loser, c.boss]) expect(tg.hasButton(msg(m), 'take:')).toBe(false);
|
||||
|
||||
const card = (await winner.api.get(`/api/leads/${c.leadId}`)).body;
|
||||
expect(card).toMatchObject({ assigneeId: winner.id, assigneeName: winner.name, phone: lastPhone });
|
||||
expect(card.events.map((e: { type: string }) => e.type)).toEqual(['taken', 'created']);
|
||||
expect((await winner.api.get('/api/leads?tab=mine')).body).toHaveLength(1);
|
||||
expect((await loser.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('кнопка «Беру» в боте; чужой Telegram и взятый лид получают отказ', async () => {
|
||||
const c = await withLead(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const press = (chat: number, id: string) =>
|
||||
handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: 1, callback_query: { id, from: { id: chat }, data: `take:${c.leadId}` } });
|
||||
await press(999_999, 'cb-stranger');
|
||||
await press(a1.chat, 'cb-1');
|
||||
await press(a2.chat, 'cb-2');
|
||||
const answer = (id: string) => tg.answers.find((a) => a.id === id)!.text;
|
||||
expect(answer('cb-stranger')).toContain('привяжите Telegram');
|
||||
expect(answer('cb-1')).toBe('Объект ваш');
|
||||
expect(answer('cb-2')).toBe('Уже взят');
|
||||
expect((await a1.api.get(`/api/leads/${c.leadId}`)).body.assigneeId).toBe(a1.id);
|
||||
});
|
||||
|
||||
it('видимость: агент не видит чужие лиды, пока руководитель не включит «видят все»', async () => {
|
||||
const c = await withLead(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
await a1.api.post(`/api/leads/${c.leadId}/take`);
|
||||
expect((await a2.api.get(`/api/leads/${c.leadId}`)).status).toBe(404);
|
||||
expect((await a2.api.get('/api/leads?tab=all')).status).toBe(403);
|
||||
expect((await c.boss.api.get('/api/leads?tab=all')).body).toHaveLength(1);
|
||||
|
||||
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
|
||||
const seen = (await a2.api.get(`/api/leads/${c.leadId}`)).body;
|
||||
expect(seen).toMatchObject({ assigneeId: a1.id, phone: null, phoneHidden: true });
|
||||
expect((await a2.api.get('/api/leads?tab=all')).body).toHaveLength(1);
|
||||
// Видеть — не значит вести: статус чужого лида агент не меняет.
|
||||
expect((await a2.api.post(`/api/leads/${c.leadId}/comments`, { text: 'мой' })).body.code).toBe('NOT_YOUR_LEAD');
|
||||
});
|
||||
|
||||
it('через 24 часа без смены статуса лид возвращается в пул: предупреждение, потеря, повторное «Беру»', async () => {
|
||||
const c = await withLead(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
await a1.api.post(`/api/leads/${c.leadId}/take`);
|
||||
await flush();
|
||||
const hoursAgo = (h: number) => t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - h * 3_600_000) }).where(eq(leads.id, c.leadId));
|
||||
|
||||
await hoursAgo(21);
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0, returned: 0 });
|
||||
|
||||
await hoursAgo(22.01);
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 1, returned: 0 });
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0 }); // предупреждение одно
|
||||
await flush();
|
||||
expect(tg.inbox(a1.chat).at(-1)!.text).toContain('вернётся в пул через 2 ч');
|
||||
|
||||
await hoursAgo(24.1);
|
||||
const before = tg.messages.length;
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ returned: 1 });
|
||||
await flush();
|
||||
const after = tg.messages.slice(before);
|
||||
expect(after.find((m) => m.chatId === a1.chat)!.text).toContain('вернулся в пул');
|
||||
const again = after.filter((m) => tg.hasButton(m, 'take:'));
|
||||
expect(again.map((m) => m.chatId).sort()).toEqual([c.boss.chat, a2.chat].sort());
|
||||
expect(again[0]!.text).toContain('снова в пуле');
|
||||
|
||||
const card = (await a2.api.get(`/api/leads/${c.leadId}`)).body;
|
||||
expect(card.assigneeId).toBeNull();
|
||||
expect(card.events.map((e: { type: string }) => e.type)).toEqual(['pool_return', 'taken', 'created']);
|
||||
expect(card.events[0]).toMatchObject({ userName: null, oldValue: a1.name });
|
||||
expect((await a2.api.post(`/api/leads/${c.leadId}/take`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('смена статуса сбрасывает таймер; после «Договор» лид в пул не возвращается', async () => {
|
||||
const c = await withLead(1);
|
||||
const a1 = c.agents[0]!;
|
||||
await a1.api.post(`/api/leads/${c.leadId}/take`);
|
||||
const statuses = (await a1.api.get('/api/pipelines/object')).body.statuses as { id: number; name: string; code: string | null; poolReturn: boolean }[];
|
||||
expect(statuses.filter((s) => s.poolReturn).map((s) => s.name)).toEqual(['Взят в работу', 'Дозвонился', 'Встреча с собственником']);
|
||||
const contract = statuses.find((s) => s.name.startsWith('Договор'))!;
|
||||
await a1.api.post(`/api/leads/${c.leadId}/status`, { statusId: contract.id });
|
||||
await t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - 100 * 3_600_000) }).where(eq(leads.id, c.leadId));
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0, returned: 0 });
|
||||
expect((await a1.api.get(`/api/leads/${c.leadId}`)).body.assigneeId).toBe(a1.id);
|
||||
});
|
||||
|
||||
it('руководитель переназначает лид: оба агента получают уведомление', async () => {
|
||||
const c = await withLead(2);
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
expect((await a1.api.post(`/api/leads/${c.leadId}/assign`, { userId: a1.id })).status).toBe(403);
|
||||
await c.boss.api.post(`/api/leads/${c.leadId}/assign`, { userId: a1.id });
|
||||
await flush();
|
||||
expect(tg.inbox(a1.chat).at(-1)!.text).toContain('Вам назначен объект');
|
||||
expect(tg.inbox(a2.chat).at(-1)!.text).toContain(`Взял(а) ${a1.name}`);
|
||||
|
||||
await c.boss.api.post(`/api/leads/${c.leadId}/assign`, { userId: a2.id });
|
||||
await flush();
|
||||
expect(tg.inbox(a2.chat).at(-1)!.text).toContain('Вам назначен объект');
|
||||
expect(tg.inbox(a1.chat).at(-1)!.text).toContain(`передан агенту ${a2.name}`);
|
||||
const card = (await c.boss.api.get(`/api/leads/${c.leadId}`)).body;
|
||||
expect(card.events[0]).toMatchObject({ type: 'assigned', oldValue: a1.name, newValue: a2.name });
|
||||
});
|
||||
|
||||
it('отказ требует причину, сделка — комиссию; «Это агент» заносит номер в чёрный список', async () => {
|
||||
const c = await withLead(1);
|
||||
const a1 = c.agents[0]!;
|
||||
const { statuses, reasons } = (await a1.api.get('/api/pipelines/object')).body as {
|
||||
statuses: { id: number; code: string | null }[]; reasons: { id: number; code: string | null }[];
|
||||
};
|
||||
const st = (code: string) => statuses.find((s) => s.code === code)!.id;
|
||||
const move = (body: object) => a1.api.post(`/api/leads/${c.leadId}/status`, body);
|
||||
|
||||
expect((await move({ statusId: st('taken') })).body.code).toBe('TAKE_FIRST');
|
||||
await a1.api.post(`/api/leads/${c.leadId}/take`);
|
||||
expect((await move({ statusId: st('new') })).body.code).toBe('USE_RETURN_TO_POOL');
|
||||
expect((await move({ statusId: st('rejected') })).body.code).toBe('REJECT_REASON_REQUIRED');
|
||||
expect((await move({ statusId: st('deal') })).body.code).toBe('COMMISSION_REQUIRED');
|
||||
expect((await move({ statusId: st('deal'), deal: { commission: 2500, currency: 'USD', date: '2026-10-08' } })).status).toBe(200);
|
||||
const card = (await a1.api.get(`/api/leads/${c.leadId}`)).body;
|
||||
expect(card.events[0]).toMatchObject({ type: 'deal', newValue: 'Сделка', text: '2500 USD' });
|
||||
|
||||
// Отдельный лид: «Это агент».
|
||||
await publish(listing({ phone: '+37455999888' }));
|
||||
const second = (await a1.api.get('/api/leads?tab=pool')).body[0].id as number;
|
||||
expect((await a1.api.post(`/api/leads/${second}/agent`)).status).toBe(200);
|
||||
const rejected = (await a1.api.get(`/api/leads/${second}`)).body;
|
||||
expect(rejected.statusId).toBe(st('rejected'));
|
||||
expect(rejected.rejectReasonId).toBe(reasons.find((r) => r.code === 'agent')!.id);
|
||||
expect(await t.db.select().from(blacklist).where(eq(blacklist.companyId, c.id))).toMatchObject([{ phone: '+37455999888' }]);
|
||||
// Следующее объявление с этого номера лид уже не создаёт.
|
||||
await publish(listing({ phone: '+37455999888' }));
|
||||
expect((await a1.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('лид вручную, комментарий, напоминание, возврат в пул; при ограниченном доступе — только чтение', async () => {
|
||||
const c = await company(1);
|
||||
const a1 = c.agents[0]!;
|
||||
const made = await a1.api.post('/api/leads', { title: 'Дом в Абовяне', phone: '091 22 33 44', price: 90000, region: 'Котайк' });
|
||||
expect(made.status).toBe(200);
|
||||
const id = made.body.id as number;
|
||||
expect((await a1.api.get(`/api/leads/${id}`)).body).toMatchObject({ source: 'manual', assigneeId: a1.id, phone: '+37491223344' });
|
||||
|
||||
await a1.api.post(`/api/leads/${id}/comments`, { text: 'Перезвонить вечером' });
|
||||
await a1.api.post(`/api/leads/${id}/next-action`, { at: new Date(Date.now() - 1000).toISOString(), note: 'Звонок' });
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ reminded: 1 });
|
||||
expect(await runLeadJobs(t.db)).toMatchObject({ reminded: 0 });
|
||||
await flush();
|
||||
expect(tg.inbox(a1.chat).at(-1)!.text).toContain('Напоминание по объекту «Дом в Абовяне»: Звонок');
|
||||
expect((await a1.api.get('/api/leads?tab=mine&q=223344')).body).toHaveLength(1);
|
||||
expect((await a1.api.get('/api/leads?tab=mine&q=Абовян')).body).toHaveLength(1);
|
||||
expect((await a1.api.get('/api/leads?tab=mine&q=Гюмри')).body).toHaveLength(0);
|
||||
|
||||
await a1.api.post(`/api/leads/${id}/release`);
|
||||
expect((await a1.api.get('/api/leads-summary')).body).toMatchObject({ pool: 1, mine: 0 });
|
||||
|
||||
await t.db.update(companies).set({ subscriptionEndsAt: new Date(Date.now() - 16 * 86_400_000) }).where(eq(companies.id, c.id));
|
||||
expect((await a1.api.post(`/api/leads/${id}/take`)).body.code).toBe('SUBSCRIPTION_REQUIRED');
|
||||
expect((await a1.api.get(`/api/leads/${id}`)).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('очередь Telegram и бот', () => {
|
||||
it('при ошибке отправки сообщение повторяется; заблокированный бот не повторяется', async () => {
|
||||
const c = await company(0);
|
||||
await flush();
|
||||
const send = async () => {
|
||||
await t.db.insert(outbox).values({ kind: 'text', userId: c.boss.id, payload: { raw: 'Привет <всем>' } });
|
||||
const [job] = await t.db.select().from(outbox).where(eq(outbox.userId, c.boss.id)).orderBy(outbox.id).then((r) => r.slice(-1));
|
||||
return job!.id;
|
||||
};
|
||||
const job = (id: number) => t.db.select().from(outbox).where(eq(outbox.id, id)).then((r) => r[0]!);
|
||||
|
||||
const first = await send();
|
||||
tg.failOnce(new Error('network down'));
|
||||
await flush();
|
||||
expect(await job(first)).toMatchObject({ doneAt: null, attempts: 1, error: 'network down' });
|
||||
await t.db.update(outbox).set({ runAt: sql`now() - interval '1 second'` }).where(eq(outbox.id, first));
|
||||
await flush();
|
||||
expect((await job(first)).doneAt).not.toBeNull();
|
||||
expect(tg.inbox(c.boss.chat).at(-1)!.text).toBe('Привет <всем>');
|
||||
|
||||
const second = await send();
|
||||
tg.failOnce(new TelegramError(403, 'Forbidden: bot was blocked by the user'));
|
||||
await flush();
|
||||
expect((await job(second)).error).toContain('failed: Forbidden');
|
||||
expect((await job(second)).doneAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('привязка Telegram по ссылке из кабинета, /pause и /resume', async () => {
|
||||
const api = await t.register('Нарек');
|
||||
const url = (await api.post('/api/me/telegram-link')).body.url as string;
|
||||
expect(url).toMatch(/^https:\/\/t\.me\/test_bot\?start=/);
|
||||
const code = url.split('start=')[1]!;
|
||||
const chat = ++tgId;
|
||||
const say = (text: string, from = chat) =>
|
||||
handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: 1, message: { chat: { id: from }, from: { id: from, username: 'narek' }, text } });
|
||||
|
||||
await say('/start wrong-code');
|
||||
expect(tg.inbox(chat).at(-1)!.text).toContain('устарела');
|
||||
await say(`/start ${code}`);
|
||||
expect(tg.inbox(chat).at(-1)!.text).toContain('Готово, Нарек');
|
||||
expect((await api.get('/api/me')).body.user.telegramLinked).toBe(true);
|
||||
await say(`/start ${code}`, ++tgId); // код одноразовый
|
||||
expect(tg.inbox(tgId).at(-1)!.text).toContain('устарела');
|
||||
|
||||
await say('/pause');
|
||||
expect((await api.get('/api/me')).body.user.notificationsPaused).toBe(true);
|
||||
await say('/resume');
|
||||
expect((await api.get('/api/me')).body.user.notificationsPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('руководитель узнаёт в боте о новом агенте и о достижении лимита', async () => {
|
||||
const c = await company(0, 'team');
|
||||
const invite = (await c.boss.api.post('/api/invites')).body.token as string;
|
||||
for (let i = 0; i < 9; i++) await (await t.register(`Новичок ${i}`)).post(`/api/invites/${invite}/accept`);
|
||||
await flush();
|
||||
const inbox = tg.inbox(c.boss.chat).map((m) => m.text);
|
||||
expect(inbox.filter((x) => x.includes('присоединился к компании'))).toHaveLength(9);
|
||||
expect(inbox.at(-1)).toContain('Достигнут лимит участников');
|
||||
});
|
||||
});
|
||||
|
||||
describe('объект по ссылке на list.am, районы, формулировки и удаление', () => {
|
||||
it('свободный объект закрепляется за агентом, занятый — показывает чей, нового — скачивается', async () => {
|
||||
const c = await company(2);
|
||||
await commonFilter(c.boss, { dealType: 'sale' });
|
||||
await baseline();
|
||||
const [a1, a2] = c.agents as [Member, Member];
|
||||
const free = listing();
|
||||
await publish(free);
|
||||
const url = `https://www.list.am/ru/item/${free.externalId}?w=1`;
|
||||
const taken = await a1.api.post('/api/leads/by-url', { url });
|
||||
expect(taken.body).toMatchObject({ result: 'taken' });
|
||||
expect((await a1.api.get(`/api/leads/${taken.body.id}`)).body).toMatchObject({ assigneeId: a1.id });
|
||||
expect((await a1.api.post('/api/leads/by-url', { url })).body).toEqual({ result: 'mine', id: taken.body.id });
|
||||
// Чужой лид агенту без общей видимости не открыть — только имя.
|
||||
expect((await a2.api.post('/api/leads/by-url', { url })).body).toEqual({ result: 'busy', id: null, assigneeName: 'Агент 1' });
|
||||
|
||||
// Объявления нет в CRM и в общей базе: скачивается с list.am и сразу закрепляется за агентом.
|
||||
const fresh = listing({ title: '1-комн. квартира у метро', district: 'Арабкир' });
|
||||
items.set(fresh.externalId, fresh);
|
||||
const created = await a2.api.post('/api/leads/by-url', { url: `list.am/item/${fresh.externalId}` });
|
||||
expect(created.body).toMatchObject({ result: 'created' });
|
||||
const card = (await a2.api.get(`/api/leads/${created.body.id}`)).body;
|
||||
expect(card).toMatchObject({ title: '1-комн. квартира у метро', assigneeId: a2.id, district: 'Арабкир', source: 'listam' });
|
||||
// Тот же объект ссылкой у первого агента — занят вторым.
|
||||
expect((await a1.api.post('/api/leads/by-url', { url: fresh.sourceUrl })).body).toMatchObject({ result: 'busy', assigneeName: 'Агент 2' });
|
||||
|
||||
expect((await a1.api.post('/api/leads/by-url', { url: 'https://www.list.am/item/99999999' })).body.code).toBe('LISTAM_NOT_FOUND');
|
||||
expect((await a1.api.post('/api/leads/by-url', { url: 'https://example.com/1' })).body.code).toBe('BAD_LISTAM_URL');
|
||||
|
||||
// Фильтр по нескольким районам.
|
||||
const ids = async (districts: string) => ((await c.boss.api.get(`/api/leads?tab=all&districts=${encodeURIComponent(districts)}`)).body as { id: number }[]).map((l) => l.id);
|
||||
expect(await ids('Кентрон,Арабкир')).toEqual(expect.arrayContaining([taken.body.id, created.body.id]));
|
||||
expect(await ids('арабкир')).toEqual([created.body.id]);
|
||||
expect(await ids('Шенгавит')).toEqual([]);
|
||||
});
|
||||
|
||||
it('объявление посредника или собственника против агентов не приходит лидом', async () => {
|
||||
const c = await company(1);
|
||||
await commonFilter(c.boss, { dealType: 'sale', ownersOnly: true });
|
||||
await baseline();
|
||||
await publish(listing({ title: '2-комн. квартира, агентам не беспокоить' }), listing({ title: '2-комн. квартира' }));
|
||||
const pool = (await c.boss.api.get('/api/leads?tab=pool')).body as { title: string }[];
|
||||
expect(pool.map((l) => l.title)).toEqual(['2-комн. квартира']);
|
||||
});
|
||||
|
||||
it('карточки удаляет руководитель; карточку со сделкой — только суперадмин', async () => {
|
||||
const c = await company(1);
|
||||
const [agent] = c.agents as [Member];
|
||||
const id = (await agent.api.post('/api/leads', { title: 'Дом' })).body.id as number;
|
||||
await t.db.insert(events).values({ companyId: c.id, leadId: id, type: 'comment', text: 'x' });
|
||||
expect((await agent.api.del(`/api/leads/${id}`)).status).toBe(403);
|
||||
expect((await c.boss.api.del(`/api/leads/${id}`)).status).toBe(200);
|
||||
expect(await t.db.select().from(leads).where(eq(leads.id, id))).toHaveLength(0);
|
||||
|
||||
const client = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'tenant', telegram: 'https://t.me/gor_k', rentTerm: 'long' })).body.id as number;
|
||||
expect((await agent.api.get(`/api/clients/${client}`)).body).toMatchObject({ telegram: '@gor_k', rentTerm: 'long' });
|
||||
const withDeal = (await agent.api.post('/api/leads', { title: 'Квартира' })).body.id as number;
|
||||
await t.db.insert(deals).values({
|
||||
companyId: c.id, leadId: withDeal, clientId: client, agentId: agent.id, commission: 100, ownerCommission: 50, clientCommission: 50,
|
||||
ownerAgentId: agent.id, clientAgentId: agent.id, currency: 'USD', dealDate: '2026-10-01',
|
||||
});
|
||||
expect((await c.boss.api.del(`/api/leads/${withDeal}`)).body.code).toBe('HAS_DEAL');
|
||||
expect((await c.boss.api.del(`/api/clients/${client}`)).body.code).toBe('HAS_DEAL');
|
||||
|
||||
// Суперадмин в кабинете компании: объект удаляется, сделка остаётся за клиентом; потом и клиент.
|
||||
const [sa] = await t.db.select().from(users).where(eq(users.role, 'superadmin'));
|
||||
const token = await createSession(t.db, sa!.id);
|
||||
await t.db.update(sessions).set({ actingCompanyId: c.id });
|
||||
const admin = t.client(token);
|
||||
expect((await admin.del(`/api/leads/${withDeal}`)).status).toBe(200);
|
||||
expect(await t.db.select().from(deals).where(eq(deals.clientId, client))).toMatchObject([{ leadId: null }]);
|
||||
expect((await admin.del(`/api/clients/${client}`)).status).toBe(200);
|
||||
expect(await t.db.select().from(clients).where(eq(clients.id, client))).toHaveLength(0);
|
||||
expect(await t.db.select().from(deals).where(eq(deals.clientId, client))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('группа компании в Telegram', () => {
|
||||
it('объект в работе с комиссией уходит в группу; новая комиссия — снова; токен в кабинет не попадает', async () => {
|
||||
const c = await company(1);
|
||||
const [agent] = c.agents as [Member];
|
||||
const [co] = await t.db.select().from(companies).where(eq(companies.id, c.id));
|
||||
await t.db.update(companies).set({ settings: { ...co!.settings, groupBot: { token: 'GROUP:TOKEN', chatId: -100500, threadId: 7 } } }).where(eq(companies.id, c.id));
|
||||
expect((await c.boss.api.get('/api/me')).body.company.settings.groupBot).toBeUndefined();
|
||||
// Сохранение настроек руководителем бот группы не стирает.
|
||||
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
|
||||
|
||||
const sent: { kind: string; chatId: number; token: string; threadId?: number; text?: string; photos?: number }[] = [];
|
||||
const sender: GroupSender = {
|
||||
album: async (bot, photos) => void sent.push({ kind: 'album', ...bot, photos: photos.length }),
|
||||
text: async (bot, text) => void sent.push({ kind: 'text', ...bot, text }),
|
||||
};
|
||||
const run = () => runGroupPosts({ db: t.db, config: t.config, sender, fetchImage: async () => testImage, pause: 0 });
|
||||
|
||||
const id = (await agent.api.post('/api/leads', { title: '2-комн. квартира', district: 'Кентрон', dealType: 'rent', price: 350000, currency: 'AMD' })).body.id as number;
|
||||
// Без комиссии в карточке — не отправляется.
|
||||
expect(await run()).toBe(0);
|
||||
await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'percent', value: 50 } });
|
||||
expect(await run()).toBe(1);
|
||||
// Описание и отдельным сообщением — кто дозвонился и комиссия.
|
||||
expect(sent).toMatchObject([{ kind: 'text', token: 'GROUP:TOKEN', chatId: -100500, threadId: 7 }, { kind: 'text' }]);
|
||||
expect(sent[0]!.text).toContain('<b>2-комн. квартира</b>');
|
||||
expect(sent[0]!.text).toContain('🏡 Кентрон');
|
||||
expect(sent[0]!.text).not.toContain('Дозвонился');
|
||||
expect(sent[1]!.text).toBe('👤 Дозвонился: <b>Агент 1</b>\n💰 Комиссия: <b>50 % (≈ 175 000 ֏)</b>');
|
||||
expect(await run()).toBe(0);
|
||||
|
||||
await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'fixed', value: 300, currency: 'USD' } });
|
||||
expect(await run()).toBe(1);
|
||||
expect(sent.at(-2)!.text).toContain('Комиссия изменилась');
|
||||
expect(sent.at(-1)!.text).toContain('Комиссия: <b>$300</b>');
|
||||
|
||||
// Объект с фото из объявления: фото уходят альбомом перед текстом.
|
||||
const photoLead = (await agent.api.post('/api/leads', { title: 'Дом' })).body.id as number;
|
||||
await t.db.update(leads).set({ photos: ['https://img.list.am/f/1/1.webp', 'https://img.list.am/f/1/2.webp'] }).where(eq(leads.id, photoLead));
|
||||
await agent.api.post(`/api/leads/${photoLead}/commission`, { rule: { mode: 'percent', value: 100 } });
|
||||
await run();
|
||||
expect(sent.slice(-3).map((m) => m.kind)).toEqual(['album', 'text', 'text']);
|
||||
expect(sent.at(-3)!.photos).toBe(2);
|
||||
|
||||
// Кнопка «Отправить в Telegram»: сразу, комиссия — из настроек компании, если в карточке её нет.
|
||||
const manual = (await agent.api.post('/api/leads', { title: 'Офис', dealType: 'sale', price: 100000, currency: 'USD' })).body.id as number;
|
||||
t.groupSent.length = 0;
|
||||
expect((await c.boss.api.get('/api/me')).body.company.groupBot).toBe(true);
|
||||
expect((await agent.api.post(`/api/leads/${manual}/group`)).status).toBe(200);
|
||||
expect(t.groupSent).toMatchObject([{ kind: 'text', chatId: -100500, threadId: 7 }, { kind: 'text' }]);
|
||||
expect(t.groupSent[0]!.text).toContain('<b>Офис</b>');
|
||||
expect(t.groupSent[1]!.text).toContain('Дозвонился: <b>Агент 1</b>');
|
||||
// Без бота у компании — понятная ошибка.
|
||||
const other = await company(0);
|
||||
const foreign = (await other.boss.api.post('/api/leads', { title: 'Дом' })).body.id as number;
|
||||
expect((await other.boss.api.get('/api/me')).body.company.groupBot).toBe(false);
|
||||
expect((await other.boss.api.post(`/api/leads/${foreign}/group`)).body.code).toBe('GROUP_BOT_NOT_SET');
|
||||
|
||||
// Отказ: смена комиссии в группу уже не уходит.
|
||||
const rejected = (await c.boss.api.get('/api/pipelines/object')).body;
|
||||
const status = rejected.statuses.find((x: { code: string }) => x.code === 'rejected');
|
||||
await agent.api.post(`/api/leads/${id}/status`, { statusId: status.id, rejectReasonId: rejected.reasons[0].id });
|
||||
await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'percent', value: 30 } });
|
||||
expect(await run()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,611 @@
|
||||
import { agentPhrase, CommissionRule, CURRENCIES, DEAL_TYPES, FilterParams, PROPERTY_TYPES, type AccessState, type Role } from '@lr/shared';
|
||||
import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts';
|
||||
import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { DealBody, dealView } from '../deals/service.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clientLeads, clients, companies, deals, departments, events, filters, leadFavorites, leads, leadViews, listings, pipelineStatuses, users } from '../db/schema.ts';
|
||||
import { loadBillingSettings } from '../billing/service.ts';
|
||||
import { backfillFilter, importListing, type PagedSource } from '../ingest/service.ts';
|
||||
import { sendLeadToGroup, sendLeadToUser, type GroupSender } from '../group/service.ts';
|
||||
import { badRequest, forbidden, notFound } from '../lib/errors.ts';
|
||||
import { onlySent } from '../lib/patch.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
|
||||
import { renderShowcaseText } from '../showcase/text.ts';
|
||||
import {
|
||||
addComment,
|
||||
assignLead,
|
||||
canSee,
|
||||
changeStatus,
|
||||
claimFoundLead,
|
||||
createClaimedLead,
|
||||
sameDepartment,
|
||||
departmentScope,
|
||||
inDepartment,
|
||||
leadActual,
|
||||
priceRange,
|
||||
createManualLead,
|
||||
deleteLead,
|
||||
findListingLead,
|
||||
editLead,
|
||||
loadLead,
|
||||
markAsAgent,
|
||||
markListingRemoved,
|
||||
releaseLead,
|
||||
phoneVariants,
|
||||
setLeadPhone,
|
||||
setNextAction,
|
||||
statusByCode,
|
||||
takeLead,
|
||||
type Actor,
|
||||
type Lead,
|
||||
} from './service.ts';
|
||||
|
||||
type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
|
||||
|
||||
export interface Guards {
|
||||
member: (req: FastifyRequest) => MemberCtx;
|
||||
manager: (req: FastifyRequest) => MemberCtx;
|
||||
writable: <T extends { access: AccessState }>(ctx: T) => T;
|
||||
}
|
||||
|
||||
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||
const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined);
|
||||
const optionalNumber = z.coerce.number().nonnegative().optional();
|
||||
|
||||
const ManualLeadBody = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: optionalText(10_000),
|
||||
dealType: z.enum(DEAL_TYPES).optional(),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional(),
|
||||
region: optionalText(60),
|
||||
district: optionalText(60),
|
||||
address: optionalText(300),
|
||||
price: optionalNumber,
|
||||
currency: z.enum(CURRENCIES).default('USD'),
|
||||
rooms: z.coerce.number().int().min(0).max(50).optional(),
|
||||
area: optionalNumber,
|
||||
floor: z.coerce.number().int().min(-5).max(200).optional(),
|
||||
floorsTotal: z.coerce.number().int().min(0).max(200).optional(),
|
||||
authorName: optionalText(100),
|
||||
phone: optionalText(40),
|
||||
url: z.url().max(1000).optional().or(z.literal('').transform(() => undefined)),
|
||||
});
|
||||
|
||||
/** Пустое поле в правке стирает значение. */
|
||||
const clearable = <T extends z.ZodType>(schema: T) => z.preprocess((v) => (v === '' ? null : v), schema.nullable());
|
||||
|
||||
const LeadEditBody = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: clearable(z.string().trim().max(10_000)),
|
||||
dealType: clearable(z.enum(DEAL_TYPES)),
|
||||
propertyType: clearable(z.enum(PROPERTY_TYPES)),
|
||||
region: clearable(z.string().trim().max(60)),
|
||||
district: clearable(z.string().trim().max(60)),
|
||||
address: clearable(z.string().trim().max(300)),
|
||||
price: clearable(z.coerce.number().nonnegative()),
|
||||
currency: z.enum(CURRENCIES),
|
||||
rooms: clearable(z.coerce.number().int().min(0).max(50)),
|
||||
area: clearable(z.coerce.number().nonnegative()),
|
||||
floor: clearable(z.coerce.number().int().min(-5).max(200)),
|
||||
floorsTotal: clearable(z.coerce.number().int().min(0).max(200)),
|
||||
authorName: clearable(z.string().trim().max(100)),
|
||||
}).partial();
|
||||
|
||||
export function registerLeadRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, fetchImage?: ImageFetcher, source?: PagedSource | null, groupSender?: GroupSender) {
|
||||
const actor = (ctx: MemberCtx): Actor => ctx;
|
||||
|
||||
/** Телефон автора виден ответственному и руководителю; у лида в пуле и у чужого лида он скрыт. */
|
||||
const present = (ctx: MemberCtx, lead: Lead, assigneeName?: string | null) => ({
|
||||
...lead,
|
||||
phone: ctx.role === 'manager' || lead.assigneeId === ctx.user.id ? lead.phone : null,
|
||||
phoneHidden: Boolean(lead.phone) && ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id,
|
||||
assigneeName: assigneeName ?? null,
|
||||
});
|
||||
|
||||
// --- список лидов ---
|
||||
|
||||
app.get('/api/leads', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = z
|
||||
.object({
|
||||
tab: z.enum(['pool', 'mine', 'all']).default('pool'),
|
||||
statusId: z.coerce.number().int().optional(),
|
||||
assigneeId: z.coerce.number().int().optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
district: z.string().trim().max(60).optional(),
|
||||
/** Несколько районов через запятую. */
|
||||
districts: z.string().max(1000).optional(),
|
||||
priceFrom: z.coerce.number().nonnegative().optional(),
|
||||
priceTo: z.coerce.number().nonnegative().optional(),
|
||||
/** В какой валюте заданы границы цены: цены объектов пересчитываются по курсу. */
|
||||
priceCurrency: z.enum(['AMD', 'USD']).default('USD'),
|
||||
from: z.coerce.date().optional(),
|
||||
to: z.coerce.date().optional(),
|
||||
/** Только избранные объекты пользователя. */
|
||||
favorite: z.literal('1').optional(),
|
||||
/** Только объекты, карточку которых пользователь ещё не открывал. */
|
||||
unseen: z.literal('1').optional(),
|
||||
/** Руководителю — один отдел; none — объекты без отдела. */
|
||||
departmentId: z.union([z.literal('none'), z.coerce.number().int().positive()]).optional(),
|
||||
})
|
||||
.parse(req.query);
|
||||
const seeAll = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
|
||||
if (q.tab === 'all' && !seeAll) throw forbidden();
|
||||
|
||||
const fresh = await statusByCode(db, ctx.company.id, 'new');
|
||||
const where: (SQL | undefined)[] = [
|
||||
eq(leads.companyId, ctx.company.id),
|
||||
// Сотрудник видит только объекты своего отдела.
|
||||
departmentScope(actor(ctx), leads.departmentId),
|
||||
// Снятые с List.am и закрытые сделкой или отказом объекты в рабочие списки не попадают — они в «Базе».
|
||||
leadActual,
|
||||
];
|
||||
if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(leads.departmentId, q.departmentId === 'none' ? null : q.departmentId));
|
||||
if (q.favorite) where.push(sql`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`);
|
||||
if (q.unseen) where.push(sql`not exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`);
|
||||
if (q.tab === 'pool') where.push(isNull(leads.assigneeId), eq(leads.statusId, fresh.id));
|
||||
if (q.tab === 'mine') where.push(eq(leads.assigneeId, ctx.user.id));
|
||||
if (q.statusId) where.push(eq(leads.statusId, q.statusId));
|
||||
if (q.assigneeId && q.tab === 'all') where.push(eq(leads.assigneeId, q.assigneeId));
|
||||
if (q.district) where.push(ilike(leads.district, q.district));
|
||||
const districts = q.districts?.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean) ?? [];
|
||||
if (districts.length) where.push(inArray(sql`lower(${leads.district})`, districts));
|
||||
if (q.priceFrom !== undefined || q.priceTo !== undefined) {
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
where.push(priceRange(leads.price, leads.currency, rates, q.priceCurrency, q.priceFrom, q.priceTo));
|
||||
}
|
||||
if (q.from) where.push(gte(leads.createdAt, q.from));
|
||||
if (q.to) where.push(lte(leads.createdAt, q.to));
|
||||
if (q.q) {
|
||||
// Поиск по телефону, адресу, названию и ID объявления.
|
||||
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
|
||||
const digits = q.q.replace(/\D/g, '');
|
||||
where.push(
|
||||
or(
|
||||
ilike(leads.address, like),
|
||||
ilike(leads.title, like),
|
||||
ilike(leads.url, like),
|
||||
// По телефону агент находит только свои лиды — чужие номера скрыты.
|
||||
digits.length >= 4
|
||||
? and(ilike(leads.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(leads.assigneeId, ctx.user.id))
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
lead: leads,
|
||||
assigneeName: users.name,
|
||||
departmentName: departments.name,
|
||||
favorite: sql<boolean>`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`,
|
||||
seen: sql<boolean>`exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`,
|
||||
})
|
||||
.from(leads)
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.leftJoin(departments, eq(departments.id, leads.departmentId))
|
||||
.where(and(...where))
|
||||
// Лид, чьё объявление подняли на list.am, встаёт наверх.
|
||||
.orderBy(desc(sql`coalesce(${leads.bumpedAt}, ${leads.createdAt})`), desc(leads.id))
|
||||
.limit(300);
|
||||
return rows.map((r) => ({ ...present(ctx, r.lead, r.assigneeName), departmentName: r.departmentName, favorite: r.favorite, seen: r.seen }));
|
||||
});
|
||||
|
||||
/** Курсы валют (сколько единиц в одном долларе): фильтр цены пересчитывает границы при смене валюты. */
|
||||
app.get('/api/rates', async (req) => {
|
||||
g.member(req);
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
return { USD: 1, ...rates };
|
||||
});
|
||||
|
||||
app.get('/api/leads/:id', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
// Карточку открыли — объект больше не «непросмотренный» для этого пользователя.
|
||||
await db
|
||||
.insert(leadViews)
|
||||
.values({ userId: ctx.user.id, leadId: id })
|
||||
.onConflictDoUpdate({ target: [leadViews.userId, leadViews.leadId], set: { viewedAt: new Date() } });
|
||||
const [[favorite], [department]] = await Promise.all([
|
||||
db.select({ id: leadFavorites.leadId }).from(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id))),
|
||||
lead.departmentId ? db.select({ name: departments.name }).from(departments).where(eq(departments.id, lead.departmentId)) : [],
|
||||
]);
|
||||
const [history, assignee, previous, interested, deal, listing] = await Promise.all([
|
||||
db
|
||||
.select({ event: events, userName: users.name })
|
||||
.from(events)
|
||||
.leftJoin(users, eq(users.id, events.userId))
|
||||
.where(eq(events.leadId, id))
|
||||
.orderBy(desc(events.at), desc(events.id)),
|
||||
lead.assigneeId ? db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [],
|
||||
lead.previousLeadId
|
||||
? db
|
||||
.select({ lead: leads, assigneeName: users.name, status: pipelineStatuses.name, removedAt: listings.removedAt })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.leftJoin(listings, eq(listings.id, leads.listingId))
|
||||
.where(and(eq(leads.id, lead.previousLeadId), eq(leads.companyId, ctx.company.id)))
|
||||
: [],
|
||||
db
|
||||
.select({ client: clients, state: clientLeads.state, assigneeName: users.name })
|
||||
.from(clientLeads)
|
||||
.innerJoin(clients, eq(clients.id, clientLeads.clientId))
|
||||
.leftJoin(users, eq(users.id, clients.assigneeId))
|
||||
.where(eq(clientLeads.leadId, id))
|
||||
.orderBy(desc(clientLeads.createdAt)),
|
||||
db
|
||||
.select({ deal: deals, clientName: clients.name })
|
||||
.from(deals)
|
||||
.leftJoin(clients, eq(clients.id, deals.clientId))
|
||||
.where(eq(deals.leadId, id)),
|
||||
lead.listingId ? db.select({ active: listings.active, removedAt: listings.removedAt }).from(listings).where(eq(listings.id, lead.listingId)) : [],
|
||||
]);
|
||||
const prev = previous[0];
|
||||
const seesClients = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
|
||||
const d = deal[0];
|
||||
// В событиях назначения хранятся ID агентов — подставляем имена.
|
||||
const names = new Map(
|
||||
(await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.companyId, ctx.company.id))).map(
|
||||
(u) => [String(u.id), u.name] as const,
|
||||
),
|
||||
);
|
||||
const byId = (v: string | null) => (v ? (names.get(v) ?? v) : null);
|
||||
// Полный набор фото и текст для клиента готовятся в фоне: открытие карточки ставит объект в очередь.
|
||||
const fromListing = Boolean(lead.listingId);
|
||||
if (fromListing && !lead.galleryAt && !lead.galleryWantedAt && lead.galleryTries < 3) {
|
||||
await db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, id));
|
||||
}
|
||||
return {
|
||||
...present(ctx, lead, assignee[0]?.name),
|
||||
favorite: Boolean(favorite),
|
||||
departmentName: department?.name ?? null,
|
||||
// Фото с List.am отдаёт сам сервис — обрезанными сверху, как на странице клиента.
|
||||
photos: lead.photos.slice(0, 20).map((src, i) => (/^https:\/\/(?:img|s)\.list\.am\//.test(src) ? `/api/leads/${id}/photos/${i}` : src)),
|
||||
/** ready — все фото загружены; pending — догружаются; none — объект заведён вручную или загрузить не удалось. */
|
||||
gallery: lead.galleryAt ? 'ready' : fromListing && lead.galleryTries < 3 ? 'pending' : 'none',
|
||||
/** Комиссия агентства с собственника: условие и ожидаемая сумма от цены объекта. */
|
||||
commission: commissionView(ctx.company.settings.commission, lead.dealType === 'rent' ? 'objectRent' : 'objectSale', lead.commission, lead.price, lead.currency),
|
||||
/** Формулировка в объявлении, по которой видно посредника или собственника против агентов. */
|
||||
agentPhrase: agentPhrase(`${lead.title}\n${lead.description ?? ''}`),
|
||||
/** Текст для клиента: карточка по шаблону, без адреса. */
|
||||
clientText: lead.showcaseText ?? renderShowcaseText(lead),
|
||||
// Варианты номера из общей базы видит тот же, кто видит сам телефон: ответственный и руководитель.
|
||||
phoneVariants: lead.listingId && (ctx.role === 'manager' || lead.assigneeId === ctx.user.id) ? await phoneVariants(db, lead.listingId) : [],
|
||||
// Заинтересованные клиенты: агент видит своих (и клиентов своего отдела при открытой видимости), руководитель — всех.
|
||||
clients: interested
|
||||
.filter((r) => ctx.role === 'manager' || r.client.assigneeId === ctx.user.id || (seesClients && sameDepartment(r.client.departmentId, ctx.user.departmentId)))
|
||||
.map((r) => ({ id: r.client.id, name: r.client.name, kind: r.client.kind, statusId: r.client.statusId, state: r.state, assigneeName: r.assigneeName })),
|
||||
deal: d ? { ...dealView(d.deal, names), clientId: d.deal.clientId, clientName: d.clientName } : null,
|
||||
// Объявление сняли с List.am (проверка раз в сутки).
|
||||
removedAt: listing[0]?.active === false ? listing[0].removedAt : null,
|
||||
// «Уже был в работе»: итог прошлой карточки виден всем, ссылка на неё — тем, кому она доступна.
|
||||
previous: prev
|
||||
? {
|
||||
id: canSee(actor(ctx), prev.lead) ? prev.lead.id : null,
|
||||
title: prev.lead.title, assigneeName: prev.assigneeName, status: prev.status, removedAt: prev.removedAt,
|
||||
}
|
||||
: null,
|
||||
events: history.map(({ event: e, userName }) => ({
|
||||
id: e.id,
|
||||
type: e.type,
|
||||
at: e.at,
|
||||
userName,
|
||||
oldValue: e.type === 'assigned' || e.type === 'pool_return' ? byId(e.oldValue) : e.oldValue,
|
||||
newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue,
|
||||
text: e.text,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// --- действия с лидом ---
|
||||
|
||||
app.post('/api/leads', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await createManualLead(db, actor(ctx), ManualLeadBody.parse(req.body));
|
||||
return { id: lead.id };
|
||||
});
|
||||
|
||||
/**
|
||||
* Объект по ссылке на list.am: есть в CRM и свободен — закрепляется за агентом; закреплён за
|
||||
* другим — ответ с его именем; нет в CRM — объявление скачивается с list.am и заводится на агента.
|
||||
*/
|
||||
app.post('/api/leads/by-url', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { url } = z.object({ url: z.string().trim().max(1000) }).parse(req.body);
|
||||
const externalId = /list\.am\/(?:[a-z]{2}\/)?item\/(\d{4,15})/i.exec(url)?.[1];
|
||||
if (!externalId) throw badRequest('BAD_LISTAM_URL');
|
||||
const [known] = await db.select().from(listings).where(eq(listings.externalId, externalId));
|
||||
// Сотрудник ищет объект в своём отделе: в другом отделе у объекта своя карточка.
|
||||
const found = await findListingLead(db, ctx.company.id, externalId, known?.id ?? null, ctx.role === 'manager' ? undefined : ctx.user.departmentId);
|
||||
if (found) return claimFoundLead(db, actor(ctx), found);
|
||||
let listing = known;
|
||||
if (!listing) {
|
||||
if (!source?.item) throw badRequest('PARSER_DISABLED');
|
||||
const fetched = await importListing(db, source, externalId).catch((error: unknown) => {
|
||||
console.error('[by-url]', error);
|
||||
throw badRequest('LISTAM_UNAVAILABLE');
|
||||
});
|
||||
if (!fetched) throw notFound('LISTAM_NOT_FOUND');
|
||||
listing = fetched;
|
||||
}
|
||||
return createClaimedLead(db, actor(ctx), listing);
|
||||
});
|
||||
|
||||
/** «Отправить в Telegram»: объект сразу уходит в группу компании её ботом. */
|
||||
app.post('/api/leads/:id/group', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
await sendLeadToGroup({ db, config, sender: groupSender, fetchImage }, ctx.company, lead);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** «Отправить в бот»: объект уходит основным ботом в личный чат того, кто нажал кнопку. */
|
||||
app.post('/api/leads/:id/bot', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
await sendLeadToUser({ db, config, sender: groupSender, fetchImage }, ctx.company, lead, ctx.user);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Избранное: у каждого пользователя своё. */
|
||||
app.post('/api/leads/:id/favorite', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const { on } = z.object({ on: z.boolean() }).parse(req.body);
|
||||
await loadLead(db, actor(ctx), id);
|
||||
if (on) await db.insert(leadFavorites).values({ userId: ctx.user.id, leadId: id }).onConflictDoNothing();
|
||||
else await db.delete(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id)));
|
||||
return { ok: true, favorite: on };
|
||||
});
|
||||
|
||||
/** Удалить карточку объекта: руководитель; со сделкой — только суперадмин. */
|
||||
app.delete('/api/leads/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await deleteLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/take', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await takeLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return present(ctx, lead, ctx.user.name);
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/assign', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body);
|
||||
await assignLead(db, actor(ctx), Id.parse(req.params).id, userId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/release', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await releaseLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/status', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({
|
||||
statusId: z.number().int().positive(),
|
||||
rejectReasonId: z.number().int().positive().optional(),
|
||||
deal: DealBody.extend({ clientId: z.number().int().positive().optional() }).optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
await changeStatus(db, actor(ctx), Id.parse(req.params).id, body);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/comments', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { text } = z.object({ text: z.string().trim().min(1).max(5000) }).parse(req.body);
|
||||
await addComment(db, actor(ctx), Id.parse(req.params).id, text);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/next-action', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({ at: z.coerce.date().nullable(), note: z.string().trim().max(500).nullable().default(null) })
|
||||
.parse(req.body);
|
||||
await setNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Условие комиссии для этого объекта; null возвращает условие из настроек компании. */
|
||||
app.post('/api/leads/:id/commission', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { rule } = z.object({ rule: CommissionRule.nullable() }).parse(req.body);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
if (ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_LEAD');
|
||||
const before = ruleCode(lead.commission);
|
||||
const after = ruleCode(rule);
|
||||
if (before === after) return { ok: true };
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(leads).set({ commission: rule }).where(eq(leads.id, id));
|
||||
// В истории видно, кто и какое условие поставил; «было» — то, что действовало до правки, включая условие компании.
|
||||
const settings = ctx.company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'];
|
||||
await tx.insert(events).values({
|
||||
companyId: ctx.company.id, leadId: id, userId: ctx.user.id, type: 'commission',
|
||||
oldValue: before ?? ruleCode(settings), newValue: after,
|
||||
// Вместе с условием сохраняется, на сколько процентов изменилась ожидаемая сумма.
|
||||
text: commissionNote(after ? null : ruleCode(settings), commissionDelta(lead.commission ?? settings, rule ?? settings, lead.price, lead.currency)),
|
||||
});
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Телефон собственника: агент берёт его из объявления и вносит сам. */
|
||||
app.post('/api/leads/:id/phone', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { phone } = z.object({ phone: z.string().trim().max(40) }).parse(req.body);
|
||||
await setLeadPhone(db, actor(ctx), Id.parse(req.params).id, phone);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Фото объекта, обрезанное сверху. */
|
||||
app.get('/api/leads/:id/photos/:index', async (req, reply) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const { index } = z.object({ index: z.coerce.number().int().min(0).max(50) }).parse(req.params);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
const url = lead.photos[index];
|
||||
if (!url || !/^https:\/\/(?:img|s)\.list\.am\//.test(url)) throw notFound();
|
||||
const photo = await croppedPhoto(config, url, fetchImage).catch(() => {
|
||||
throw notFound();
|
||||
});
|
||||
return reply.header('content-type', 'image/jpeg').header('cache-control', 'private, max-age=604800').send(photo);
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/agent', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await markAsAgent(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Правка данных объекта: те же поля, что у объекта вручную, кроме телефона и ссылки. */
|
||||
app.patch('/api/leads/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = onlySent(req.body, LeadEditBody.parse(req.body));
|
||||
if (!Object.keys(body).length) throw badRequest('VALIDATION');
|
||||
await editLead(db, actor(ctx), Id.parse(req.params).id, body);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/removed', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await markListingRemoved(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- фильтры ---
|
||||
|
||||
/**
|
||||
* Агент видит фильтры своего отдела (без отдела — общие фильтры компании) и свои личные;
|
||||
* руководитель — все фильтры: компании, отделов и личные фильтры агентов.
|
||||
*/
|
||||
app.get('/api/filters', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const rows = await db
|
||||
.select({ filter: filters, ownerName: users.name, departmentName: departments.name })
|
||||
.from(filters)
|
||||
.leftJoin(users, eq(users.id, filters.ownerUserId))
|
||||
.leftJoin(departments, eq(departments.id, filters.departmentId))
|
||||
.where(
|
||||
and(
|
||||
eq(filters.companyId, ctx.company.id),
|
||||
ctx.role === 'manager'
|
||||
? undefined
|
||||
: or(and(isNull(filters.ownerUserId), inDepartment(filters.departmentId, ctx.user.departmentId)), eq(filters.ownerUserId, ctx.user.id)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(filters.id));
|
||||
return rows.map(({ filter: f, ownerName, departmentName }) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
enabled: f.enabled,
|
||||
params: f.params,
|
||||
scope: f.ownerUserId !== null ? 'personal' : f.departmentId !== null ? 'department' : 'company',
|
||||
ownerUserId: f.ownerUserId,
|
||||
ownerName,
|
||||
departmentId: f.departmentId,
|
||||
departmentName,
|
||||
// Фильтр компании и отдела меняет руководитель, личный — его владелец.
|
||||
editable: f.ownerUserId === null ? ctx.role === 'manager' : f.ownerUserId === ctx.user.id,
|
||||
}));
|
||||
});
|
||||
|
||||
const FilterBody = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
enabled: z.boolean().default(true),
|
||||
params: FilterParams,
|
||||
});
|
||||
|
||||
/** Отдел компании по ID; чужой или несуществующий — ошибка. */
|
||||
const companyDepartment = async (ctx: MemberCtx, id: number) => {
|
||||
const [d] = await db.select({ id: departments.id }).from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id)));
|
||||
if (!d) throw badRequest('BAD_DEPARTMENT');
|
||||
return d.id;
|
||||
};
|
||||
|
||||
app.post('/api/filters', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = FilterBody.extend({
|
||||
scope: z.enum(['company', 'department', 'personal']),
|
||||
departmentId: z.number().int().positive().optional(),
|
||||
}).parse(req.body);
|
||||
// Фильтры компании и отделов задаёт руководитель.
|
||||
if (body.scope !== 'personal' && ctx.role !== 'manager') throw forbidden();
|
||||
if (body.scope === 'department' && !body.departmentId) throw badRequest('BAD_DEPARTMENT');
|
||||
const departmentId = body.scope === 'department' ? await companyDepartment(ctx, body.departmentId!) : null;
|
||||
const [row] = await db
|
||||
.insert(filters)
|
||||
.values({
|
||||
companyId: ctx.company.id,
|
||||
ownerUserId: body.scope === 'personal' ? ctx.user.id : null,
|
||||
departmentId,
|
||||
name: body.name,
|
||||
enabled: body.enabled,
|
||||
params: body.params,
|
||||
})
|
||||
.returning();
|
||||
// Подходящие объявления из общей базы сразу попадают в пул (без уведомлений).
|
||||
return { id: row!.id, fromBase: await backfillFilter(db, row!.id) };
|
||||
});
|
||||
|
||||
const ownFilter = async (ctx: MemberCtx, id: number) => {
|
||||
const [f] = await db.select().from(filters).where(and(eq(filters.id, id), eq(filters.companyId, ctx.company.id)));
|
||||
if (!f) throw notFound();
|
||||
if (f.ownerUserId === null ? ctx.role !== 'manager' : f.ownerUserId !== ctx.user.id) throw forbidden();
|
||||
return f;
|
||||
};
|
||||
|
||||
app.patch('/api/filters/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const f = await ownFilter(ctx, id);
|
||||
const { departmentId, ...body } = onlySent(
|
||||
req.body,
|
||||
FilterBody.partial().extend({ departmentId: z.number().int().positive().nullable() }).partial().parse(req.body),
|
||||
);
|
||||
// Руководитель переносит фильтр компании в отдел и обратно; у личного фильтра отдела нет.
|
||||
const patch = {
|
||||
...body,
|
||||
...(departmentId !== undefined && f.ownerUserId === null
|
||||
? { departmentId: departmentId === null ? null : await companyDepartment(ctx, departmentId) }
|
||||
: {}),
|
||||
};
|
||||
if (!Object.keys(patch).length) throw badRequest('VALIDATION');
|
||||
await db.update(filters).set(patch).where(eq(filters.id, id));
|
||||
return { ok: true, fromBase: await backfillFilter(db, id) };
|
||||
});
|
||||
|
||||
app.delete('/api/filters/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
await ownFilter(ctx, id);
|
||||
await db.delete(filters).where(eq(filters.id, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Счётчики для вкладок списка лидов. */
|
||||
app.get('/api/leads-summary', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const fresh = await statusByCode(db, ctx.company.id, 'new');
|
||||
const [row] = await db
|
||||
.select({
|
||||
pool: sql<number>`count(*) filter (where ${leads.assigneeId} is null and ${leads.statusId} = ${fresh.id})::int`,
|
||||
mine: sql<number>`count(*) filter (where ${leads.assigneeId} = ${ctx.user.id})::int`,
|
||||
all: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(leads)
|
||||
.where(and(eq(leads.companyId, ctx.company.id), departmentScope(actor(ctx), leads.departmentId), leadActual));
|
||||
return row ?? { pool: 0, mine: 0, all: 0 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
import { normalizePhone, phoneConfidence } from '@lr/shared';
|
||||
import { and, eq, isNull, or, sql, type AnyColumn, type SQL } from 'drizzle-orm';
|
||||
import type { Db, Tx } from '../db/client.ts';
|
||||
import {
|
||||
blacklist,
|
||||
companies,
|
||||
deals,
|
||||
events,
|
||||
leads,
|
||||
listingPhones,
|
||||
listings,
|
||||
pipelineStatuses,
|
||||
rejectReasons,
|
||||
users,
|
||||
} from '../db/schema.ts';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts';
|
||||
import { dealInput, dealText, dropDealSide, moveClientToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts';
|
||||
import { statusByCode } from '../pipelines/service.ts';
|
||||
import { enqueueLead, enqueueTaken, enqueueText } from '../telegram/outbox.ts';
|
||||
|
||||
// Жизненный цикл лида в воронке объекта: пул → «Беру» → статусы → сделка или отказ,
|
||||
// возврат в пул. Каждое действие пишет событие в историю и ставит уведомления в очередь.
|
||||
|
||||
export type Lead = typeof leads.$inferSelect;
|
||||
export type User = typeof users.$inferSelect;
|
||||
type Company = typeof companies.$inferSelect;
|
||||
type Listing = typeof listings.$inferSelect;
|
||||
|
||||
/** Кто действует: пользователь компании с ролью в текущем кабинете. */
|
||||
export interface Actor {
|
||||
user: User;
|
||||
role: 'manager' | 'agent' | 'superadmin';
|
||||
company: Company;
|
||||
}
|
||||
|
||||
const isManager = (a: Actor) => a.role === 'manager';
|
||||
|
||||
/** Один и тот же отдел: пустой отдел («без отдела») равен только пустому. */
|
||||
export const sameDepartment = (a: number | null | undefined, b: number | null | undefined) => (a ?? null) === (b ?? null);
|
||||
|
||||
/** Условие «запись из отдела department» для запросов; null — записи без отдела. */
|
||||
export const inDepartment = (column: AnyColumn, department: number | null | undefined): SQL =>
|
||||
department == null ? isNull(column) : eq(column, department);
|
||||
|
||||
/**
|
||||
* Какие отделы видит пользователь в списках: руководитель и суперадмин — все (undefined — без ограничения),
|
||||
* сотрудник — только свой.
|
||||
*/
|
||||
export const departmentScope = (actor: Actor, column: AnyColumn): SQL | undefined =>
|
||||
isManager(actor) ? undefined : inDepartment(column, actor.user.departmentId);
|
||||
|
||||
/**
|
||||
* Объект актуален: объявление не снято с List.am (у объекта вручную объявления нет) и карточка не закрыта
|
||||
* сделкой или отказом. Неактуальные объекты остаются в базе и в разделе «База», но не попадают в рабочие
|
||||
* списки, поиск, фильтры и подборки.
|
||||
*/
|
||||
// Столбцы внешней таблицы в подзапросах — с явным именем таблицы: без него Postgres может взять столбец подзапроса.
|
||||
export const listingLive = sql`not exists (select 1 from listings li where li.id = "leads"."listing_id" and not li.active)`;
|
||||
export const leadOpen = sql`not exists (select 1 from pipeline_statuses ps where ps.id = "leads"."status_id" and ps.code in ('deal', 'rejected'))`;
|
||||
export const leadActual = sql`(${listingLive} and ${leadOpen})`;
|
||||
|
||||
/**
|
||||
* Цена в выбранной валюте не ниже from и не выше to. Цена объекта пересчитывается по курсам
|
||||
* (сколько единиц валюты в одном долларе); объект без валюты считается в долларах.
|
||||
*/
|
||||
export function priceRange(
|
||||
price: AnyColumn,
|
||||
currency: AnyColumn,
|
||||
rates: Record<string, number>,
|
||||
target: string,
|
||||
from?: number,
|
||||
to?: number,
|
||||
): SQL | undefined {
|
||||
if (from === undefined && to === undefined) return undefined;
|
||||
const perUsd = (code: string) => rates[code] ?? (code === 'USD' ? 1 : undefined);
|
||||
const known = Object.entries({ USD: 1, ...rates }).filter(([, rate]) => rate > 0);
|
||||
// Цена объекта в долларах: делим на курс его валюты.
|
||||
const usd = sql`(${price}::numeric / (case coalesce(${currency}, 'USD') ${sql.join(
|
||||
known.map(([code, rate]) => sql`when ${code} then ${rate}::numeric`),
|
||||
sql` `,
|
||||
)} else 1 end))`;
|
||||
const k = perUsd(target) ?? 1;
|
||||
return and(
|
||||
sql`${price} is not null`,
|
||||
from !== undefined ? sql`${usd} >= ${from / k}::numeric` : undefined,
|
||||
to !== undefined ? sql`${usd} <= ${to / k}::numeric` : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export const activeMembers = (tx: Tx, companyId: number) =>
|
||||
tx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
|
||||
|
||||
async function logEvent(
|
||||
tx: Tx,
|
||||
lead: Pick<Lead, 'id' | 'companyId'>,
|
||||
userId: number | null,
|
||||
type: string,
|
||||
extra: { oldValue?: string | null; newValue?: string | null; text?: string | null; statusId?: number } = {},
|
||||
) {
|
||||
await tx.insert(events).values({ companyId: lead.companyId, leadId: lead.id, userId, type, ...extra });
|
||||
}
|
||||
|
||||
const touchCompany = (tx: Tx, companyId: number) =>
|
||||
tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId));
|
||||
|
||||
/**
|
||||
* Может ли пользователь видеть лид. Руководитель — любой. Сотрудник — только лиды своего отдела:
|
||||
* свой, из пула, либо любой при открытой видимости.
|
||||
*/
|
||||
export function canSee(actor: Actor, lead: Lead): boolean {
|
||||
if (lead.companyId !== actor.company.id) return false;
|
||||
if (isManager(actor)) return true;
|
||||
if (!sameDepartment(lead.departmentId, actor.user.departmentId)) return false;
|
||||
if (actor.company.settings.agentsSeeAll) return true;
|
||||
return lead.assigneeId === actor.user.id || lead.assigneeId === null;
|
||||
}
|
||||
|
||||
/** Вести лид (статус, комментарий, напоминание) может ответственный и руководитель. */
|
||||
function assertCanWork(actor: Actor, lead: Lead) {
|
||||
if (!isManager(actor) && lead.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_LEAD');
|
||||
}
|
||||
|
||||
async function loadLead(tx: Tx, actor: Actor, leadId: number): Promise<Lead> {
|
||||
const [lead] = await tx
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)));
|
||||
if (!lead || !canSee(actor, lead)) throw notFound('LEAD_NOT_FOUND');
|
||||
return lead;
|
||||
}
|
||||
|
||||
const copyProperty = (l: Listing) => ({
|
||||
title: l.title,
|
||||
description: l.description,
|
||||
dealType: l.dealType,
|
||||
propertyType: l.propertyType,
|
||||
region: l.region,
|
||||
district: l.district,
|
||||
address: l.address,
|
||||
price: l.price,
|
||||
currency: l.currency,
|
||||
rooms: l.rooms,
|
||||
area: l.area,
|
||||
floor: l.floor,
|
||||
floorsTotal: l.floorsTotal,
|
||||
photos: l.photos,
|
||||
authorName: l.authorName,
|
||||
phone: l.phone,
|
||||
features: l.features,
|
||||
});
|
||||
|
||||
/**
|
||||
* Лид из объявления: статус «Новый», в пуле, уведомления подходящим агентам.
|
||||
* Возвращает null, если у компании уже есть лид по этому объявлению.
|
||||
*/
|
||||
export async function createLeadFromListing(
|
||||
tx: Tx,
|
||||
companyId: number,
|
||||
listing: Listing,
|
||||
recipients: User[],
|
||||
/** Прошлый лид по тому же объекту: объявление сняли и опубликовали снова. */
|
||||
previousLeadId: number | null = null,
|
||||
/** Отдел, в который пришёл объект; null — без отдела. */
|
||||
departmentId: number | null = null,
|
||||
) {
|
||||
const fresh = await statusByCode(tx, companyId, 'new');
|
||||
const [lead] = await tx
|
||||
.insert(leads)
|
||||
.values({ companyId, departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: fresh.id, previousLeadId, bumpedAt: listing.bumpedAt, ...copyProperty(listing) })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (!lead) return null;
|
||||
await logEvent(tx, lead, null, 'created', { newValue: 'listam' });
|
||||
await enqueueLead(tx, lead.id, recipients);
|
||||
return lead;
|
||||
}
|
||||
|
||||
export interface ManualLead {
|
||||
title: string;
|
||||
description?: string;
|
||||
dealType?: string;
|
||||
propertyType?: string;
|
||||
region?: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
rooms?: number;
|
||||
area?: number;
|
||||
floor?: number;
|
||||
floorsTotal?: number;
|
||||
authorName?: string;
|
||||
phone?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Лид вручную (объект не с List.am): сразу закреплён за создателем в статусе «Взят в работу». */
|
||||
export async function createManualLead(db: Db, actor: Actor, input: ManualLead) {
|
||||
return db.transaction(async (tx) => {
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const [lead] = await tx
|
||||
.insert(leads)
|
||||
.values({
|
||||
...input,
|
||||
phone: normalizePhone(input.phone) ?? input.phone ?? null,
|
||||
photos: [],
|
||||
companyId: actor.company.id,
|
||||
// Объект попадает в отдел того, кто его завёл.
|
||||
departmentId: actor.user.departmentId,
|
||||
source: 'manual',
|
||||
statusId: taken.id,
|
||||
assigneeId: actor.user.id,
|
||||
takenAt: new Date(),
|
||||
createdById: actor.user.id,
|
||||
})
|
||||
.returning();
|
||||
await logEvent(tx, lead!, actor.user.id, 'created', { newValue: 'manual' });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return lead!;
|
||||
});
|
||||
}
|
||||
|
||||
/** Чем кончилось добавление объекта ссылкой на list.am. */
|
||||
export type ClaimResult =
|
||||
/** Объект был в пуле — теперь он закреплён за агентом. */
|
||||
| { result: 'taken'; id: number }
|
||||
/** Объекта в CRM не было — заведён и закреплён за агентом. */
|
||||
| { result: 'created'; id: number }
|
||||
/** Объект уже у этого агента. */
|
||||
| { result: 'mine'; id: number }
|
||||
/** Объект закреплён за другим агентом; id — если агенту можно открыть карточку. */
|
||||
| { result: 'busy'; id: number | null; assigneeName: string | null };
|
||||
|
||||
/**
|
||||
* Лид компании по объявлению list.am: последний по связи с общей базой или по ссылке у объекта,
|
||||
* заведённого вручную. `department` — искать только в этом отделе (undefined — во всей компании).
|
||||
*/
|
||||
export async function findListingLead(tx: Tx, companyId: number, externalId: string, listingId: number | null, department?: number | null) {
|
||||
const byUrl = sql`${leads.url} ~ ${`list\\.am/(?:[a-z]{2}/)?item/${externalId}([^0-9]|$)`}`;
|
||||
const [row] = await tx
|
||||
.select({ lead: leads, assigneeName: users.name })
|
||||
.from(leads)
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.where(
|
||||
and(
|
||||
eq(leads.companyId, companyId),
|
||||
department === undefined ? undefined : inDepartment(leads.departmentId, department),
|
||||
listingId === null ? byUrl : or(eq(leads.listingId, listingId), byUrl),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${leads.id} desc`)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Агент добавил ссылкой объект, который уже есть в CRM: свободный объект из пула закрепляется
|
||||
* за ним, иначе он узнаёт, за кем объект закреплён.
|
||||
*/
|
||||
export async function claimFoundLead(db: Db, actor: Actor, found: { lead: Lead; assigneeName: string | null }): Promise<ClaimResult> {
|
||||
const { lead } = found;
|
||||
if (lead.assigneeId === actor.user.id) return { result: 'mine', id: lead.id };
|
||||
if (lead.assigneeId === null) {
|
||||
const fresh = await statusByCode(db, actor.company.id, 'new');
|
||||
if (lead.statusId === fresh.id) {
|
||||
try {
|
||||
return { result: 'taken', id: (await takeLead(db, actor, lead.id)).id };
|
||||
} catch (error) {
|
||||
// Объект успели взять, пока агент вставлял ссылку: показываем, кто взял.
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ALREADY_TAKEN')) throw error;
|
||||
const [again] = await db.select({ lead: leads, assigneeName: users.name }).from(leads).leftJoin(users, eq(users.id, leads.assigneeId)).where(eq(leads.id, lead.id));
|
||||
return claimFoundLead(db, actor, again!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result: 'busy', id: canSee(actor, lead) ? lead.id : null, assigneeName: found.assigneeName };
|
||||
}
|
||||
|
||||
/** Объекта ещё нет в CRM компании: лид из объявления сразу закреплён за агентом, без пула и рассылки. */
|
||||
export async function createClaimedLead(db: Db, actor: Actor, listing: Listing): Promise<ClaimResult> {
|
||||
const lead = await db.transaction(async (tx) => {
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const [row] = await tx
|
||||
.insert(leads)
|
||||
.values({
|
||||
companyId: actor.company.id, departmentId: actor.user.departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: taken.id,
|
||||
assigneeId: actor.user.id, takenAt: new Date(), createdById: actor.user.id, bumpedAt: listing.bumpedAt, ...copyProperty(listing),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (!row) return null;
|
||||
await logEvent(tx, row, actor.user.id, 'created', { newValue: 'link' });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return row;
|
||||
});
|
||||
if (lead) return { result: 'created', id: lead.id };
|
||||
// Лид по этому объявлению появился одновременно (парсер или другой агент).
|
||||
const found = await findListingLead(db, actor.company.id, listing.externalId, listing.id, actor.user.departmentId);
|
||||
return claimFoundLead(db, actor, found!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление карточки объекта вместе с историей. Удаляет руководитель (и суперадмин в кабинете
|
||||
* компании); карточку со сделкой — только суперадмин: сделка уходит из аналитики, ведомости и счетов.
|
||||
*/
|
||||
export async function deleteLead(db: Db, actor: Actor, leadId: number) {
|
||||
if (!isManager(actor)) throw forbidden();
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.leadId, lead.id));
|
||||
if (deal) {
|
||||
if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL');
|
||||
await dropDealSide(tx, 'lead', lead.id);
|
||||
}
|
||||
await tx.update(leads).set({ previousLeadId: null }).where(eq(leads.previousLeadId, lead.id));
|
||||
await tx.delete(events).where(eq(events.leadId, lead.id));
|
||||
await tx.delete(leads).where(eq(leads.id, lead.id));
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* «Беру». Лид получает ровно один агент: условие «ответственного нет» проверяется самим
|
||||
* UPDATE, поэтому при одновременном нажатии второй запрос не найдёт строку и получит «Уже взят».
|
||||
*/
|
||||
export async function takeLead(db: Db, actor: Actor, leadId: number): Promise<Lead> {
|
||||
return db.transaction(async (tx) => {
|
||||
const fresh = await statusByCode(tx, actor.company.id, 'new');
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const [lead] = await tx
|
||||
.update(leads)
|
||||
.set({
|
||||
assigneeId: actor.user.id,
|
||||
statusId: taken.id,
|
||||
takenAt: new Date(),
|
||||
statusChangedAt: new Date(),
|
||||
poolWarnedAt: null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(leads.id, leadId),
|
||||
eq(leads.companyId, actor.company.id),
|
||||
// Объект чужого отдела взять нельзя.
|
||||
departmentScope(actor, leads.departmentId),
|
||||
isNull(leads.assigneeId),
|
||||
eq(leads.statusId, fresh.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!lead) {
|
||||
const [exists] = await tx
|
||||
.select({ id: leads.id })
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id), departmentScope(actor, leads.departmentId)));
|
||||
throw exists ? conflict('ALREADY_TAKEN') : notFound('LEAD_NOT_FOUND');
|
||||
}
|
||||
await logEvent(tx, lead, actor.user.id, 'taken');
|
||||
await enqueueTaken(tx, lead.id, actor.user.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return lead;
|
||||
});
|
||||
}
|
||||
|
||||
/** Руководитель назначает или переназначает лид. Оба агента получают уведомление. */
|
||||
export async function assignLead(db: Db, actor: Actor, leadId: number, toUserId: number): Promise<Lead> {
|
||||
if (!isManager(actor)) throw forbidden();
|
||||
return db.transaction(async (tx) => {
|
||||
const [before] = await tx
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)))
|
||||
.for('update');
|
||||
if (!before) throw notFound('LEAD_NOT_FOUND');
|
||||
const [target] = await tx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.id, toUserId), eq(users.companyId, actor.company.id), isNull(users.removedAt)));
|
||||
if (!target) throw badRequest('BAD_ASSIGNEE');
|
||||
if (before.assigneeId === toUserId) return before;
|
||||
// Объект переходит в отдел нового ответственного — иначе тот его не увидит. Если в том отделе уже
|
||||
// есть карточка по этому объявлению, вторую туда не переносим.
|
||||
const moving = !sameDepartment(before.departmentId, target.departmentId);
|
||||
if (moving && before.listingId && before.previousLeadId === null) {
|
||||
const [twin] = await tx
|
||||
.select({ id: leads.id })
|
||||
.from(leads)
|
||||
.where(
|
||||
and(
|
||||
eq(leads.companyId, actor.company.id),
|
||||
eq(leads.listingId, before.listingId),
|
||||
isNull(leads.previousLeadId),
|
||||
inDepartment(leads.departmentId, target.departmentId),
|
||||
),
|
||||
);
|
||||
if (twin) throw conflict('DEPARTMENT_HAS_LEAD');
|
||||
}
|
||||
|
||||
const fresh = await statusByCode(tx, actor.company.id, 'new');
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const fromPool = before.assigneeId === null;
|
||||
const [lead] = await tx
|
||||
.update(leads)
|
||||
.set({
|
||||
assigneeId: toUserId,
|
||||
departmentId: target.departmentId,
|
||||
poolWarnedAt: null,
|
||||
statusChangedAt: new Date(),
|
||||
...(before.statusId === fresh.id ? { statusId: taken.id } : {}),
|
||||
...(fromPool ? { takenAt: new Date() } : {}),
|
||||
})
|
||||
.where(eq(leads.id, leadId))
|
||||
.returning();
|
||||
await logEvent(tx, lead!, actor.user.id, 'assigned', {
|
||||
oldValue: before.assigneeId ? String(before.assigneeId) : null,
|
||||
newValue: String(toUserId),
|
||||
});
|
||||
if (fromPool) await enqueueTaken(tx, leadId, toUserId);
|
||||
if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'assigned', [lead!.title], leadId);
|
||||
if (before.assigneeId && before.assigneeId !== actor.user.id) {
|
||||
await enqueueText(tx, [before.assigneeId], 'reassignedAway', [lead!.title, target.name]);
|
||||
}
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return lead!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Возврат в пул: лид снова «Новый» и без ответственного, история сохраняется.
|
||||
* `actor = null` — возврат по правилу «N часов без смены статуса»: прежний агент получает
|
||||
* уведомление о потере. Остальные участники получают повторное уведомление с кнопкой «Беру».
|
||||
*/
|
||||
export async function returnToPool(tx: Tx, lead: Lead, actorUserId: number | null): Promise<void> {
|
||||
const fresh = await statusByCode(tx, lead.companyId, 'new');
|
||||
const previous = lead.assigneeId;
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ assigneeId: null, statusId: fresh.id, takenAt: null, statusChangedAt: new Date(), poolWarnedAt: null })
|
||||
.where(eq(leads.id, lead.id));
|
||||
await logEvent(tx, lead, actorUserId, 'pool_return', { oldValue: previous ? String(previous) : null });
|
||||
if (previous && actorUserId === null) await enqueueText(tx, [previous], 'poolLost', [lead.title]);
|
||||
// «Беру» снова получают сотрудники отдела, которому принадлежит объект.
|
||||
const others = (await activeMembers(tx, lead.companyId)).filter((u) => u.id !== previous && sameDepartment(u.departmentId, lead.departmentId));
|
||||
await enqueueLead(tx, lead.id, others, true);
|
||||
}
|
||||
|
||||
export async function releaseLead(db: Db, actor: Actor, leadId: number) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
if (lead.assigneeId === null) throw badRequest('ALREADY_IN_POOL');
|
||||
assertCanWork(actor, lead);
|
||||
await returnToPool(tx, lead, actor.user.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export interface StatusChange {
|
||||
statusId: number;
|
||||
rejectReasonId?: number;
|
||||
/** Обязательна при переводе в «Сделка». */
|
||||
deal?: DealBody & { clientId?: number };
|
||||
}
|
||||
|
||||
export async function changeStatus(db: Db, actor: Actor, leadId: number, change: StatusChange): Promise<Lead> {
|
||||
return db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
// Лид в пуле сначала нужно взять.
|
||||
if (lead.assigneeId === null) throw badRequest('TAKE_FIRST');
|
||||
assertCanWork(actor, lead);
|
||||
const statuses = await tx
|
||||
.select()
|
||||
.from(pipelineStatuses)
|
||||
.where(and(eq(pipelineStatuses.companyId, actor.company.id), eq(pipelineStatuses.pipeline, 'object')));
|
||||
const from = statuses.find((s) => s.id === lead.statusId);
|
||||
const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt);
|
||||
if (!to) throw badRequest('BAD_STATUS');
|
||||
if (to.id === lead.statusId) return lead;
|
||||
// «Новый» — это пул: туда лид попадает только возвратом в пул.
|
||||
if (to.code === 'new') throw badRequest('USE_RETURN_TO_POOL');
|
||||
if (from?.code === 'deal' && !isManager(actor)) throw forbidden('DEAL_CLOSED');
|
||||
|
||||
let rejectReasonId: number | null = null;
|
||||
if (to.code === 'rejected') {
|
||||
const [reason] = change.rejectReasonId
|
||||
? await tx
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(
|
||||
and(
|
||||
eq(rejectReasons.id, change.rejectReasonId),
|
||||
eq(rejectReasons.companyId, actor.company.id),
|
||||
eq(rejectReasons.pipeline, 'object'),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (!reason) throw badRequest('REJECT_REASON_REQUIRED');
|
||||
rejectReasonId = reason.id;
|
||||
}
|
||||
let deal: DealInput | null = null;
|
||||
if (to.code === 'deal') {
|
||||
if (!change.deal) throw badRequest('COMMISSION_REQUIRED');
|
||||
deal = dealInput(change.deal, 'owner');
|
||||
await recordDeal(tx, {
|
||||
companyId: actor.company.id,
|
||||
leadId,
|
||||
clientId: change.deal.clientId ?? null,
|
||||
agentId: lead.assigneeId,
|
||||
deal,
|
||||
});
|
||||
// Сделка с клиентом из CRM закрывается один раз: клиент переходит в «Сделка» вместе с объектом.
|
||||
if (change.deal.clientId) {
|
||||
await moveClientToDeal(tx, actor.company.id, actor.user.id, leadId, change.deal.clientId, deal);
|
||||
}
|
||||
} else if (from?.code === 'deal') {
|
||||
await dropDealSide(tx, 'lead', leadId);
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(leads)
|
||||
.set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date(), poolWarnedAt: null })
|
||||
.where(eq(leads.id, leadId))
|
||||
.returning();
|
||||
await logEvent(tx, lead, actor.user.id, to.code === 'deal' ? 'deal' : 'status', {
|
||||
oldValue: from?.name ?? null,
|
||||
newValue: to.name,
|
||||
statusId: to.id,
|
||||
text: deal ? dealText(deal) : null,
|
||||
});
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return updated!;
|
||||
});
|
||||
}
|
||||
|
||||
export async function addComment(db: Db, actor: Actor, leadId: number, text: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
await logEvent(tx, lead, actor.user.id, 'comment', { text });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setNextAction(db: Db, actor: Actor, leadId: number, at: Date | null, note: string | null) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null })
|
||||
.where(eq(leads.id, leadId));
|
||||
});
|
||||
}
|
||||
|
||||
export interface PhoneVariant {
|
||||
phone: string;
|
||||
/** Сколько компаний указали этот номер. */
|
||||
votes: number;
|
||||
/** 80 — совпал у двух, 100 — у трёх и больше, null — указал один агент. */
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/** Все варианты номера по объявлению из общей базы: самый подтверждённый — первым. */
|
||||
export async function phoneVariants(tx: Tx, listingId: number): Promise<PhoneVariant[]> {
|
||||
const rows = await tx
|
||||
.select({ phone: listingPhones.phone, votes: sql<number>`count(*)::int`, first: sql<Date>`min(${listingPhones.createdAt})` })
|
||||
.from(listingPhones)
|
||||
.where(eq(listingPhones.listingId, listingId))
|
||||
.groupBy(listingPhones.phone)
|
||||
.orderBy(sql`count(*) desc`, sql`min(${listingPhones.createdAt})`);
|
||||
return rows.map((r) => ({ phone: r.phone, votes: r.votes, confidence: phoneConfidence(r.votes) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Телефон собственника, который агент взял из объявления и внёс вручную. Вариант компании
|
||||
* записывается в общую базу; номером объявления становится самый подтверждённый вариант — он же
|
||||
* работает в правиле «больше N объявлений с номера». Пустая строка стирает номер у лида и вариант компании.
|
||||
*/
|
||||
export async function setLeadPhone(db: Db, actor: Actor, leadId: number, raw: string) {
|
||||
const phone = raw.trim() ? normalizePhone(raw) : null;
|
||||
if (raw.trim() && !phone) throw badRequest('BAD_PHONE');
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
if (lead.phone === phone) return;
|
||||
await tx.update(leads).set({ phone }).where(eq(leads.id, leadId));
|
||||
if (lead.listingId) {
|
||||
const mine = and(eq(listingPhones.listingId, lead.listingId), eq(listingPhones.companyId, actor.company.id));
|
||||
if (phone) {
|
||||
await tx
|
||||
.insert(listingPhones)
|
||||
.values({ listingId: lead.listingId, companyId: actor.company.id, userId: actor.user.id, phone })
|
||||
.onConflictDoUpdate({ target: [listingPhones.listingId, listingPhones.companyId], set: { phone, userId: actor.user.id, createdAt: new Date() } });
|
||||
} else {
|
||||
await tx.delete(listingPhones).where(mine);
|
||||
}
|
||||
const [best] = await phoneVariants(tx, lead.listingId);
|
||||
await tx.update(listings).set({ phone: best?.phone ?? null }).where(eq(listings.id, lead.listingId));
|
||||
}
|
||||
await logEvent(tx, lead, actor.user.id, 'phone', { newValue: phone });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export type LeadEdit = Partial<
|
||||
Pick<Lead, 'title' | 'description' | 'dealType' | 'propertyType' | 'region' | 'district' | 'address' | 'price' | 'currency' | 'rooms' | 'area' | 'floor' | 'floorsTotal' | 'authorName'>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Правка данных объекта. Меняется только карточка компании — общая база объявлений остаётся как есть.
|
||||
* Текст для клиента собирается заново из новых данных.
|
||||
*/
|
||||
export async function editLead(db: Db, actor: Actor, leadId: number, patch: LeadEdit) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
const changed = Object.fromEntries(
|
||||
Object.entries(patch).filter(([key, value]) => String(lead[key as keyof LeadEdit] ?? '') !== String(value ?? '')),
|
||||
) as LeadEdit;
|
||||
if (!Object.keys(changed).length) return;
|
||||
await tx.update(leads).set({ ...changed, showcaseText: null }).where(eq(leads.id, leadId));
|
||||
await logEvent(tx, lead, actor.user.id, 'edited');
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* «Объявления больше нет»: агент увидел, что объявление сняли, раньше суточной проверки.
|
||||
* Отметка ставится в общей базе — у всех компаний с этим объектом; если объявление снова появится
|
||||
* в списке list.am, парсер вернёт его на сайт сам.
|
||||
*/
|
||||
export async function markListingRemoved(db: Db, actor: Actor, leadId: number) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
if (lead.assigneeId !== null) assertCanWork(actor, lead);
|
||||
if (!lead.listingId) throw badRequest('NO_LISTING');
|
||||
const now = new Date();
|
||||
const [row] = await tx
|
||||
.update(listings)
|
||||
.set({ active: false, removedAt: now, lastCheckedAt: now })
|
||||
.where(and(eq(listings.id, lead.listingId), eq(listings.active, true)))
|
||||
.returning({ id: listings.id });
|
||||
if (!row) return;
|
||||
await logEvent(tx, lead, actor.user.id, 'listing_removed');
|
||||
await rejectRemovedListingLeads(tx, row.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Объявление сняли с List.am: у компаний с настройкой autoRejectRemoved открытые лиды по нему
|
||||
* (не «Сделка» и не «Отказ») уходят в «Отказ» с причиной «Уже продано или сдано» — в архив,
|
||||
* из пула и рабочих списков. Возвращает число переведённых лидов.
|
||||
*/
|
||||
export async function rejectRemovedListingLeads(tx: Tx, listingId: number): Promise<number> {
|
||||
const open = await tx
|
||||
.select({ lead: leads, status: pipelineStatuses.name })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.innerJoin(companies, eq(companies.id, leads.companyId))
|
||||
.where(
|
||||
and(
|
||||
eq(leads.listingId, listingId),
|
||||
sql`coalesce(${pipelineStatuses.code}, '') not in ('deal', 'rejected')`,
|
||||
sql`(${companies.settings}->>'autoRejectRemoved')::boolean is true`,
|
||||
),
|
||||
);
|
||||
for (const { lead, status } of open) {
|
||||
const rejected = await statusByCode(tx, lead.companyId, 'rejected');
|
||||
// У компаний, заведённых до кода 'gone', причина находится по названию.
|
||||
const [reason] = await tx
|
||||
.select({ id: rejectReasons.id })
|
||||
.from(rejectReasons)
|
||||
.where(
|
||||
and(
|
||||
eq(rejectReasons.companyId, lead.companyId),
|
||||
eq(rejectReasons.pipeline, 'object'),
|
||||
or(eq(rejectReasons.code, 'gone'), and(isNull(rejectReasons.code), eq(rejectReasons.name, 'Уже продано или сдано'))),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${rejectReasons.code} nulls last`)
|
||||
.limit(1);
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ statusId: rejected.id, rejectReasonId: reason?.id ?? null, statusChangedAt: new Date(), poolWarnedAt: null, nextActionAt: null })
|
||||
.where(eq(leads.id, lead.id));
|
||||
await logEvent(tx, lead, null, 'status', {
|
||||
oldValue: status,
|
||||
newValue: rejected.name,
|
||||
statusId: rejected.id,
|
||||
text: 'Объявление снято с List.am',
|
||||
});
|
||||
}
|
||||
return open.length;
|
||||
}
|
||||
|
||||
/** «Это агент»: номер автора — в чёрный список компании (если он известен), лид — в «Отказ». */
|
||||
export async function markAsAgent(db: Db, actor: Actor, leadId: number): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
if (lead.assigneeId !== null) assertCanWork(actor, lead);
|
||||
const rejected = await statusByCode(tx, actor.company.id, 'rejected');
|
||||
const [reason] = await tx
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(
|
||||
and(
|
||||
eq(rejectReasons.companyId, actor.company.id),
|
||||
eq(rejectReasons.pipeline, 'object'),
|
||||
eq(rejectReasons.code, 'agent'),
|
||||
),
|
||||
);
|
||||
if (lead.phone) {
|
||||
await tx
|
||||
.insert(blacklist)
|
||||
.values({ companyId: actor.company.id, phone: lead.phone, addedById: actor.user.id })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
const wasInPool = lead.assigneeId === null;
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({
|
||||
statusId: rejected.id,
|
||||
rejectReasonId: reason?.id ?? null,
|
||||
assigneeId: lead.assigneeId ?? actor.user.id,
|
||||
statusChangedAt: new Date(),
|
||||
poolWarnedAt: null,
|
||||
})
|
||||
.where(eq(leads.id, leadId));
|
||||
await logEvent(tx, lead, actor.user.id, 'blacklisted', { newValue: lead.phone, statusId: rejected.id });
|
||||
if (wasInPool) await enqueueTaken(tx, leadId, actor.user.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Фоновые задачи по лидам, запускаются раз в минуту:
|
||||
* предупреждение за 2 часа до возврата в пул, сам возврат, напоминания о следующем действии.
|
||||
*/
|
||||
export async function runLeadJobs(db: Db, now = new Date()): Promise<{ warned: number; returned: number; reminded: number }> {
|
||||
const result = { warned: 0, returned: 0, reminded: 0 };
|
||||
const due = await db
|
||||
.select({ lead: leads, hours: sql<number>`(${companies.settings}->>'poolReturnHours')::int` })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.innerJoin(companies, eq(companies.id, leads.companyId))
|
||||
.where(
|
||||
and(
|
||||
sql`${leads.assigneeId} is not null`,
|
||||
eq(pipelineStatuses.poolReturn, true),
|
||||
// Предупреждение уходит за 2 часа до срока; при сроке меньше 2 часов — сразу.
|
||||
sql`${leads.statusChangedAt} <= ${now.toISOString()}::timestamptz - make_interval(hours => greatest((${companies.settings}->>'poolReturnHours')::int - 2, 0))`,
|
||||
),
|
||||
);
|
||||
for (const { lead, hours } of due) {
|
||||
const expired = lead.statusChangedAt.getTime() <= now.getTime() - hours * 3_600_000;
|
||||
await db.transaction(async (tx) => {
|
||||
// Перечитываем под блокировкой: агент мог сменить статус, пока шла выборка.
|
||||
const [current] = await tx.select().from(leads).where(eq(leads.id, lead.id)).for('update');
|
||||
if (!current || current.assigneeId === null || current.statusChangedAt.getTime() !== lead.statusChangedAt.getTime()) return;
|
||||
if (expired) {
|
||||
await returnToPool(tx, current, null);
|
||||
result.returned++;
|
||||
} else if (!current.poolWarnedAt) {
|
||||
const left = Math.max(1, Math.round((current.statusChangedAt.getTime() + hours * 3_600_000 - now.getTime()) / 3_600_000));
|
||||
await enqueueText(tx, [current.assigneeId], 'poolWarn', [current.title, left], current.id);
|
||||
await tx.update(leads).set({ poolWarnedAt: now }).where(eq(leads.id, current.id));
|
||||
result.warned++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const reminders = await db
|
||||
.update(leads)
|
||||
.set({ nextActionNotifiedAt: now })
|
||||
.where(
|
||||
and(
|
||||
sql`${leads.nextActionAt} <= ${now.toISOString()}::timestamptz`,
|
||||
isNull(leads.nextActionNotifiedAt),
|
||||
sql`${leads.assigneeId} is not null`,
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
for (const lead of reminders) {
|
||||
await enqueueText(db, [lead.assigneeId!], 'reminder', [lead.title, lead.nextActionNote], lead.id);
|
||||
result.reminded++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export { loadLead, statusByCode };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expectedCommission, toUsd, type CommissionKey, type CommissionRule, type CommissionSettings, type Currency } from '@lr/shared';
|
||||
|
||||
/**
|
||||
* Комиссия для карточки: условие (своё или из настроек компании) и ожидаемая сумма.
|
||||
* `custom` — агент поменял условие в этой карточке.
|
||||
*/
|
||||
export function commissionView(
|
||||
settings: CommissionSettings | undefined,
|
||||
key: CommissionKey,
|
||||
own: CommissionRule | null,
|
||||
price: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
) {
|
||||
const rule = own ?? settings?.[key] ?? null;
|
||||
return { rule, custom: Boolean(own), expected: expectedCommission(rule, price, currency as Currency | null) };
|
||||
}
|
||||
|
||||
/** Условие в записи истории: «percent:2.5», «fixed:1000:USD», «mixed:200:USD:2»; null — как в настройках компании. */
|
||||
export const ruleCode = (rule: CommissionRule | null | undefined): string | null =>
|
||||
!rule
|
||||
? null
|
||||
: rule.mode === 'percent'
|
||||
? `percent:${rule.value}`
|
||||
: rule.mode === 'fixed'
|
||||
? `fixed:${rule.value}:${rule.currency}`
|
||||
: `mixed:${rule.fixed ?? 0}:${rule.currency}:${rule.value}`;
|
||||
|
||||
/**
|
||||
* На сколько процентов изменилась ожидаемая сумма комиссии: 50 % → 35 % от той же цены — это −30 %.
|
||||
* Если цены нет, но оба условия — проценты, считаем по самим процентам. null — сравнить нечего.
|
||||
*/
|
||||
export function commissionDelta(
|
||||
before: CommissionRule | null | undefined,
|
||||
after: CommissionRule | null | undefined,
|
||||
price: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
): number | null {
|
||||
const a = expectedCommission(before, price, currency as Currency | null);
|
||||
const b = expectedCommission(after, price, currency as Currency | null);
|
||||
let from: number | null = a ? toUsd(a.amount, a.currency) : null;
|
||||
let to: number | null = b ? toUsd(b.amount, b.currency) : null;
|
||||
if ((from === null || to === null) && before?.mode === 'percent' && after?.mode === 'percent') {
|
||||
from = before.value;
|
||||
to = after.value;
|
||||
}
|
||||
if (from === null || to === null || !(from > 0)) return null;
|
||||
return Math.round(((to - from) / from) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** Поле text записи истории: «<условие компании при сбросе>|<изменение суммы в процентах>». */
|
||||
export const commissionNote = (settingsCode: string | null, delta: number | null) => `${settingsCode ?? ''}|${delta ?? ''}`;
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Config {
|
||||
databaseUrl: string;
|
||||
port: number;
|
||||
appUrl: string;
|
||||
production: boolean;
|
||||
telegramBotToken?: string;
|
||||
telegramBotUsername?: string;
|
||||
/** listam | feed | off */
|
||||
listingSource: string;
|
||||
jinaApiKey?: string;
|
||||
/** Клиентский бот: анкета соискателя. Без токена анкета выключена. */
|
||||
clientBotToken?: string;
|
||||
clientBotUsername?: string;
|
||||
/** Сколько процентов высоты срезать сверху у фото на странице клиента. */
|
||||
cropTopPercent: number;
|
||||
/** Куда складывать обработанные фото. */
|
||||
mediaDir: string;
|
||||
/** Переписывание текста объекта моделью через OpenRouter. Без ключа текст собирается по шаблону. */
|
||||
openRouterKey?: string;
|
||||
openRouterModel: string;
|
||||
}
|
||||
|
||||
export function loadConfig(env = process.env): Config {
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL не задан');
|
||||
return {
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
port: Number(env.API_PORT ?? 3100),
|
||||
appUrl: (env.APP_URL ?? 'http://localhost:5174').replace(/\/$/, ''),
|
||||
production: env.NODE_ENV === 'production',
|
||||
telegramBotToken: env.TELEGRAM_BOT_TOKEN || undefined,
|
||||
telegramBotUsername: env.TELEGRAM_BOT_USERNAME || undefined,
|
||||
listingSource: env.LISTING_SOURCE || 'listam',
|
||||
jinaApiKey: env.JINA_API_KEY || undefined,
|
||||
clientBotToken: env.CLIENT_BOT_TOKEN || undefined,
|
||||
clientBotUsername: env.CLIENT_BOT_USERNAME || undefined,
|
||||
cropTopPercent: Math.max(0, Math.min(40, Number(env.CROP_TOP_PERCENT ?? 8) || 0)),
|
||||
mediaDir: env.MEDIA_DIR || './data/media',
|
||||
openRouterKey: env.OPENROUTER_API_KEY || undefined,
|
||||
openRouterModel: env.OPENROUTER_MODEL || 'anthropic/claude-haiku-4.5',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Ошибка с кодом: интерфейс переводит код на язык пользователя. */
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message = code,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const forbidden = (code = 'FORBIDDEN') => new AppError(403, code);
|
||||
export const notFound = (code = 'NOT_FOUND') => new AppError(404, code);
|
||||
export const badRequest = (code: string) => new AppError(400, code);
|
||||
export const conflict = (code: string) => new AppError(409, code);
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Mail {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Отправка писем. Почтовый сервис ещё не выбран — пока письма пишутся в лог. */
|
||||
export interface Mailer {
|
||||
send(mail: Mail): Promise<void>;
|
||||
}
|
||||
|
||||
export const consoleMailer: Mailer = {
|
||||
async send(mail) {
|
||||
console.log(`[mail → ${mail.to}] ${mail.subject}\n${mail.text}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Для PATCH-запросов: оставляет из разобранного тела только поля, которые клиент действительно
|
||||
* прислал. Схема с `.partial()` подставляет значения по умолчанию и в пропущенные поля — без
|
||||
* этой фильтрации правка одного поля сбрасывала бы остальные.
|
||||
*/
|
||||
export function onlySent<T extends object>(body: unknown, parsed: T): Partial<T> {
|
||||
const sent = body !== null && typeof body === 'object' ? body : {};
|
||||
return Object.fromEntries(Object.entries(parsed).filter(([key]) => key in sent)) as Partial<T>;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { buildApp } from './app.ts';
|
||||
import { runBillingJobs } from './billing/service.ts';
|
||||
import { runClientJobs } from './clients/service.ts';
|
||||
import { connect } from './db/client.ts';
|
||||
import { runIngest, runRemovalCheck } from './ingest/service.ts';
|
||||
import { runParserWatchdog, type WatchdogState } from './ingest/watchdog.ts';
|
||||
import { handleIntakeUpdate, telegramDownloader } from './intake/service.ts';
|
||||
import { runReportJobs } from './reports.ts';
|
||||
import { runShowcaseJobs } from './showcase/service.ts';
|
||||
import { runLeadJobs } from './leads/service.ts';
|
||||
import { loadConfig } from './lib/config.ts';
|
||||
import { runGroupPosts } from './group/service.ts';
|
||||
import { ListAmListingSource } from './parser/listam-source.ts';
|
||||
import { loadParserSettings } from './parser/settings.ts';
|
||||
import { startPolling } from './telegram/bot.ts';
|
||||
import { HttpTelegram } from './telegram/client.ts';
|
||||
import { processOutbox } from './telegram/outbox.ts';
|
||||
|
||||
const config = loadConfig();
|
||||
const { db } = connect(config.databaseUrl);
|
||||
const source = config.listingSource === 'listam' ? new ListAmListingSource({ apiKey: config.jinaApiKey }) : null;
|
||||
const app = buildApp({ db, config, source });
|
||||
await app.listen({ port: config.port, host: '0.0.0.0' });
|
||||
console.log(`API слушает порт ${config.port}`);
|
||||
|
||||
/** Повторяет задачу бесконечно: следующий запуск — через паузу после окончания предыдущего. */
|
||||
function loop(name: string, pauseMs: number | (() => Promise<number>), task: () => Promise<unknown>) {
|
||||
void (async () => {
|
||||
for (;;) {
|
||||
try {
|
||||
await task();
|
||||
} catch (e) {
|
||||
console.error(`[${name}]`, e);
|
||||
}
|
||||
const ms = typeof pauseMs === 'number' ? pauseMs : await pauseMs().catch(() => 5_000);
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Клиентский бот — анкета соискателя. Без токена анкета выключена, остальное работает.
|
||||
const clientTg = config.clientBotToken ? new HttpTelegram(config.clientBotToken) : null;
|
||||
if (clientTg) {
|
||||
const download = telegramDownloader(config.clientBotToken!);
|
||||
startPolling({ tg: clientTg }, console.error, (update) => handleIntakeUpdate({ db, tg: clientTg, config, download }, update));
|
||||
console.log(`Клиентский бот @${config.clientBotUsername} запущен`);
|
||||
}
|
||||
|
||||
if (config.telegramBotToken) {
|
||||
const tg = new HttpTelegram(config.telegramBotToken);
|
||||
startPolling({ db, tg, config, clientTg });
|
||||
// Очередь сообщений: пока есть что отправлять, пачки идут подряд.
|
||||
loop('outbox', 500, async () => {
|
||||
while ((await processOutbox({ db, tg, config })) > 0);
|
||||
});
|
||||
// Mini App открывается кнопкой меню бота; Telegram принимает для неё только https-адрес.
|
||||
if (config.appUrl.startsWith('https://')) {
|
||||
tg.setMenuButton('Lead Radar', config.appUrl).catch((e) => console.error('[menu-button]', e));
|
||||
}
|
||||
console.log(`Бот @${config.telegramBotUsername} запущен`);
|
||||
} else {
|
||||
console.log('TELEGRAM_BOT_TOKEN не задан: бот и уведомления выключены');
|
||||
}
|
||||
|
||||
// Возврат в пул, предупреждения и напоминания.
|
||||
loop('lead-jobs', 60_000, async () => {
|
||||
await runLeadJobs(db);
|
||||
await runClientJobs(db);
|
||||
});
|
||||
|
||||
// Объекты, которые агент показывает клиенту: полный набор фото и текст для страницы подборки.
|
||||
loop('showcase', 20_000, () => runShowcaseJobs({ db, config, source }));
|
||||
|
||||
// Объекты в работе с указанной комиссией — в группу компании её ботом.
|
||||
loop('group', 20_000, () => runGroupPosts({ db, config }));
|
||||
|
||||
// Отчёты руководителю в Telegram: за неделю и за месяц.
|
||||
loop('reports', 600_000, () => runReportJobs(db, config));
|
||||
|
||||
// Счета, запланированная смена тарифа и напоминания о подписке.
|
||||
loop('billing', 600_000, () => runBillingJobs(db));
|
||||
|
||||
if (source) {
|
||||
// Парсер ходит по кругу почти без паузы: скорость «публикация → лид» определяется длительностью
|
||||
// прохода, а темп запросов к ридеру выдерживает сам источник.
|
||||
// Включение и пауза между проходами берутся из настроек парсера в админке.
|
||||
loop(
|
||||
'parser',
|
||||
async () => Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000,
|
||||
async () => {
|
||||
if ((await loadParserSettings(db)).enabled) await runIngest({ db, source, log: console.log });
|
||||
},
|
||||
);
|
||||
// Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки.
|
||||
loop('removal-check', 600_000, () => runRemovalCheck({ db, source }));
|
||||
// Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram.
|
||||
const watchdog: WatchdogState = { alertedAt: null };
|
||||
loop('parser-watchdog', 300_000, () => runParserWatchdog(db, watchdog));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SourcedListing } from './source.ts';
|
||||
import { FeedListingSource } from './feed-source.ts';
|
||||
|
||||
const filter = { city: 'yerevan', category: 'apartments', ownerOnly: true };
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
async function collect(source: FeedListingSource, report = vi.fn()) {
|
||||
const items: SourcedListing[] = [];
|
||||
for await (const item of source.fetchLatest(filter, report)) items.push(item);
|
||||
return items;
|
||||
}
|
||||
|
||||
describe('FeedListingSource', () => {
|
||||
it('maps feed items, follows the cursor and sends the token', async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
items: [
|
||||
{
|
||||
id: 123,
|
||||
url: 'https://feed.example/items/123',
|
||||
title: '2-комн., Арабкир',
|
||||
price: '120,000',
|
||||
currency: 'usd',
|
||||
district: 'Arabkir',
|
||||
rooms: '2',
|
||||
photos: ['https://img.example/1.jpg'],
|
||||
},
|
||||
],
|
||||
next: 'c2',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(json({ items: [{ id: 'x9', url: 'https://feed.example/x9' }] }));
|
||||
|
||||
const items = await collect(
|
||||
new FeedListingSource('https://feed.example/api', 'secret', fetchFn),
|
||||
);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
externalId: '123',
|
||||
sourceUrl: 'https://feed.example/items/123',
|
||||
price: 120000,
|
||||
currency: 'USD',
|
||||
district: 'Arabkir',
|
||||
rooms: 2,
|
||||
photoUrls: ['https://img.example/1.jpg'],
|
||||
});
|
||||
expect(items[1]).toMatchObject({ externalId: 'x9', title: 'Объявление x9', currency: 'USD' });
|
||||
|
||||
const [firstUrl, init] = fetchFn.mock.calls[0]!;
|
||||
expect(String(firstUrl)).toBe(
|
||||
'https://feed.example/api?city=yerevan&category=apartments&ownerOnly=true',
|
||||
);
|
||||
expect((init?.headers as Record<string, string>).authorization).toBe('Bearer secret');
|
||||
expect(String(fetchFn.mock.calls[1]![0])).toContain('cursor=c2');
|
||||
});
|
||||
|
||||
it('skips malformed items and reports them', async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
json({
|
||||
items: [
|
||||
{ id: 1, url: 'not a url' },
|
||||
{ id: 2, url: 'https://a.b/2' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const report = vi.fn();
|
||||
const items = await collect(
|
||||
new FeedListingSource('https://feed.example', undefined, fetchFn),
|
||||
report,
|
||||
);
|
||||
expect(items.map((i) => i.externalId)).toEqual(['2']);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fails the run on HTTP errors', async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue(json({}, 503));
|
||||
await expect(
|
||||
collect(new FeedListingSource('https://feed.example', undefined, fetchFn)),
|
||||
).rejects.toThrow('Фид ответил 503');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { CURRENCIES } from '@lr/shared';
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
ListingSource,
|
||||
ListingSourceFilter,
|
||||
SourcedListing,
|
||||
} from './source.ts';
|
||||
|
||||
const text = z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((v) => String(v).trim())
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => v || undefined);
|
||||
const num = z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => {
|
||||
if (v === null || v === undefined || v === '') return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v.replace(/[\s,]/g, ''));
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
});
|
||||
|
||||
/** Объявление в фиде. Обязательны только ID и ссылка, остальное — по возможности. */
|
||||
const FeedItem = z.object({
|
||||
id: z.union([z.string().min(1), z.number()]).transform(String),
|
||||
url: z.url(),
|
||||
title: text,
|
||||
description: text,
|
||||
price: num,
|
||||
currency: text,
|
||||
district: text,
|
||||
propertyType: text,
|
||||
rooms: num,
|
||||
floor: num,
|
||||
floorsTotal: num,
|
||||
area: num,
|
||||
address: text,
|
||||
ownerName: text,
|
||||
phone: text,
|
||||
dealType: z.enum(['sale', 'rent']).optional().nullable(),
|
||||
region: text,
|
||||
isAgency: z.boolean().optional().nullable(),
|
||||
publishedAt: z.iso.datetime({ offset: true }).optional().nullable(),
|
||||
photos: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
type FeedItem = z.infer<typeof FeedItem>;
|
||||
|
||||
const FeedPage = z.object({
|
||||
items: z.array(z.unknown()),
|
||||
/** Курсор следующей страницы; нет или null — страниц больше нет. */
|
||||
next: z.string().nullish(),
|
||||
});
|
||||
|
||||
const MAX_PAGES = 20;
|
||||
|
||||
/**
|
||||
* Источник объявлений — JSON-фид по HTTP (формат описан в docs/PARSER.md).
|
||||
* Подходит для официального API/фида list.am или провайдера данных: адаптер не ходит
|
||||
* на сайт list.am сам, а читает то, что отдаёт настроенный фид.
|
||||
*/
|
||||
export class FeedListingSource implements ListingSource {
|
||||
readonly name = 'feed';
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly token: string | undefined,
|
||||
private readonly fetchFn: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
async *fetchLatest(
|
||||
filter: ListingSourceFilter,
|
||||
report: (problem: string) => void,
|
||||
): AsyncIterable<SourcedListing> {
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const body = await this.request(filter, cursor);
|
||||
for (const [i, raw] of body.items.entries()) {
|
||||
const item = FeedItem.safeParse(raw);
|
||||
if (!item.success) {
|
||||
report(
|
||||
`Запись ${i + 1} на странице ${page + 1} пропущена: ${item.error.issues[0]?.message}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
yield toSourced(item.data);
|
||||
}
|
||||
if (!body.next) return;
|
||||
cursor = body.next;
|
||||
}
|
||||
}
|
||||
|
||||
private async request(filter: ListingSourceFilter, cursor?: string) {
|
||||
const url = new URL(this.url);
|
||||
url.searchParams.set('city', filter.city);
|
||||
url.searchParams.set('category', filter.category);
|
||||
url.searchParams.set('ownerOnly', String(filter.ownerOnly));
|
||||
if (cursor) url.searchParams.set('cursor', cursor);
|
||||
|
||||
const res = await this.fetchFn(url, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Фид ответил ${res.status} ${res.statusText}`.trim());
|
||||
const parsed = FeedPage.safeParse(await res.json());
|
||||
if (!parsed.success) throw new Error('Фид вернул данные не в ожидаемом формате');
|
||||
return parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
function toSourced(item: FeedItem): SourcedListing {
|
||||
const currency = item.currency?.toUpperCase();
|
||||
return {
|
||||
externalId: item.id,
|
||||
sourceUrl: item.url,
|
||||
title: item.title ?? `Объявление ${item.id}`,
|
||||
description: item.description,
|
||||
price: item.price,
|
||||
currency: (CURRENCIES as readonly string[]).includes(currency ?? '')
|
||||
? (currency as SourcedListing['currency'])
|
||||
: 'USD',
|
||||
district: item.district,
|
||||
propertyType: item.propertyType,
|
||||
rooms: item.rooms,
|
||||
floor: item.floor,
|
||||
floorsTotal: item.floorsTotal,
|
||||
area: item.area,
|
||||
address: item.address,
|
||||
ownerName: item.ownerName,
|
||||
phone: item.phone,
|
||||
dealType: item.dealType ?? undefined,
|
||||
region: item.region,
|
||||
isAgency: item.isAgency ?? false,
|
||||
publishedAt: item.publishedAt ? new Date(item.publishedAt) : undefined,
|
||||
photoUrls: item.photos,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SourcedListing } from './source.ts';
|
||||
import { LISTAM_CATEGORIES, ListAmListingSource } from './listam-source.ts';
|
||||
|
||||
const filter = { city: 'yerevan', category: 'apartments', ownerOnly: true };
|
||||
const noSleep = () => Promise.resolve();
|
||||
|
||||
const CATEGORY_PAGE = `
|
||||
## Недвижимость
|
||||
|
||||
Топ объявления
|
||||
|
||||
[ $1,600,000 Склад в Каракерте, 20,000 кв.м. Монолит, 20,000 кв.м. Каракерт](https://www.list.am/ru/item/24154105?ld_src=2)
|
||||
|
||||
Обычные объявления
|
||||
|
||||
[ $299,000 4-комн. квартира на ул. Налбандяна в центре, 155 кв.м., 3/9 этаж 4 ком., 155 кв.м., 3/9 этаж Кентрон](https://www.list.am/ru/item/14290392?ld_src=2)
|
||||
|
||||
[ 650,000֏в месяц 2-комн. квартира в новостройке на пр. Аршакуняц в центре, 56 кв.м., 2/18 этаж 2 ком., 56 кв.м., 2/18 этаж Недвижимость не проверена Агентство Кентрон](https://www.list.am/ru/item/23836325?ld_src=2)
|
||||
|
||||
[ 28,750,000֏ 2-комн. квартира в новостройке, 41-я улица в Мецаморе, 87 кв.м. 2 ком., 87 кв.м. Мецамор](https://www.list.am/ru/item/995117414?ld_src=2)
|
||||
|
||||
[ €90 Участок общего назначения в Зовуни, 3,100 кв.м. 3100 кв.м. Зовуни](https://www.list.am/ru/item/24005094?ld_src=2)
|
||||
`;
|
||||
|
||||
/** Страница объявления, как её отдаёт ридер с x-target-selector (снято с list.am в октябре 2026). */
|
||||
const itemPage = (address: string, description: string, extra = '') => `Title: 2-комн. квартира - Продажа квартир
|
||||
|
||||
URL Source: https://www.list.am/ru/item/14290392
|
||||
|
||||
Markdown Content:
|
||||
[${address}](https://www.list.am/ru/item/14290392)
|
||||
|
||||
${extra}
|
||||
|
||||
37 кв.м.
|
||||
|
||||
Общая площадь
|
||||
|
||||
3
|
||||
|
||||
Этаж
|
||||
|
||||
9
|
||||
|
||||
Этажей в доме
|
||||
|
||||
2.8 м
|
||||
|
||||
Высота потолков
|
||||
|
||||
4
|
||||
|
||||
Количество комнат
|
||||
|
||||
1
|
||||
|
||||
Количество санузлов
|
||||
|
||||
Регион
|
||||
|
||||
О здании
|
||||
|
||||
Тип здания
|
||||
|
||||
Монолит
|
||||
|
||||
Описание
|
||||
|
||||
${description}
|
||||
|
||||
Номер объявления 14290392 Размещено 08.10.2026 Обновлено 08.10.2026, 19:34
|
||||
|
||||
Links/Buttons:
|
||||
This page does not seem to contain any buttons/links.
|
||||
`;
|
||||
const ITEM_PAGE = itemPage('Nalbandyan street 5, Երևան', 'Светлая квартира в центре.\n\nТорг & ипотека');
|
||||
|
||||
function fakeFetch(pages: Record<string, string>) {
|
||||
return vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
for (const [needle, body] of Object.entries(pages)) {
|
||||
if (url.endsWith(needle)) return new Response(body, { status: 200 });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(source: ListAmListingSource, report = vi.fn()) {
|
||||
const items: SourcedListing[] = [];
|
||||
for await (const item of source.fetchLatest(filter, report)) items.push(item);
|
||||
return items;
|
||||
}
|
||||
|
||||
const source = (fetchFn: typeof fetch, fetchDetails = false) =>
|
||||
new ListAmListingSource({ fetchFn, fetchDetails, sleep: noSleep });
|
||||
|
||||
describe('ListAmListingSource', () => {
|
||||
it('parses cards: regular first, promoted last, canonical url, parsed fields', async () => {
|
||||
const items = await collect(source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' })));
|
||||
|
||||
// Мецамор и Зовуни — не Ереван, отфильтрованы; топ-склад — тоже. Агентство — ownerOnly.
|
||||
expect(items.map((i) => i.externalId)).toEqual(['14290392']);
|
||||
expect(items[0]).toMatchObject({
|
||||
sourceUrl: 'https://www.list.am/item/14290392',
|
||||
title: '4-комн. квартира на ул. Налбандяна в центре, 155 кв.м., 3/9 этаж 4 ком., 155 кв.м., 3/9 этаж',
|
||||
price: 299_000,
|
||||
currency: 'USD',
|
||||
district: 'Кентрон',
|
||||
propertyType: 'apartment',
|
||||
rooms: 4,
|
||||
floor: 3,
|
||||
floorsTotal: 9,
|
||||
area: 155,
|
||||
photoUrls: ['https://img.list.am/f/032/100081032.webp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('yields promoted cards after regular ones', async () => {
|
||||
const page = `
|
||||
Топ объявления
|
||||
|
||||
[ $50,000 1-комн. квартира, 30 кв.м. Арабкир](https://www.list.am/ru/item/111?ld_src=2)
|
||||
|
||||
Обычные объявления
|
||||
|
||||
[ $60,000 2-комн. квартира, 40 кв.м. Арабкир](https://www.list.am/ru/item/222?ld_src=2)
|
||||
`;
|
||||
const items = await collect(source(fakeFetch({ '/ru/category/60': page, '/ru/category/56': '' })));
|
||||
expect(items.map((i) => i.externalId)).toEqual(['222', '111']);
|
||||
});
|
||||
|
||||
it('keeps agency cards when ownerOnly is off', async () => {
|
||||
const items: SourcedListing[] = [];
|
||||
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' }));
|
||||
for await (const item of src.fetchLatest({ ...filter, ownerOnly: false }, vi.fn()))
|
||||
items.push(item);
|
||||
expect(items.map((i) => i.externalId)).toContain('23836325');
|
||||
const agency = items.find((i) => i.externalId === '23836325')!;
|
||||
expect(agency).toMatchObject({ price: 650_000, currency: 'AMD', rooms: 2, floor: 2, floorsTotal: 18 });
|
||||
expect(agency.title).not.toContain('Недвижимость не проверена');
|
||||
});
|
||||
|
||||
it('imports non-Yerevan cards when city filter is off', async () => {
|
||||
const items: SourcedListing[] = [];
|
||||
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' }));
|
||||
for await (const item of src.fetchLatest({ ...filter, city: 'armenia' }, vi.fn()))
|
||||
items.push(item);
|
||||
expect(items.map((i) => i.externalId)).toContain('24005094');
|
||||
expect(items.find((i) => i.externalId === '24005094')).toMatchObject({
|
||||
price: 90,
|
||||
currency: 'EUR',
|
||||
// Тип объекта задаёт категория list.am, а не текст карточки.
|
||||
propertyType: 'apartment',
|
||||
dealType: 'sale',
|
||||
});
|
||||
});
|
||||
|
||||
it('название города, оканчивающееся как район Еревана, районом не считается', async () => {
|
||||
const page = `Обычные объявления
|
||||
|
||||
[ $90,000 Дом на ул. Самвела Варданяна, 120 кв.м. 3 ком., 120 кв.м. Бюрегаван](https://www.list.am/ru/item/995000001?ld_src=2)
|
||||
|
||||
[ $95,000 Дом в Аване, 100 кв.м. 3 ком., 100 кв.м. Аван](https://www.list.am/ru/item/995000002?ld_src=2)
|
||||
`;
|
||||
const house = LISTAM_CATEGORIES.find((c) => c.propertyType === 'house' && c.dealType === 'sale')!;
|
||||
const cards = await source(fakeFetch({ [`/ru/category/${house.id}`]: page })).listPage(house);
|
||||
expect(cards[0]).toMatchObject({ region: 'Котайк', district: 'Бюрегаван' });
|
||||
expect(cards[1]).toMatchObject({ region: 'Ереван', district: 'Аван' });
|
||||
});
|
||||
|
||||
it('listPage: один запрос, тип сделки и метка агентства из карточки', async () => {
|
||||
const fetchFn = fakeFetch({ '/ru/category/56': CATEGORY_PAGE });
|
||||
const rent = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'rent')!;
|
||||
const cards = await source(fetchFn, true).listPage(rent);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(cards.every((c) => c.dealType === 'rent')).toBe(true);
|
||||
expect(cards.find((c) => c.externalId === '23836325')).toMatchObject({ isAgency: true, region: 'Ереван', district: 'Кентрон' });
|
||||
expect(cards.find((c) => c.externalId === '14290392')).toMatchObject({ isAgency: false });
|
||||
// Обычные объявления идут раньше «Топ».
|
||||
expect(cards.at(-1)!.externalId).toBe('24154105');
|
||||
});
|
||||
|
||||
it('enrich: вне Еревана населённый пункт и область берутся из адреса', async () => {
|
||||
const page = itemPage('41-я улица 5, Мецамор', 'Дом.', 'Агентство');
|
||||
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/995117414': page }), true);
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const card = (await src.listPage(sale)).find((c) => c.externalId === '995117414')!;
|
||||
// Область и населённый пункт известны уже по карточке — без запроса страницы объявления.
|
||||
expect(card).toMatchObject({ region: 'Армавир', district: 'Мецамор' });
|
||||
expect(card.isAgency).toBe(false);
|
||||
// Метка агентства бывает только на странице объявления — она тоже учитывается.
|
||||
expect(await src.enrich(card)).toMatchObject({ region: 'Армавир', district: 'Мецамор', address: '41-я улица 5, Мецамор', isAgency: true });
|
||||
});
|
||||
|
||||
it('enriches from the item page when fetchDetails is on', async () => {
|
||||
const items = await collect(
|
||||
source(
|
||||
fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '', '/ru/item/14290392': ITEM_PAGE }),
|
||||
true,
|
||||
),
|
||||
);
|
||||
expect(items[0]).toMatchObject({
|
||||
description: 'Светлая квартира в центре.\nТорг & ипотека',
|
||||
address: 'Nalbandyan street 5, Երևան',
|
||||
area: 37,
|
||||
floor: 3,
|
||||
floorsTotal: 9,
|
||||
rooms: 4,
|
||||
photoUrls: ['https://img.list.am/f/032/100081032.webp'],
|
||||
// «Размещено 08.10.2026» — начало этого дня по Еревану.
|
||||
publishedAt: new Date('2026-10-07T20:00:00Z'),
|
||||
isAgency: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('ридер заблокировал запросы без ключа — страницы категорий читаются с ключом', async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>().mockImplementation(async (_input, init) =>
|
||||
new Headers(init?.headers).get('authorization')
|
||||
? new Response(CATEGORY_PAGE, { status: 200 })
|
||||
: new Response('AuthenticationRequiredError: blocked from performing anonymous queries', { status: 401 }),
|
||||
);
|
||||
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' });
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
expect((await src.listPage(sale)).length).toBeGreaterThan(0);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// Следующий запрос сразу идёт с ключом, без лишнего отказа.
|
||||
expect((await src.listPage(sale)).length).toBeGreaterThan(0);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('publishedAt: дата из подвала страницы, отдельным запросом', async () => {
|
||||
const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026Обновлено 08.10.2026, 20:01\n';
|
||||
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': footer });
|
||||
const src = source(fetchFn, true);
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
|
||||
expect(await src.publishedAt(card)).toEqual(new Date('2026-10-04T20:00:00Z'));
|
||||
expect(new Headers(fetchFn.mock.calls.at(-1)![1]!.headers).get('x-target-selector')).toBe('.footer');
|
||||
expect(await source(fakeFetch({ '/ru/item/14290392': 'Markdown Content:\n' }), true).publishedAt(card)).toBeUndefined();
|
||||
await expect(source(fakeFetch({ '/ru/item/14290392': 'Just a moment...' }), true).publishedAt(card)).rejects.toThrow('Cloudflare');
|
||||
});
|
||||
|
||||
it('inspect с ключом: один запрос HTML — точное время размещения и все детали', async () => {
|
||||
const attr = (value: string, label: string) =>
|
||||
`<div class="at3"><p class="te13 text-primary">${value}</p><p class="te13 text-light-grey ellipsis">${label}</p></div>`;
|
||||
const html = `<title>Дом</title><div class="loc"><a href="">улица Маргаряна 17, Ереван</a></div>
|
||||
<div class="po79"><span class="ge4">Код 3Y</span><span class="ge4">Агентство</span></div>
|
||||
<div class="attr">${attr('77.3 кв.м.', 'Общая площадь')}${attr('2', 'Этаж')}${attr('4', 'Этажей в доме')}${attr('3', 'Количество комнат')}</div>
|
||||
<div class="gt">Описание</div><div class="body" itemprop="description">Первая строка<br>Вторая & третья</div>
|
||||
<div class="footer"><span>Номер объявления 14290392</span><span itemprop="datePosted" content="2026-10-08T15:38:04+00:00">Размещено 08.10.2026</span></div>`;
|
||||
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': html });
|
||||
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' });
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
|
||||
const headers = (n: number) => new Headers(fetchFn.mock.calls.at(n)![1]!.headers);
|
||||
// Большая страница категории читается без ключа: ключ оплачивается по объёму ответа.
|
||||
expect(headers(0).get('authorization')).toBeNull();
|
||||
|
||||
// Старое объявление: один маленький запрос подвала.
|
||||
const old = await src.inspect(card, () => false);
|
||||
expect(old.publishedAt).toEqual(new Date('2026-10-08T15:38:04Z'));
|
||||
expect(old.description).toBeUndefined();
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(headers(-1).get('x-target-selector')).toBe('.footer');
|
||||
expect(headers(-1).get('authorization')).toBe('Bearer jina_key');
|
||||
expect(headers(-1).get('x-return-format')).toBe('html');
|
||||
expect(headers(-1).get('x-engine')).toBe('browser');
|
||||
|
||||
// Свежее: подвал и блоки с деталями.
|
||||
const full = await src.inspect(card, () => true);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(4);
|
||||
expect(full).toMatchObject({
|
||||
publishedAt: new Date('2026-10-08T15:38:04Z'), address: 'улица Маргаряна 17, Ереван', area: 77.3, floor: 2,
|
||||
floorsTotal: 4, rooms: 3, description: 'Первая строка\nВторая & третья', isAgency: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('ключ отклонён (кончился баланс): объявление проверяется без ключа', async () => {
|
||||
const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026\n';
|
||||
const fetchFn = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (new Headers(init!.headers).get('authorization')) return new Response('no balance', { status: 402 });
|
||||
return new Response(String(url).includes('/category/') ? CATEGORY_PAGE : footer);
|
||||
});
|
||||
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' });
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
|
||||
expect((await src.inspect(card, () => false)).publishedAt).toEqual(new Date('2026-10-04T20:00:00Z'));
|
||||
const calls = fetchFn.mock.calls.length;
|
||||
// Дальше ключ час не используется: лишних отклонённых запросов нет.
|
||||
await src.inspect(card, () => false);
|
||||
expect(fetchFn.mock.calls.length).toBe(calls + 1);
|
||||
});
|
||||
|
||||
it('проверка «объявление ещё на сайте»: есть подвал — да; нет подвала и «не существует» — снято; иначе неизвестно', async () => {
|
||||
const reader = (pages: Record<string, { footer?: string; h1?: string }>) =>
|
||||
vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
const page = pages[String(url).split('/item/')[1]!] ?? {};
|
||||
const text = new Headers(init!.headers).get('x-target-selector') === '.footer' ? page.footer : page.h1;
|
||||
return text === undefined ? new Response('No content available', { status: 422 }) : new Response(text);
|
||||
});
|
||||
const src = new ListAmListingSource({
|
||||
sleep: noSleep,
|
||||
fetchFn: reader({
|
||||
'100': { footer: 'Номер объявления 100Размещено 04.10.2026', h1: 'Квартира' },
|
||||
'200': { h1: 'Данного объявления не существует' },
|
||||
'300': { h1: 'Квартира' },
|
||||
'400': { footer: 'Just a moment...' },
|
||||
}),
|
||||
});
|
||||
expect(await src.isAlive('100')).toBe(true);
|
||||
expect(await src.isAlive('200')).toBe(false);
|
||||
// Подвал не пришёл, но и страницы «не существует» нет: ридер ошибся — решать рано.
|
||||
expect(await src.isAlive('300')).toBeUndefined();
|
||||
expect(await src.isAlive('400')).toBeUndefined();
|
||||
expect(await src.isAlive('500')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('страница категории: при ответе «слишком много запросов» чтение повторяется с ключом', async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>().mockImplementation(async (_url, init) =>
|
||||
new Headers(init!.headers).get('authorization') ? new Response(CATEGORY_PAGE) : new Response('rate limit', { status: 429 }),
|
||||
);
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
expect((await new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' }).listPage(sale)).length).toBeGreaterThan(0);
|
||||
await expect(new ListAmListingSource({ fetchFn, sleep: noSleep }).listPage(sale)).rejects.toThrow('429');
|
||||
});
|
||||
|
||||
it('inspect без ключа: сначала дата, детали — только для свежего объявления', async () => {
|
||||
const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026\n';
|
||||
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': footer });
|
||||
const src = source(fetchFn, true);
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
|
||||
expect((await src.inspect(card, () => false)).publishedAt).toEqual(new Date('2026-10-04T20:00:00Z'));
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2); // список и подвал; за деталями старого объявления не ходим
|
||||
await src.inspect(card, () => true);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('страница без подвала с датой и страница с проверкой Cloudflare', async () => {
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const noFooter = ITEM_PAGE.replace(/Номер объявления.*\n/, '');
|
||||
const a = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': noFooter }), true);
|
||||
const card = (await a.listPage(sale)).find((c) => c.externalId === '14290392')!;
|
||||
expect(await a.details(card)).toMatchObject({ publishedAt: undefined, description: 'Светлая квартира в центре.\nТорг & ипотека' });
|
||||
const b = source(fakeFetch({ '/ru/item/14290392': '<title>Just a moment...</title>' }), true);
|
||||
await expect(b.details(card)).rejects.toThrow('Cloudflare');
|
||||
});
|
||||
|
||||
it('details() бросает ошибку, если страницу объявления прочитать не удалось', async () => {
|
||||
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE }), true);
|
||||
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
|
||||
const [card] = await src.listPage(sale);
|
||||
await expect(src.details(card!)).rejects.toThrow('Ридер ответил 404');
|
||||
});
|
||||
|
||||
it('параллельные запросы идут по одному с паузой: лимит ридера не превышается', async () => {
|
||||
const sleeps: number[] = [];
|
||||
const src = new ListAmListingSource({
|
||||
fetchFn: fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': CATEGORY_PAGE }),
|
||||
sleep: async (ms) => void sleeps.push(ms),
|
||||
});
|
||||
await Promise.all(LISTAM_CATEGORIES.slice(0, 2).map((c) => src.listPage(c)));
|
||||
// Первый запрос уходит сразу, второй ждёт свою очередь около 3 секунд.
|
||||
expect(sleeps).toHaveLength(1);
|
||||
expect(sleeps[0]).toBeGreaterThan(3000);
|
||||
});
|
||||
|
||||
it('keeps card data when the item page fails', async () => {
|
||||
const report = vi.fn();
|
||||
const items = await collect(
|
||||
source(
|
||||
fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' }),
|
||||
true,
|
||||
),
|
||||
report,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ externalId: '14290392', area: 155 });
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('paginates a category until an empty page', async () => {
|
||||
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' });
|
||||
await collect(source(fetchFn));
|
||||
const urls = fetchFn.mock.calls.map((c) => String(c[0]));
|
||||
expect(urls.some((u) => u.endsWith('/ru/category/60'))).toBe(true);
|
||||
expect(urls.some((u) => u.endsWith('/ru/category/60/2'))).toBe(true);
|
||||
expect(urls.some((u) => u.endsWith('/ru/category/60/3'))).toBe(false);
|
||||
});
|
||||
|
||||
it('fails on an unknown category', async () => {
|
||||
const src = source(fakeFetch({}));
|
||||
await expect(async () => {
|
||||
for await (const _ of src.fetchLatest({ ...filter, category: 'boats' }, vi.fn())) void _;
|
||||
}).rejects.toThrow('Неизвестная категория');
|
||||
});
|
||||
|
||||
it('accepts dashed category names like apartments-rent', async () => {
|
||||
const fetchFn = fakeFetch({ '/ru/category/56': CATEGORY_PAGE });
|
||||
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, fetchDetails: false });
|
||||
const items = [];
|
||||
for await (const item of src.fetchLatest({ ...filter, category: 'apartments-rent' }, vi.fn()))
|
||||
items.push(item);
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
const urls = fetchFn.mock.calls.map((c) => String(c[0]));
|
||||
expect(urls.some((u) => u.endsWith('/ru/category/56'))).toBe(true);
|
||||
expect(urls.some((u) => u.endsWith('/ru/category/60'))).toBe(false);
|
||||
});
|
||||
|
||||
it('fails the run when the reader errors', async () => {
|
||||
const src = source(fakeFetch({}));
|
||||
await expect(collect(src)).rejects.toThrow('Ридер ответил 404');
|
||||
});
|
||||
|
||||
it('страницы категорий читаются без ключа ридера', async () => {
|
||||
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' });
|
||||
await collect(new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key', fetchDetails: false }));
|
||||
for (const call of fetchFn.mock.calls) expect(new Headers(call[1]!.headers).get('authorization')).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('характеристики объявления', () => {
|
||||
it('удобства, правила проживания и остальные поля; отсутствующие удобства не попадают', async () => {
|
||||
const { parseFeatures } = await import('./listam-source.ts');
|
||||
const item = (main: string, sub?: string, off = false) =>
|
||||
`<div class="at3${off ? ' disabled' : ''}"><div class="attr-icon"></div><div class="bo67"><p class="te13 text-primary attr-value">${main}</p>${
|
||||
sub ? `<p class="te13 text-light-grey">${sub}</p>` : ''
|
||||
}</div></div>`;
|
||||
const html = [
|
||||
`<div class="attr g new bordered">${item('105 кв.м.', 'Общая площадь')}${item('1', 'Количество санузлов')}</div>`,
|
||||
`<div class="gt">Бытовая техника</div><div class="attr g new">${item('Холодильник')}${item('Плита', undefined, true)}${item('Стиральная машина')}</div>`,
|
||||
`<div class="gt">Удобства</div><div class="attr g new">${item('Мебель', 'С мебелью')}${item('Кондиционер', undefined, true)}${item('Интернет')}</div>`,
|
||||
`<div class="gt">Правила проживания</div><div class="attr g new">${item('Можно с детьми', 'Да')}${item('Можно с животными', 'По договоренности')}</div>`,
|
||||
`<div class="gt">Условия</div><div class="attr g new">${item('600,000 ֏', 'В месяц')}${item('Предоплата', '1 месяц')}${item('50%', 'Комиссия с арендатора')}</div>`,
|
||||
].join('');
|
||||
expect(parseFeatures(html)).toEqual({
|
||||
children: 'yes',
|
||||
pets: 'negotiable',
|
||||
amenities: [
|
||||
{ group: 'Бытовая техника', items: ['Холодильник', 'Стиральная машина'] },
|
||||
{ group: 'Удобства', items: ['Интернет'] },
|
||||
],
|
||||
attrs: [
|
||||
{ label: 'Количество санузлов', value: '1' },
|
||||
{ label: 'Мебель', value: 'С мебелью' },
|
||||
{ label: 'Можно с детьми', value: 'Да' },
|
||||
{ label: 'Можно с животными', value: 'По договоренности' },
|
||||
{ label: 'Предоплата', value: '1 месяц' },
|
||||
{ label: 'Комиссия с арендатора', value: '50%' },
|
||||
],
|
||||
});
|
||||
expect(parseFeatures('<div class="loc">адрес</div>')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('галерея объявления', () => {
|
||||
it('ссылки на все фото берутся из скрипта галереи, без повторов', async () => {
|
||||
const { parseGallery } = await import('./listam-source.ts');
|
||||
const html = `<img src="//img.list.am/f/571/1.webp"><script>recview(1,56);po114.init('#po114', {img:["//img.list.am/f/571/1.webp","//img.list.am/f/551/2.webp","//s.list.am/f/9/3.jpg","//img.list.am/f/571/1.webp"],w:740});</script>`;
|
||||
expect(parseGallery(html)).toEqual(['https://img.list.am/f/571/1.webp', 'https://img.list.am/f/551/2.webp', 'https://s.list.am/f/9/3.jpg']);
|
||||
expect(parseGallery('<div>нет галереи</div>')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,733 @@
|
||||
import { regionOfLocality, trailingLocality, type Allowance, type DealType, type ListingFeatures, type PropertyType } from '@lr/shared';
|
||||
import type {
|
||||
ListingSource,
|
||||
ListingSourceFilter,
|
||||
SourcedListing,
|
||||
} from './source.ts';
|
||||
|
||||
export interface ListAmSourceOptions {
|
||||
/** База ридера, через который читаются страницы list.am. */
|
||||
readerBase?: string;
|
||||
/** Ключ Jina Reader (бесплатный): без него лимит ~20 запросов/мин и чтение идёт медленнее. */
|
||||
apiKey?: string;
|
||||
/** Догружать страницу объявления (описание, адрес, обложка фото). По умолчанию true. */
|
||||
fetchDetails?: boolean;
|
||||
fetchFn?: typeof fetch;
|
||||
/** Пауза между запросами; в тестах подменяется. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const LISTAM = 'https://www.list.am';
|
||||
const READER = 'https://r.jina.ai/';
|
||||
/** За один запуск обходим не больше стольких страниц каждой категории. */
|
||||
const MAX_PAGES = 10;
|
||||
/** Анонимный лимит ридера ~20/мин; с ключом — на порядок больше. */
|
||||
const DELAY_ANON_MS = 3_100;
|
||||
const DELAY_KEY_MS = 600;
|
||||
/** Блоки страницы объявления: адрес, метка агентства, характеристики, заголовки разделов, описание, подвал с датой. */
|
||||
const ITEM_SELECTOR = '.loc, .po79, .attr, .gt, [itemprop=description], .footer';
|
||||
const ITEM_SELECTOR_HTML = '.loc, .po79, .gt, .attr, [itemprop=description]';
|
||||
|
||||
/** Категории list.am по типу объекта: ID взяты со страниц раздела «Недвижимость» (/ru/category/54). */
|
||||
const CATEGORY_IDS = {
|
||||
apartment: { sale: 60, rent: 56 },
|
||||
house: { sale: 1386, rent: 1377 },
|
||||
commercial: { sale: 199, rent: 59 },
|
||||
land: { sale: 55, rent: 270 },
|
||||
garage: { sale: 173, rent: 175 },
|
||||
} as const satisfies Record<PropertyType, Record<DealType, number>>;
|
||||
|
||||
export interface ListAmCategory {
|
||||
id: number;
|
||||
propertyType: PropertyType;
|
||||
dealType: DealType;
|
||||
}
|
||||
|
||||
/** Все категории list.am, которые умеет читать парсер. */
|
||||
export const LISTAM_CATEGORIES: ListAmCategory[] = Object.entries(CATEGORY_IDS).flatMap(
|
||||
([propertyType, ids]) =>
|
||||
(['sale', 'rent'] as const).map((dealType) => ({
|
||||
id: ids[dealType],
|
||||
propertyType: propertyType as PropertyType,
|
||||
dealType,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Имена наборов категорий для fetchLatest: `apartments`, `apartments-sale`, `houses`, … */
|
||||
const PLURAL: Record<PropertyType, string> = {
|
||||
apartment: 'apartments', house: 'houses', commercial: 'commercial', land: 'land', garage: 'garages',
|
||||
};
|
||||
const CATEGORIES: Record<string, ListAmCategory[]> = {};
|
||||
for (const c of LISTAM_CATEGORIES) {
|
||||
const name = PLURAL[c.propertyType];
|
||||
(CATEGORIES[name] ??= []).push(c);
|
||||
CATEGORIES[`${name}-${c.dealType}`] = [c];
|
||||
}
|
||||
|
||||
/** Районы Еревана, как их пишет list.am → название в справочнике CRM. */
|
||||
const DISTRICT_ALIASES: Record<string, string> = {
|
||||
арабкир: 'Арабкир',
|
||||
ачапняк: 'Аджапняк',
|
||||
аджапняк: 'Аджапняк',
|
||||
аван: 'Аван',
|
||||
давташен: 'Давташен',
|
||||
эребуни: 'Эребуни',
|
||||
'канакер зейтун': 'Канакер-Зейтун',
|
||||
кентрон: 'Кентрон',
|
||||
'малатия себастия': 'Малатия-Себастия',
|
||||
'нор норк': 'Нор-Норк',
|
||||
'норк мараш': 'Норк-Мараш',
|
||||
нубарашен: 'Нубарашен',
|
||||
шенгавит: 'Шенгавит',
|
||||
};
|
||||
const DISTRICT_KEYS = Object.keys(DISTRICT_ALIASES).sort((a, b) => b.length - a.length);
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, SourcedListing['currency']> = {
|
||||
$: 'USD',
|
||||
'€': 'EUR',
|
||||
'₽': 'RUB',
|
||||
'֏': 'AMD',
|
||||
};
|
||||
|
||||
/** Служебные плашки внутри текста карточки — в название не входят. */
|
||||
const BADGE_PHRASES = ['Недвижимость не проверена', 'Проверено по кадастру', 'Срочно!', 'Эксклюзив'];
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
text: string;
|
||||
thumbUrl?: string;
|
||||
agency: boolean;
|
||||
}
|
||||
|
||||
const normalize = (v: string) =>
|
||||
v
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, ' ');
|
||||
|
||||
/** Ключи категорий нормализованы так же, как ввод пользователя («apartments-rent» → «apartments rent»). */
|
||||
const CATEGORY_LOOKUP = new Map(
|
||||
Object.entries(CATEGORIES).map(([key, ids]) => [normalize(key), ids] as const),
|
||||
);
|
||||
|
||||
/**
|
||||
* Источник объявлений — list.am, читаемый через публичный ридер (по умолчанию Jina Reader).
|
||||
* Сам адаптер на list.am не ходит и никакой защиты не обходит: читает markdown-снимок
|
||||
* страницы, который отдаёт ридер (docs/PARSER.md).
|
||||
*
|
||||
* Ограничения источника: телефон на list.am открывается отдельным запросом по нажатию
|
||||
* и не импортируется. Карточка всегда ведёт на оригинал по sourceUrl.
|
||||
*/
|
||||
export class ListAmListingSource implements ListingSource {
|
||||
readonly name = 'list.am';
|
||||
|
||||
private readonly readerBase: string;
|
||||
private apiKey?: string;
|
||||
private readonly defaultKey?: string;
|
||||
private readonly fetchDetails: boolean;
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
|
||||
constructor(options: ListAmSourceOptions = {}) {
|
||||
this.readerBase = options.readerBase ?? READER;
|
||||
this.apiKey = this.defaultKey = options.apiKey;
|
||||
this.fetchDetails = options.fetchDetails ?? true;
|
||||
this.fetchFn = options.fetchFn ?? fetch;
|
||||
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ ридера из настроек админки: строка — использовать этот ключ, undefined — ключ из
|
||||
* окружения сервера, null — работать без ключа.
|
||||
*/
|
||||
configure(apiKey: string | null | undefined) {
|
||||
const next = apiKey === null ? undefined : apiKey || this.defaultKey;
|
||||
// У нового ключа свой баланс: прошлый отказ к нему не относится.
|
||||
if (next !== this.apiKey) this.keyOffUntil = 0;
|
||||
this.apiKey = next;
|
||||
}
|
||||
|
||||
async *fetchLatest(
|
||||
filter: ListingSourceFilter,
|
||||
report: (problem: string) => void,
|
||||
): AsyncIterable<SourcedListing> {
|
||||
const categories = CATEGORY_LOOKUP.get(normalize(filter.category));
|
||||
if (!categories) {
|
||||
throw new Error(
|
||||
`Неизвестная категория «${filter.category}». Допустимые: ${Object.keys(CATEGORIES).join(', ')}`,
|
||||
);
|
||||
}
|
||||
const yerevanOnly = ['yerevan', 'ереван'].includes(normalize(filter.city));
|
||||
|
||||
for (const category of categories) {
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
let cards: ListAmCard[];
|
||||
try {
|
||||
cards = await this.listPage(category, page);
|
||||
} catch (error) {
|
||||
// Ошибка первой страницы — сбой источника; дальнейших страниц просто нет.
|
||||
if (page === 1) throw error;
|
||||
report(
|
||||
`Категория ${category.id}, страница ${page}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (cards.length === 0) break;
|
||||
for (const card of cards) {
|
||||
if (filter.ownerOnly && card.isAgency) continue;
|
||||
// Городской фильтр: Ереван — только карточки с районом Еревана.
|
||||
if (yerevanOnly && card.region !== 'Ереван') continue;
|
||||
yield await this.enrich(card, report);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточки одной страницы категории — один запрос к ридеру, без захода в объявления.
|
||||
* Сначала обычные объявления (от новых к старым), потом «Топ»: старые топ-дубли в начале
|
||||
* выдачи иначе остановили бы обход раньше времени.
|
||||
*/
|
||||
async listPage(category: ListAmCategory, page = 1): Promise<ListAmCard[]> {
|
||||
// Страницы категорий читаем без кэша ридера, чтобы не терять свежесть.
|
||||
const markdown = await this.read(`${LISTAM}/ru/category/${category.id}${page > 1 ? `/${page}` : ''}`, { fresh: true });
|
||||
const { regular, promoted } = parseCards(markdown);
|
||||
return [...regular.map((card) => ({ card, promoted: false })), ...promoted.map((card) => ({ card, promoted: true }))].map(({ card, promoted }) => {
|
||||
const parsed = parseCard(card);
|
||||
const districtKey = parsed.district ? normalize(parsed.district) : undefined;
|
||||
const inYerevan = Boolean(districtKey && DISTRICT_ALIASES[districtKey]);
|
||||
if (inYerevan) parsed.district = DISTRICT_ALIASES[districtKey!];
|
||||
const sourced = toSourced(card, parsed, {}, category, inYerevan);
|
||||
if (!inYerevan) {
|
||||
// Вне Еревана карточка заканчивается названием города или села: область — по справочнику.
|
||||
const place = trailingLocality(card.text.replace(/\s*Агентство\s*$/i, ''));
|
||||
if (place) Object.assign(sourced, { region: place.region, district: place.locality });
|
||||
}
|
||||
return promoted ? { ...sourced, promoted } : sourced;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Дата размещения объявления — из подвала страницы («Номер объявления … Размещено ДД.ММ.ГГГГ»).
|
||||
* Подвал запрашивается отдельно: в общем снимке страницы ридер его чаще всего отбрасывает
|
||||
* (замер 08.10.2026: 1 из 12 против 13 из 13 при отдельном запросе).
|
||||
* Возвращает undefined, если даты в ответе нет; бросает ошибку, если страницу прочитать не удалось.
|
||||
*/
|
||||
async publishedAt(card: ListAmCard): Promise<Date | undefined> {
|
||||
const markdown = await this.read(`${LISTAM}/ru/item/${card.externalId}`, { selector: '.footer' });
|
||||
if (/Just a moment/i.test(markdown)) throw new Error('страница закрыта проверкой Cloudflare');
|
||||
return parsePostedDate(markdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Догружает страницу объявления: описание, адрес и область, точные площадь/этаж/комнаты.
|
||||
* Ридеру передаётся список нужных блоков страницы (x-target-selector).
|
||||
* Бросает ошибку, если страницу прочитать не удалось.
|
||||
*/
|
||||
async details(card: ListAmCard): Promise<SourcedListing> {
|
||||
const markdown = await this.read(`${LISTAM}/ru/item/${card.externalId}`, { selector: ITEM_SELECTOR });
|
||||
if (/Just a moment/i.test(markdown)) throw new Error('страница закрыта проверкой Cloudflare');
|
||||
return applyDetails(card, parseDetails(markdown));
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка незнакомого объявления для конвейера: дата размещения и, если объявление свежее, детали.
|
||||
* - с ключом ридера — HTML-вырезки страницы: подвал с точным временем размещения и, для
|
||||
* свежего объявления, блоки с деталями;
|
||||
* - без ключа — запрос подвала с датой (с точностью до дня) и, только для свежего, запрос деталей.
|
||||
* `publishedAt` в ответе пуст, если даты на странице не оказалось. Бросает ошибку, если страницу
|
||||
* прочитать не удалось.
|
||||
*/
|
||||
async inspect(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise<SourcedListing> {
|
||||
if (this.apiKey && Date.now() >= this.keyOffUntil) {
|
||||
try {
|
||||
return await this.inspectWithKey(card, isFresh);
|
||||
} catch (error) {
|
||||
// Ключ отклонён или кончился баланс: час работаем без ключа (медленнее, дата без времени).
|
||||
if (!/Ридер ответил 40[123] /.test(error instanceof Error ? error.message : '')) throw error;
|
||||
this.keyOffUntil = Date.now() + 3_600_000;
|
||||
}
|
||||
}
|
||||
const publishedAt = await this.publishedAt(card);
|
||||
if (!publishedAt || !isFresh(publishedAt)) return { ...card, publishedAt };
|
||||
// Детали не прочитались — данных из списка достаточно для лида.
|
||||
const full = await this.details(card).catch(() => card);
|
||||
return { ...full, publishedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли объявление на сайте. У снятого объявления нет подвала с номером (ридер отвечает 422),
|
||||
* а заголовок страницы — «Данного объявления не существует» (замер 08.10.2026).
|
||||
* undefined — понять не удалось: спросим в следующий раз.
|
||||
*/
|
||||
async isAlive(externalId: string): Promise<boolean | undefined> {
|
||||
const url = `${LISTAM}/ru/item/${externalId}`;
|
||||
try {
|
||||
// Без обхода кеша ридера: с ним выборка подвала у ридера ломается (422), а суточной проверке
|
||||
// снимок часовой давности не мешает.
|
||||
const footer = await this.read(url, { selector: '.footer' });
|
||||
if (/Just a moment/i.test(footer)) return undefined;
|
||||
if (footer.includes(externalId)) return true;
|
||||
} catch (error) {
|
||||
if (!/Ридер ответил 422 /.test(error instanceof Error ? error.message : '')) return undefined;
|
||||
}
|
||||
const title = await this.read(url, { selector: 'h1' }).catch(() => '');
|
||||
return /объявления не существует/i.test(title) ? false : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Все фото объявления и его характеристики. Полный список есть только в скрипте галереи на странице, поэтому страница
|
||||
* читается целиком — это дороже обычного чтения, и вызывается только для объектов, которые агент
|
||||
* показывает клиенту.
|
||||
*/
|
||||
async gallery(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }> {
|
||||
const html = await this.read(`${LISTAM}/ru/item/${externalId}`, { html: true, keyed: true });
|
||||
if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare');
|
||||
// Снятое объявление list.am перенаправляет на главную: на ней нет номера объявления (замер 15.10.2026).
|
||||
if (!html.includes(externalId)) return { photos: [], removed: true };
|
||||
// Страница уже прочитана целиком — заодно берём характеристики (у старых объектов их в базе нет)
|
||||
// и описание: у объявлений, найденных без ключа ридера, его может не быть.
|
||||
return { photos: parseGallery(html), features: parseFeatures(html), description: parseDetailsHtml(html).description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Объявление целиком по его номеру — для объекта, который агент добавляет ссылкой. Страница
|
||||
* читается так же, как для галереи. null — объявления на сайте нет.
|
||||
*/
|
||||
async item(externalId: string): Promise<SourcedListing | null> {
|
||||
const html = await this.read(`${LISTAM}/ru/item/${externalId}`, { html: true, keyed: true });
|
||||
if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare');
|
||||
return parseItemPage(html, externalId);
|
||||
}
|
||||
|
||||
/** До этого момента ключ ридера не используется: его отклонили. */
|
||||
private keyOffUntil = 0;
|
||||
|
||||
/** До этого момента запросы без ключа не делаются: ридер заблокировал их для нашего IP. */
|
||||
private anonOffUntil = 0;
|
||||
|
||||
private async inspectWithKey(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise<SourcedListing> {
|
||||
const url = `${LISTAM}/ru/item/${card.externalId}`;
|
||||
const check = (html: string) => {
|
||||
if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare');
|
||||
return html;
|
||||
};
|
||||
// Сначала только подвал (ответ в несколько строк): у «поднятого» старого объявления
|
||||
// остальное читать незачем.
|
||||
const footer = check(await this.read(url, { html: true, selector: '.footer', keyed: true }));
|
||||
const publishedAt = parseDetailsHtml(footer).publishedAt;
|
||||
if (!publishedAt || !isFresh(publishedAt)) return { ...card, publishedAt };
|
||||
const html = await this.read(url, { html: true, selector: ITEM_SELECTOR_HTML, keyed: true }).then(check, () => '');
|
||||
return { ...applyDetails(card, parseDetailsHtml(html)), publishedAt };
|
||||
}
|
||||
|
||||
/** То же, что details(), но при ошибке возвращает карточку как есть и сообщает о проблеме. */
|
||||
async enrich(card: ListAmCard, report: (problem: string) => void = () => {}): Promise<SourcedListing> {
|
||||
if (!this.fetchDetails) return card;
|
||||
try {
|
||||
return await this.details(card);
|
||||
} catch (error) {
|
||||
report(
|
||||
`${LISTAM}/item/${card.externalId}: карточка создана без деталей (${error instanceof Error ? error.message : String(error)})`,
|
||||
);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Момент, раньше которого нельзя начинать следующий запрос. Очередей две: запросы без ключа
|
||||
* (лимит ридера ~20 в минуту) и запросы с ключом (лимит на порядок выше).
|
||||
*/
|
||||
private nextSlot = { anon: 0, keyed: 0 };
|
||||
|
||||
/**
|
||||
* Читает страницу через ридер. Запросы одной очереди идут по одному с паузой между ними, даже
|
||||
* если их запросили параллельно, — иначе упрёмся в лимит ридера.
|
||||
*
|
||||
* Ключ ридера оплачивается по объёму ответа, поэтому с ключом (`keyed`) читаются только
|
||||
* маленькие вырезки страниц объявлений. Большие страницы категорий читаются без ключа;
|
||||
* ключ для них используется, только если ридер ответил «слишком много запросов».
|
||||
*/
|
||||
private async read(
|
||||
url: string,
|
||||
opts: { fresh?: boolean; selector?: string; html?: boolean; keyed?: boolean } = {},
|
||||
): Promise<string> {
|
||||
// Анонимные запросы с нашего IP ридер может заблокировать — тогда на время читаем всё с ключом.
|
||||
const keyed = Boolean((opts.keyed || Date.now() < this.anonOffUntil) && this.apiKey);
|
||||
const lane = keyed ? 'keyed' : 'anon';
|
||||
const now = Date.now();
|
||||
const startAt = Math.max(now, this.nextSlot[lane]);
|
||||
this.nextSlot[lane] = startAt + (keyed ? DELAY_KEY_MS : DELAY_ANON_MS);
|
||||
if (startAt > now) await this.sleep(startAt - now);
|
||||
const res = await this.fetchFn(this.readerBase + url, {
|
||||
headers: {
|
||||
accept: 'text/plain',
|
||||
...(keyed ? { authorization: `Bearer ${this.apiKey}` } : {}),
|
||||
...(opts.fresh ? { 'x-no-cache': 'true' } : {}),
|
||||
...(opts.selector ? { 'x-target-selector': opts.selector } : {}),
|
||||
// Готовый HTML страницы после проверки Cloudflare ридер отдаёт только с ключом.
|
||||
...(opts.html ? { 'x-return-format': 'html', 'x-engine': 'browser', 'x-no-cache': 'true' } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.status === 401 && !keyed && this.apiKey) {
|
||||
// «Blocked from anonymous queries due to bad IP reputation» (22.10.2026): без ключа ридер не пускает.
|
||||
this.anonOffUntil = Date.now() + 3_600_000;
|
||||
return this.read(url, { ...opts, keyed: true });
|
||||
}
|
||||
if (res.status === 429 && !keyed && this.apiKey) return this.read(url, { ...opts, keyed: true });
|
||||
if (!res.ok) throw new Error(`Ридер ответил ${res.status} на ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
}
|
||||
|
||||
/** Карточки страницы категории: «Топ объявления» идёт блоком перед «Обычные объявления». */
|
||||
function parseCards(markdown: string): { regular: Card[]; promoted: Card[] } {
|
||||
const cards: { marker: 'top' | 'regular' | undefined; card: Card }[] = [];
|
||||
let marker: 'top' | 'regular' | undefined;
|
||||
for (const line of markdown.split('\n')) {
|
||||
if (line.includes('Топ объявления')) marker = 'top';
|
||||
else if (line.includes('Обычные объявления')) marker = 'regular';
|
||||
const card = matchCard(line);
|
||||
if (card) cards.push({ marker, card });
|
||||
}
|
||||
return {
|
||||
regular: cards.filter((c) => c.marker === 'regular').map((c) => c.card),
|
||||
promoted: cards.filter((c) => c.marker !== 'regular').map((c) => c.card),
|
||||
};
|
||||
}
|
||||
|
||||
const CARD_RE =
|
||||
/\[!\[Image[^\]]*\]\((https:\/\/img\.list\.am\/[^)\s]+)\)\s*(.+?)\]\(https:\/\/www\.list\.am\/(?:ru\/)?item\/(\d+)[^)\s]*\)/;
|
||||
|
||||
function matchCard(line: string): Card | null {
|
||||
const m = CARD_RE.exec(line);
|
||||
if (!m) return null;
|
||||
const text = m[2]!.replace(/!\[Image[^\]]*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return { id: m[3]!, text, thumbUrl: m[1], agency: /Агентство/.test(text) };
|
||||
}
|
||||
|
||||
interface ParsedCard {
|
||||
price?: number;
|
||||
currency: SourcedListing['currency'];
|
||||
title: string;
|
||||
rooms?: number;
|
||||
floor?: number;
|
||||
floorsTotal?: number;
|
||||
area?: number;
|
||||
district?: string;
|
||||
propertyType?: string;
|
||||
}
|
||||
|
||||
function parseCard(card: Card): ParsedCard {
|
||||
let rest = card.text;
|
||||
|
||||
let currency: SourcedListing['currency'] = 'AMD';
|
||||
let price: number | undefined;
|
||||
const priceM = /^(?:([$€₽])\s*)?([\d][\d\s,.]*?)\s*(֏|₽)?\s*(?:в\s*месяц)?\s+/.exec(rest);
|
||||
if (priceM && priceM[2]!.replace(/\D/g, '').length > 0) {
|
||||
const symbol = priceM[1] ?? priceM[3];
|
||||
if (symbol) currency = CURRENCY_SYMBOLS[symbol]!;
|
||||
const n = Number(priceM[2]!.replace(/[\s,]/g, '').replace(',', '.'));
|
||||
if (Number.isFinite(n) && n > 0) price = n;
|
||||
rest = rest.slice(priceM[0].length);
|
||||
}
|
||||
|
||||
const rooms = numberMatch(rest, /(\d{1,2})\s*[-–]?\s*комн\.|(\d{1,2})\s*ком\./);
|
||||
const floorM = /(\d{1,3})\s*\/\s*(\d{1,3})\s*этаж/.exec(rest);
|
||||
const area = numberMatch(rest, /([\d]+(?:[.,]\d+)?)\s*кв\.?\s*м/);
|
||||
|
||||
let title = rest;
|
||||
for (const phrase of BADGE_PHRASES) title = title.replaceAll(phrase, ' ');
|
||||
// Район — последнее слово/словосочетание карточки (после плашки «Агентство» или без неё).
|
||||
let district: string | undefined;
|
||||
for (const key of DISTRICT_KEYS) {
|
||||
// Район — отдельное слово в конце: «Бюрегаван» не должен распознаваться как «Аван».
|
||||
const tail = new RegExp(`(?:^|\\s)${key.replace(/ /g, '[\\s_-]+')}\\s*$`, 'i');
|
||||
if (tail.test(title)) {
|
||||
district = key;
|
||||
title = title.replace(tail, '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
title = title
|
||||
.replace(/\s*агентство\s*$/i, '')
|
||||
.replace(/[ ,.;-]+$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return {
|
||||
price,
|
||||
currency,
|
||||
title: title || `Объявление ${card.id}`,
|
||||
rooms,
|
||||
floor: floorM ? Number(floorM[1]) : undefined,
|
||||
floorsTotal: floorM ? Number(floorM[2]) : undefined,
|
||||
area,
|
||||
district,
|
||||
propertyType: guessPropertyType(rest),
|
||||
};
|
||||
}
|
||||
|
||||
function numberMatch(text: string, re: RegExp): number | undefined {
|
||||
const m = re.exec(text);
|
||||
if (!m) return undefined;
|
||||
const raw = m[1] ?? m[2];
|
||||
const n = Number(raw?.replace(',', '.'));
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function guessPropertyType(text: string): string | undefined {
|
||||
const t = text.toLowerCase();
|
||||
if (/квартир/.test(t)) return 'apartment';
|
||||
if (/(?:^|[^а-яё])дом(?:[^а-яё]|$)|коттедж/.test(t)) return 'house';
|
||||
if (/офис|помещени|коммерч|склад|торгов/.test(t)) return 'commercial';
|
||||
if (/участок|земл/.test(t)) return 'land';
|
||||
if (/гараж|паркинг|машиномест/.test(t)) return 'garage';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface ItemDetails {
|
||||
description?: string;
|
||||
address?: string;
|
||||
rooms?: number;
|
||||
floor?: number;
|
||||
floorsTotal?: number;
|
||||
area?: number;
|
||||
publishedAt?: Date;
|
||||
isAgency?: boolean;
|
||||
features?: ListingFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Детали со страницы объявления. Характеристики идут парами «значение, подпись»; описание —
|
||||
* между заголовком «Описание» и подвалом «Номер объявления … Размещено ДД.ММ.ГГГГ».
|
||||
*/
|
||||
function parseDetails(markdown: string): ItemDetails {
|
||||
const details: ItemDetails = {};
|
||||
const text = markdown.replace(/\r/g, '');
|
||||
|
||||
const address = /^\[([^\]\n]+)\]\(https:\/\/www\.list\.am\/[^)\s]*item\/\d+[^)\s]*\)\s*$/m.exec(text);
|
||||
if (address && !/скрыт/i.test(address[1]!)) details.address = address[1]!.trim().slice(0, 300);
|
||||
|
||||
details.rooms = numberMatch(text, /(\d{1,2})\n+Количество комнат/);
|
||||
details.floor = numberMatch(text, /(-?\d{1,3})\n+Этаж\n/);
|
||||
details.floorsTotal = numberMatch(text, /(\d{1,3})\n+Этажей в доме/);
|
||||
details.area = numberMatch(text, /([\d]+(?:[.,]\d+)?)\s*кв\.?\s*м\.?\n+Общая площадь/);
|
||||
|
||||
const description = /\nОписание\n+([\s\S]*?)(?:\n+Номер объявления|\n+Links\/Buttons:|$)/.exec(text);
|
||||
if (description) {
|
||||
const body = description[1]!
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (body) details.description = body.slice(0, 10_000);
|
||||
}
|
||||
|
||||
details.publishedAt = parsePostedDate(text);
|
||||
|
||||
if (/^Агентство\s*$/m.test(text.split('\nОписание\n')[0]!)) details.isAgency = true;
|
||||
return details;
|
||||
}
|
||||
|
||||
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
||||
const decode = (v: string) =>
|
||||
v
|
||||
.replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(parseInt(n, 16)))
|
||||
.replace(/&([a-z]+);/g, (m, name: string) => ENTITIES[name] ?? m);
|
||||
|
||||
/** Детали из HTML страницы объявления: характеристики идут парами «значение, подпись». */
|
||||
function parseDetailsHtml(html: string): ItemDetails {
|
||||
const details: ItemDetails = {};
|
||||
|
||||
const attrs = new Map<string, string>();
|
||||
for (const m of html.matchAll(/>([^<>]{1,60})<\/p><p class="[^"]*text-light-grey[^"]*">([^<>]{1,60})<\/p>/g)) {
|
||||
attrs.set(decode(m[2]!).trim(), decode(m[1]!).trim());
|
||||
}
|
||||
const attr = (label: string) => {
|
||||
const n = Number(attrs.get(label)?.replace(',', '.').match(/-?[\d.]+/)?.[0]);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
details.rooms = attr('Количество комнат');
|
||||
details.floor = attr('Этаж');
|
||||
details.floorsTotal = attr('Этажей в доме');
|
||||
// У домов «Общая площадь» — площадь дома; площадь участка идёт отдельным полем.
|
||||
details.area = attr('Общая площадь') ?? attr('Площадь дома');
|
||||
|
||||
const address =
|
||||
/class="post-location-title">(?:<span[^>]*><\/span>)?<p[^>]*>([^<]+)<\/p>/.exec(html) ??
|
||||
/class="loc"><a[^>]*>([^<]+)<\/a>/.exec(html);
|
||||
if (address && !/скрыт/i.test(address[1]!)) details.address = decode(address[1]!).trim().slice(0, 300);
|
||||
|
||||
const description = /itemprop="description"[^>]*>([\s\S]*?)<\/div>/.exec(html);
|
||||
if (description) {
|
||||
const text = decode(description[1]!.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''))
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (text) details.description = text.slice(0, 10_000);
|
||||
}
|
||||
|
||||
// Точное время размещения есть только в разметке: <span itemprop="datePosted" content="…">.
|
||||
const posted = /itemprop="datePosted" content="([^"]+)"/.exec(html);
|
||||
if (posted) {
|
||||
const d = new Date(posted[1]!);
|
||||
if (!Number.isNaN(d.getTime())) details.publishedAt = d;
|
||||
}
|
||||
|
||||
if (/<span class="ge4">Агентство<\/span>/.test(html)) details.isAgency = true;
|
||||
details.features = parseFeatures(html);
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Объявление со страницы list.am целиком: категория (тип сделки и объекта) — из разметки страницы,
|
||||
* название — из заголовка, цена — из микроразметки, остальное — как в деталях. Район Еревана на
|
||||
* странице отдельно не указан: ищем его название в заголовке и адресе («в Ачапняке»).
|
||||
* null — номера объявления на странице нет: его сняли, и list.am перенаправил на главную.
|
||||
*/
|
||||
export function parseItemPage(html: string, externalId: string): SourcedListing | null {
|
||||
if (!html.includes(externalId)) return null;
|
||||
const categoryId = Number(/"category_id":(\d+)/.exec(html)?.[1] ?? [...html.matchAll(/href="\/(?:ru\/)?category\/(\d+)"/g)].at(-1)?.[1]);
|
||||
const category = LISTAM_CATEGORIES.find((c) => c.id === categoryId);
|
||||
const title = decode(/<h1[^>]*itemprop="name"[^>]*>([^<]+)<\/h1>/.exec(html)?.[1] ?? '').replace(/\s+/g, ' ').trim();
|
||||
const price = Number(/itemprop="price" content="([\d.]+)"/.exec(html)?.[1]);
|
||||
const currency = /itemprop="priceCurrency" content="([A-Z]{3})"/.exec(html)?.[1];
|
||||
const details = parseDetailsHtml(html);
|
||||
const text = `${title} ${details.address ?? ''}`.toLowerCase();
|
||||
// «в центре» в заголовках list.am — это Кентрон.
|
||||
const district =
|
||||
DISTRICT_KEYS.find((key) => new RegExp(`(?:^|[\\s,])${key.replace(/ /g, '[\\s_-]+')}`, 'i').test(text)) ??
|
||||
(/(?:^|\s)в центре(?:[\s,.]|$)/.test(text) && /ереван/.test(text) ? 'кентрон' : undefined);
|
||||
const base: SourcedListing = {
|
||||
externalId,
|
||||
sourceUrl: `${LISTAM}/item/${externalId}`,
|
||||
title: (title || `Объявление ${externalId}`).slice(0, 200),
|
||||
dealType: category?.dealType,
|
||||
propertyType: category?.propertyType ?? guessPropertyType(title),
|
||||
isAgency: false,
|
||||
price: Number.isFinite(price) && price > 0 ? price : undefined,
|
||||
currency: currency && Object.values(CURRENCY_SYMBOLS).includes(currency as SourcedListing['currency']) ? (currency as SourcedListing['currency']) : 'AMD',
|
||||
rooms: numberMatch(title, /(\d{1,2})\s*[-–]?\s*комн\./),
|
||||
region: district ? 'Ереван' : undefined,
|
||||
district: district ? DISTRICT_ALIASES[district] : undefined,
|
||||
photoUrls: parseGallery(html),
|
||||
};
|
||||
return applyDetails(base, details);
|
||||
}
|
||||
|
||||
/** Ссылки на фото из скрипта галереи: po114.init('#po114', {img:["//img.list.am/f/…", …]}). */
|
||||
export function parseGallery(html: string): string[] {
|
||||
const list = /po\d+\.init\([^)]*?\{\s*img:\s*(\[[^\]]*\])/.exec(html)?.[1] ?? '';
|
||||
const urls = [...list.matchAll(/["'](\/\/(?:img|s)\.list\.am\/f\/\d+\/\d+\.(?:webp|jpe?g|png))["']/g)].map((m) => `https:${m[1]}`);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
/** Уже разобраны отдельными полями либо дублируют цену — в список характеристик не идут. */
|
||||
const SKIP_LABELS = new Set(['Общая площадь', 'Площадь дома', 'Этаж', 'Этажей в доме', 'Количество комнат', 'В месяц', 'В сутки', 'Цена']);
|
||||
const ALLOWANCE: Record<string, Allowance> = { да: 'yes', нет: 'no', 'по договоренности': 'negotiable', 'по договорённости': 'negotiable' };
|
||||
|
||||
/**
|
||||
* Характеристики объявления. Страница состоит из блоков: первый (без заголовка) — «значение, подпись»;
|
||||
* дальше идут разделы с заголовком («Бытовая техника», «Правила проживания»), в них либо пары
|
||||
* «подпись, значение», либо отметки-удобства. Удобство, которого в квартире нет, помечено классом disabled.
|
||||
*/
|
||||
export function parseFeatures(html: string): ListingFeatures | undefined {
|
||||
const features: ListingFeatures = { amenities: [], attrs: [] };
|
||||
let group = '';
|
||||
let head = false;
|
||||
const token =
|
||||
/<div class="gt">([^<]{1,80})<\/div>|<div class="attr g new( bordered)?"|<div class="at3( disabled)?">[\s\S]*?<p class="[^"]*(?:attr-value|text-primary)[^"]*">([^<]{1,80})<\/p>(?:<p class="[^"]*text-light-grey[^"]*">([^<]{1,80})<\/p>)?/g;
|
||||
for (const m of html.matchAll(token)) {
|
||||
if (m[1] !== undefined) {
|
||||
group = decode(m[1]).trim();
|
||||
continue;
|
||||
}
|
||||
if (m[4] === undefined) {
|
||||
head = Boolean(m[2]);
|
||||
if (head) group = '';
|
||||
continue;
|
||||
}
|
||||
const main = decode(m[4]).trim();
|
||||
const sub = m[5] === undefined ? undefined : decode(m[5]).trim();
|
||||
if (sub === undefined) {
|
||||
// Отметка-удобство: учитываем только те, что в квартире есть.
|
||||
if (m[3] || !main) continue;
|
||||
const bucket = features.amenities.find((a) => a.group === group) ?? features.amenities[features.amenities.push({ group, items: [] }) - 1]!;
|
||||
bucket.items.push(main);
|
||||
continue;
|
||||
}
|
||||
// В первом блоке сверху значение, снизу подпись; в разделах — наоборот, кроме строк вида «50% — Комиссия».
|
||||
const valueFirst = head || (/\d/.test(main) && !/\d/.test(sub));
|
||||
const [label, value] = valueFirst ? [sub, main] : [main, sub];
|
||||
if (label === 'Можно с детьми') features.children = ALLOWANCE[value.toLowerCase()];
|
||||
else if (label === 'Можно с животными') features.pets = ALLOWANCE[value.toLowerCase()];
|
||||
if (!SKIP_LABELS.has(label)) features.attrs.push({ label, value });
|
||||
}
|
||||
return features.amenities.length || features.attrs.length ? features : undefined;
|
||||
}
|
||||
|
||||
/** «Размещено ДД.ММ.ГГГГ» → дата. Времени на странице нет: берём начало этого дня по Еревану (UTC+4). */
|
||||
function parsePostedDate(text: string): Date | undefined {
|
||||
const m = /Размещено\s*(\d{2})\.(\d{2})\.(\d{4})/.exec(text);
|
||||
if (!m) return undefined;
|
||||
return new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1])) - 4 * 3_600_000);
|
||||
}
|
||||
|
||||
/** Карточка со страницы категории: то же объявление, но без деталей со страницы объявления. */
|
||||
export type ListAmCard = SourcedListing;
|
||||
|
||||
function toSourced(
|
||||
card: Card,
|
||||
parsed: ParsedCard,
|
||||
details: ItemDetails,
|
||||
category: ListAmCategory,
|
||||
inYerevan: boolean,
|
||||
): SourcedListing {
|
||||
const base: SourcedListing = {
|
||||
externalId: card.id,
|
||||
sourceUrl: `${LISTAM}/item/${card.id}`,
|
||||
title: parsed.title.slice(0, 200),
|
||||
dealType: category.dealType,
|
||||
// Тип объекта задаёт категория; угадывание по тексту — запасной вариант.
|
||||
propertyType: category.propertyType ?? parsed.propertyType,
|
||||
isAgency: card.agency,
|
||||
price: parsed.price,
|
||||
currency: parsed.currency,
|
||||
region: inYerevan ? 'Ереван' : undefined,
|
||||
district: parsed.district,
|
||||
rooms: parsed.rooms,
|
||||
floor: parsed.floor,
|
||||
floorsTotal: parsed.floorsTotal,
|
||||
area: parsed.area,
|
||||
photoUrls: card.thumbUrl ? [card.thumbUrl.replace('/g/', '/f/')] : [],
|
||||
};
|
||||
return applyDetails(base, details);
|
||||
}
|
||||
|
||||
function applyDetails(card: SourcedListing, details: ItemDetails): SourcedListing {
|
||||
const out: SourcedListing = {
|
||||
...card,
|
||||
description: details.description ?? card.description,
|
||||
address: details.address ?? card.address,
|
||||
rooms: details.rooms ?? card.rooms,
|
||||
floor: details.floor ?? card.floor,
|
||||
floorsTotal: details.floorsTotal ?? card.floorsTotal,
|
||||
area: details.area ?? card.area,
|
||||
publishedAt: details.publishedAt ?? card.publishedAt,
|
||||
isAgency: card.isAgency || Boolean(details.isAgency),
|
||||
features: details.features ?? card.features,
|
||||
};
|
||||
// Вне Еревана район в карточке не распознаётся: населённый пункт берём из адреса
|
||||
// («улица Минска 30, Ереван» → последняя часть), область — по справочнику.
|
||||
if (!out.region && details.address) {
|
||||
const locality = details.address.split(',').at(-1)?.trim();
|
||||
const region = regionOfLocality(locality);
|
||||
if (region) out.region = region;
|
||||
if (locality && region !== 'Ереван') out.district = locality;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { settings } from '../db/schema.ts';
|
||||
import { LISTAM_CATEGORIES } from './listam-source.ts';
|
||||
|
||||
/** Настройки парсера, которые суперадмин меняет в админке без перезапуска сервиса. */
|
||||
export const ParserSettings = z.object({
|
||||
/** Выключенный парсер не ходит на list.am сам; кнопка «Запустить сейчас» работает всегда. */
|
||||
enabled: z.boolean().default(true),
|
||||
/**
|
||||
* Блок «Топ» и первый проход по категории: в базу идут объявления, размещённые не раньше чем столько дней назад.
|
||||
* Новые для обычного списка (опубликованные или поднятые) идут в базу всегда.
|
||||
*/
|
||||
freshDays: z.number().int().min(1).max(30).default(3),
|
||||
/** Пауза между проходами, секунд. */
|
||||
pauseSec: z.number().int().min(0).max(3600).default(5),
|
||||
/** Сколько страниц категории читать за проход, пока на них есть незнакомые объявления. */
|
||||
maxPages: z.number().int().min(1).max(10).default(3),
|
||||
/**
|
||||
* Разовый глубокий проход — догнать то, что вышло, пока парсер стоял: страницы читаются до maxPages
|
||||
* без остановки на уже известных, новые берутся только по дате (freshDays). После прохода выключается сам.
|
||||
*/
|
||||
deepPass: z.boolean().default(false),
|
||||
/** ID категорий list.am, которые читает парсер. */
|
||||
categories: z.array(z.number().int()).default(LISTAM_CATEGORIES.map((c) => c.id)),
|
||||
/** Ключ ридера Jina; пусто — берётся из окружения сервера (JINA_API_KEY). */
|
||||
jinaApiKey: z.string().trim().max(200).default(''),
|
||||
/** Использовать ключ: с ним быстрее и известно точное время размещения, но он платный. */
|
||||
useKey: z.boolean().default(true),
|
||||
/** Проверять, не сняты ли с сайта объявления, по которым есть лиды. */
|
||||
removalCheck: z.boolean().default(true),
|
||||
/** Сколько объявлений проверять за один заход (раз в 10 минут). */
|
||||
removalBatch: z.number().int().min(1).max(100).default(20),
|
||||
/** Как часто перепроверять одно объявление, часов. */
|
||||
recheckHours: z.number().int().min(1).max(720).default(24),
|
||||
});
|
||||
export type ParserSettings = z.infer<typeof ParserSettings>;
|
||||
|
||||
const KEY = 'parser';
|
||||
|
||||
export async function loadParserSettings(db: Db): Promise<ParserSettings> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, KEY));
|
||||
return ParserSettings.parse(row?.value ?? {});
|
||||
}
|
||||
|
||||
export async function saveParserSettings(db: Db, patch: Partial<ParserSettings>): Promise<ParserSettings> {
|
||||
const value = ParserSettings.parse({ ...(await loadParserSettings(db)), ...patch });
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: KEY, value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } });
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Currency, DealType, ListingFeatures } from '@lr/shared';
|
||||
|
||||
// Контракт источника объявлений. Модуль перенесён из прежней CRM (crmyerevan) и расширен
|
||||
// полями из ТЗ: тип сделки, метка «агентство», имя и телефон автора, регион, дата публикации.
|
||||
//
|
||||
// - ListAmListingSource — снимки страниц list.am через публичный ридер (Jina Reader);
|
||||
// сам сервис на list.am не ходит. Телефон автора этим путём недоступен.
|
||||
// - FeedListingSource — JSON-фид по LISTING_FEED_URL (официальный API или провайдер данных).
|
||||
|
||||
/** Объявление из внешнего источника. */
|
||||
export interface SourcedListing {
|
||||
/** ID объявления у источника (item_id на list.am). */
|
||||
externalId: string;
|
||||
sourceUrl: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
dealType?: DealType;
|
||||
/** Тип объекта, как его называет источник (apartment, house, …). */
|
||||
propertyType?: string;
|
||||
region?: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
price?: number;
|
||||
currency: Currency;
|
||||
rooms?: number;
|
||||
floor?: number;
|
||||
floorsTotal?: number;
|
||||
area?: number;
|
||||
ownerName?: string;
|
||||
phone?: string;
|
||||
/** Метка list.am «Агентство». */
|
||||
isAgency: boolean;
|
||||
publishedAt?: Date;
|
||||
/** Ссылки на оригинальные фото. */
|
||||
photoUrls: string[];
|
||||
/** Удобства и правила проживания (дети, животные) со страницы объявления. */
|
||||
features?: ListingFeatures;
|
||||
/** Карточка из блока «Топ объявления» на странице категории: его порядок ротируется. */
|
||||
promoted?: boolean;
|
||||
}
|
||||
|
||||
export interface ListingSourceFilter {
|
||||
/** `yerevan` — только объявления с районом Еревана; иначе все. */
|
||||
city: string;
|
||||
category: string;
|
||||
/** true — источник сам отбрасывает объявления с меткой «Агентство». */
|
||||
ownerOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ListingSource {
|
||||
readonly name: string;
|
||||
/**
|
||||
* Свежие объявления, начиная с самых новых; итерацию можно прервать в любой момент.
|
||||
* Некорректные записи источник пропускает и сообщает о них через `report`.
|
||||
*/
|
||||
fetchLatest(
|
||||
filter: ListingSourceFilter,
|
||||
report: (problem: string) => void,
|
||||
): AsyncIterable<SourcedListing>;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { AgentPay, agentPayout, monthsBetween, payPercent, type AccessState, type Currency, type DealKind } from '@lr/shared';
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lte, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { loadBillingSettings } from './billing/service.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
import { clients, companies, deals, leads, payAdjustments, payouts, users } from './db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from './lib/errors.ts';
|
||||
|
||||
// Выплаты агентам: по условиям, которые задал руководитель, считается, кому сколько платить за период.
|
||||
|
||||
type MemberCtx = { user: typeof users.$inferSelect; role: string; company: typeof companies.$inferSelect; access: AccessState };
|
||||
interface Guards {
|
||||
member: (req: FastifyRequest) => MemberCtx;
|
||||
manager: (req: FastifyRequest) => MemberCtx;
|
||||
writable: <T extends { access: AccessState }>(ctx: T) => T;
|
||||
}
|
||||
|
||||
const Period = z.object({ from: z.iso.date(), to: z.iso.date() });
|
||||
/** Текущий календарный месяц по Еревану: оклад считается за месяц целиком. */
|
||||
export function currentMonth(now = new Date()) {
|
||||
const today = new Date(now.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
|
||||
const [y, m] = today.split('-').map(Number) as [number, number];
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
return { from: `${today.slice(0, 7)}-01`, to: `${today.slice(0, 7)}-${String(last).padStart(2, '0')}`, today };
|
||||
}
|
||||
|
||||
type Deal = typeof deals.$inferSelect;
|
||||
|
||||
/** Часть комиссии сделки, которая идёт агенту: с собственника — агенту объекта, с клиента — агенту клиента. */
|
||||
const partOf = (d: Deal, userId: number) => (d.ownerAgentId === userId ? d.ownerCommission : 0) + (d.clientAgentId === userId ? d.clientCommission : 0);
|
||||
const takesPart = (userId: number) => or(eq(deals.ownerAgentId, userId), eq(deals.clientAgentId, userId));
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
type Rates = Record<string, number>;
|
||||
type Payout = typeof payouts.$inferSelect;
|
||||
|
||||
/** Курсы те же, что в счетах: сколько единиц валюты в одном долларе. */
|
||||
async function loadRates(db: Db): Promise<Rates> {
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
return { USD: 1, ...rates };
|
||||
}
|
||||
const converterOf = (perUsd: Rates) => (amount: number, fromCur: string, toCur: string) => (amount / (perUsd[fromCur] ?? 1)) * (perUsd[toCur] ?? 1);
|
||||
const converter = async (db: Db) => converterOf(await loadRates(db));
|
||||
|
||||
const Month = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/);
|
||||
const monthRange = (ym: string) => {
|
||||
const [y, m] = ym.split('-').map(Number) as [number, number];
|
||||
return { from: `${ym}-01`, to: `${ym}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}` };
|
||||
};
|
||||
const nextMonth = (ym: string) => {
|
||||
const [y, m] = ym.split('-').map(Number) as [number, number];
|
||||
return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`;
|
||||
};
|
||||
/** «ГГГГ-ММ», если период — ровно один календарный месяц: только для него ведутся начисления и выплаты. */
|
||||
const monthOf = (from: string, to: string) => {
|
||||
const ym = from.slice(0, 7);
|
||||
const r = monthRange(ym);
|
||||
return r.from === from && r.to === to ? ym : null;
|
||||
};
|
||||
|
||||
/** Начислено агенту по условиям `pay` за сделки `mine`: оклад, процент с его части комиссии, бонус за план. */
|
||||
function accrue(userId: number, pay: AgentPay | null, rates: Rates, mine: Deal[], months: number) {
|
||||
const convert = converterOf(rates);
|
||||
const currency: Currency = pay?.currency ?? 'USD';
|
||||
// Часть комиссии агента по его сделкам — в валюте его условий (отдельно продажа и аренда) и по валютам как есть.
|
||||
const byKind: Record<DealKind, number> = { sale: 0, rent: 0 };
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const d of mine) {
|
||||
const part = partOf(d, userId);
|
||||
if (!part) continue;
|
||||
byKind[d.kind === 'rent' ? 'rent' : 'sale'] += convert(part, d.currency, currency);
|
||||
byCurrency[d.currency] = round2((byCurrency[d.currency] ?? 0) + part);
|
||||
}
|
||||
const commissionSale = round2(byKind.sale);
|
||||
const commissionRent = round2(byKind.rent);
|
||||
return {
|
||||
pay, deals: mine.length,
|
||||
commission: round2(commissionSale + commissionRent), commissionSale, commissionRent, byCurrency, currency,
|
||||
...agentPayout(pay, { sale: commissionSale, rent: commissionRent }, months, mine.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Кому сколько платить за период. Для календарного месяца к начисленному добавляются ручные записи
|
||||
* (премии, удержания, авансы) и перерасчёты закрытых месяцев. Месяц закрывает отметка «выплачено»:
|
||||
* она фиксирует условия и курсы; если сделки закрытого месяца потом меняются, разница (как и
|
||||
* недоплата) переходит в ближайший следующий месяц без выплаты.
|
||||
*/
|
||||
export async function payroll(db: Db, companyId: number, from: string, to: string, onlyUserId?: number) {
|
||||
const perUsd = await loadRates(db);
|
||||
const month = monthOf(from, to);
|
||||
const members = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, companyId), isNull(users.removedAt), onlyUserId ? eq(users.id, onlyUserId) : undefined))
|
||||
.orderBy(users.id);
|
||||
const [paid, manual] = month
|
||||
? await Promise.all([
|
||||
db.select().from(payouts).where(and(eq(payouts.companyId, companyId), onlyUserId ? eq(payouts.userId, onlyUserId) : undefined)).orderBy(payouts.month),
|
||||
db.select().from(payAdjustments).where(and(eq(payAdjustments.companyId, companyId), onlyUserId ? eq(payAdjustments.userId, onlyUserId) : undefined)).orderBy(payAdjustments.id),
|
||||
])
|
||||
: [[], []];
|
||||
// Закрытые месяцы пересчитываются заново — нужны и их сделки.
|
||||
const since = paid.reduce((min, p) => (`${p.month}-01` < min ? `${p.month}-01` : min), from);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(deals)
|
||||
.where(and(eq(deals.companyId, companyId), gte(deals.dealDate, since), lte(deals.dealDate, to), onlyUserId ? takesPart(onlyUserId) : undefined));
|
||||
const months = monthsBetween(from, to);
|
||||
const dealsOf = (userId: number, test: (day: string) => boolean) => rows.filter((d) => (d.ownerAgentId === userId || d.clientAgentId === userId) && test(d.dealDate));
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
months,
|
||||
month,
|
||||
agents: members.map((u) => {
|
||||
const base = { userId: u.id, name: u.name, role: u.role };
|
||||
if (!month) {
|
||||
const row = accrue(u.id, u.pay, perUsd, dealsOf(u.id, (day) => day >= from && day <= to), months);
|
||||
return { ...base, ...row, accrued: row.total, adjustments: [], adjustmentsTotal: 0, recalc: [], recalcTotal: 0, payout: null, debt: null };
|
||||
}
|
||||
const mine = paid.filter((p) => p.userId === u.id);
|
||||
const byMonth = new Map(mine.map((p) => [p.month, p] as const));
|
||||
const manualOf = (ym: string) => manual.filter((a) => a.userId === u.id && a.month === ym);
|
||||
const inMonth = (ym: string) => dealsOf(u.id, (day) => day.slice(0, 7) === ym);
|
||||
/** Сколько по закрытому месяцу осталось доплатить (или удержать) с учётом изменений после выплаты — в валюте той выплаты. */
|
||||
const remaining = (p: Payout) => {
|
||||
const conv = converterOf(p.snapshot.rates);
|
||||
const accrued = accrue(u.id, p.snapshot.pay, p.snapshot.rates, inMonth(p.month), 1).total;
|
||||
const extra = manualOf(p.month).reduce((sum, a) => sum + conv(a.amount, a.currency, p.currency), 0);
|
||||
const carriedIn = p.snapshot.recalc.reduce((sum, r) => sum + r.amount, 0);
|
||||
const settled = mine
|
||||
.filter((q) => q.month > p.month)
|
||||
.reduce((sum, q) => sum + q.snapshot.recalc.filter((r) => r.month === p.month).reduce((n, r) => n + converterOf(perUsd)(r.amount, q.currency, p.currency), 0), 0);
|
||||
return round2(accrued + extra + carriedIn - p.amount - settled);
|
||||
};
|
||||
/** Месяц, в который переходит остаток закрытого месяца: ближайший следующий без выплаты. */
|
||||
const carryTo = (ym: string) => {
|
||||
let next = nextMonth(ym);
|
||||
while (byMonth.has(next)) next = nextMonth(next);
|
||||
return next;
|
||||
};
|
||||
|
||||
const own = byMonth.get(month);
|
||||
const rates = own ? own.snapshot.rates : perUsd;
|
||||
const row = accrue(u.id, own ? own.snapshot.pay : u.pay, rates, inMonth(month), 1);
|
||||
const convert = converterOf(rates);
|
||||
const adjustments = manualOf(month).map((a) => ({ id: a.id, amount: a.amount, currency: a.currency, note: a.note }));
|
||||
const adjustmentsTotal = round2(adjustments.reduce((sum, a) => sum + convert(a.amount, a.currency, row.currency), 0));
|
||||
const recalc = own
|
||||
? own.snapshot.recalc
|
||||
: mine
|
||||
.filter((p) => p.month < month && carryTo(p.month) === month)
|
||||
.map((p) => ({ month: p.month, amount: round2(convert(remaining(p), p.currency, row.currency)) }))
|
||||
.filter((r) => Math.abs(r.amount) >= 0.01);
|
||||
const recalcTotal = round2(recalc.reduce((sum, r) => sum + r.amount, 0));
|
||||
const total = round2(row.total + adjustmentsTotal + recalcTotal);
|
||||
return {
|
||||
...base, ...row,
|
||||
/** Оклад, процент и бонус за план — без ручных записей и перерасчётов. */
|
||||
accrued: row.total,
|
||||
adjustments, adjustmentsTotal, recalc, recalcTotal, total,
|
||||
payout: own
|
||||
? { id: own.id, amount: own.amount, currency: own.currency, paidAt: own.paidAt, note: own.note, last: mine[mine.length - 1]!.id === own.id }
|
||||
: null,
|
||||
/** Остаток по закрытому месяцу: уйдёт перерасчётом в следующий открытый. */
|
||||
debt: own ? remaining(own) : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Сделки агента за период: его часть комиссии и сколько ему с неё начислено. */
|
||||
export async function agentDeals(db: Db, companyId: number, userId: number, from: string, to: string) {
|
||||
const convert = await converter(db);
|
||||
const [agent] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.companyId, companyId)));
|
||||
if (!agent) throw notFound();
|
||||
const currency: Currency = agent.pay?.currency ?? 'USD';
|
||||
const rows = await db
|
||||
.select({ deal: deals, leadTitle: leads.title, clientName: clients.name })
|
||||
.from(deals)
|
||||
.leftJoin(leads, eq(leads.id, deals.leadId))
|
||||
.leftJoin(clients, eq(clients.id, deals.clientId))
|
||||
.where(and(eq(deals.companyId, companyId), gte(deals.dealDate, from), lte(deals.dealDate, to), takesPart(userId)))
|
||||
.orderBy(asc(deals.dealDate), asc(deals.id));
|
||||
const partnerIds = [...new Set(rows.flatMap((r) => [r.deal.ownerAgentId, r.deal.clientAgentId]).filter((id) => id !== userId))];
|
||||
const partners = new Map(
|
||||
(partnerIds.length ? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, partnerIds)) : []).map((u) => [u.id, u.name] as const),
|
||||
);
|
||||
return {
|
||||
userId,
|
||||
currency,
|
||||
deals: rows.map(({ deal: d, leadTitle, clientName }) => {
|
||||
const kind: DealKind = d.kind === 'rent' ? 'rent' : 'sale';
|
||||
const part = partOf(d, userId);
|
||||
const percent = payPercent(agent.pay, kind);
|
||||
const owner = d.ownerAgentId === userId;
|
||||
const client = d.clientAgentId === userId;
|
||||
return {
|
||||
id: d.id, date: d.dealDate, kind,
|
||||
leadId: d.leadId, leadTitle, clientId: d.clientId, clientName,
|
||||
/** С какой стороны агент в сделке: объект, клиент или обе. */
|
||||
side: owner && client ? ('both' as const) : owner ? ('owner' as const) : ('client' as const),
|
||||
/** Второй агент сделки, если стороны вели разные люди. */
|
||||
partner: owner && client ? null : (partners.get(owner ? d.clientAgentId : d.ownerAgentId) ?? null),
|
||||
dealCommission: d.commission, part, dealCurrency: d.currency,
|
||||
percent,
|
||||
/** Начислено агенту с этой сделки — в валюте его условий. */
|
||||
share: round2((convert(part, d.currency, currency) * percent) / 100),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Динамика по месяцам: сделки и часть комиссии каждого агента за последние `count` месяцев, включая
|
||||
* текущий. Комиссия — в долларах по текущему курсу, чтобы месяцы и агенты сравнивались между собой.
|
||||
*/
|
||||
export async function payTrend(db: Db, companyId: number, count: number, onlyUserId?: number) {
|
||||
const convert = await converter(db);
|
||||
const months: string[] = [];
|
||||
const [y, m] = currentMonth().from.split('-').map(Number) as [number, number];
|
||||
for (let i = count - 1; i >= 0; i--) months.push(new Date(Date.UTC(y, m - 1 - i, 1)).toISOString().slice(0, 7));
|
||||
const members = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, companyId), isNull(users.removedAt), onlyUserId ? eq(users.id, onlyUserId) : undefined))
|
||||
.orderBy(users.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(deals)
|
||||
.where(and(eq(deals.companyId, companyId), gte(deals.dealDate, `${months[0]}-01`), onlyUserId ? takesPart(onlyUserId) : undefined));
|
||||
const point = (list: Deal[], part: (d: Deal) => number) => ({
|
||||
deals: list.length,
|
||||
commission: round2(list.reduce((sum, d) => sum + convert(part(d), d.currency, 'USD'), 0)),
|
||||
});
|
||||
const of = (ym: string) => rows.filter((d) => d.dealDate.slice(0, 7) === ym);
|
||||
return {
|
||||
months,
|
||||
agents: members.map((u) => ({
|
||||
userId: u.id,
|
||||
name: u.name,
|
||||
points: months.map((ym) => point(of(ym).filter((d) => d.ownerAgentId === u.id || d.clientAgentId === u.id), (d) => partOf(d, u.id))),
|
||||
})),
|
||||
/** По компании: сделка двух агентов считается один раз. Агенту — только его собственные цифры. */
|
||||
totals: months.map((ym) => point(of(ym), (d) => (onlyUserId ? partOf(d, onlyUserId) : d.commission))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Рейтинг за месяц — виден всем участникам: место, число сделок, выполнение плана в процентах.
|
||||
* Сумм комиссии и выплат в нём нет; при равном числе сделок выше тот, у кого комиссия больше.
|
||||
*/
|
||||
export async function rating(db: Db, companyId: number, month: string) {
|
||||
const convert = await converter(db);
|
||||
const { from, to } = monthRange(month);
|
||||
const { agents } = await payroll(db, companyId, from, to);
|
||||
return agents
|
||||
.map((a) => {
|
||||
const parts = [
|
||||
a.pay?.planDeals != null ? a.deals / a.pay.planDeals : null,
|
||||
a.pay?.planCommission != null ? a.commission / a.pay.planCommission : null,
|
||||
].filter((x): x is number => x !== null);
|
||||
return {
|
||||
userId: a.userId, name: a.name, deals: a.deals,
|
||||
/** Выполнение плана: по показателю, который отстаёт сильнее; null — плана нет. */
|
||||
planPercent: parts.length ? Math.round(Math.min(...parts) * 100) : null,
|
||||
usd: convert(a.commission, a.currency, 'USD'),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.deals - a.deals || b.usd - a.usd || a.userId - b.userId)
|
||||
.map(({ usd: _usd, ...row }, i) => ({ place: i + 1, ...row }));
|
||||
}
|
||||
|
||||
export function registerPayrollRoutes(app: FastifyInstance, db: Db, g: Guards) {
|
||||
/** Условия оплаты агента задаёт руководитель; null — условий нет. */
|
||||
app.post('/api/members/:id/pay', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
|
||||
const { pay } = z.object({ pay: AgentPay.nullable() }).parse(req.body);
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ pay })
|
||||
.where(and(eq(users.id, id), eq(users.companyId, ctx.company.id), isNull(users.removedAt)))
|
||||
.returning({ id: users.id });
|
||||
if (!updated) throw notFound();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Кому сколько платить за период — руководителю. По умолчанию текущий месяц. */
|
||||
app.get('/api/payroll', async (req) => {
|
||||
const ctx = g.manager(req);
|
||||
const q = Period.partial().parse(req.query);
|
||||
const period = q.from && q.to ? { from: q.from, to: q.to } : currentMonth();
|
||||
return payroll(db, ctx.company.id, period.from, period.to);
|
||||
});
|
||||
|
||||
/** Сделки агента за период с его частью комиссии: руководителю — по любому агенту, агенту — только свои. */
|
||||
app.get('/api/payroll/deals', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = Period.partial().extend({ userId: z.coerce.number().int().optional() }).parse(req.query);
|
||||
const period = q.from && q.to ? { from: q.from, to: q.to } : currentMonth();
|
||||
const userId = ctx.role === 'manager' ? (q.userId ?? ctx.user.id) : ctx.user.id;
|
||||
return agentDeals(db, ctx.company.id, userId, period.from, period.to);
|
||||
});
|
||||
|
||||
/** Участник компании, которому начисляют; месяц не из будущего. */
|
||||
const target = async (companyId: number, userId: number, month: string) => {
|
||||
if (month > currentMonth().from.slice(0, 7)) throw badRequest('VALIDATION');
|
||||
const [user] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.companyId, companyId), isNull(users.removedAt)));
|
||||
if (!user) throw notFound();
|
||||
return user;
|
||||
};
|
||||
const isPaid = async (userId: number, month: string) =>
|
||||
(await db.select({ id: payouts.id }).from(payouts).where(and(eq(payouts.userId, userId), eq(payouts.month, month)))).length > 0;
|
||||
|
||||
/** Ручная запись за месяц: премия, штраф, аванс. Сумма со знаком, в валюте условий агента. */
|
||||
app.post('/api/payroll/adjustments', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const body = z
|
||||
.object({ userId: z.number().int(), month: Month, amount: z.number().min(-100_000_000).max(100_000_000).refine((n) => n !== 0), note: z.string().trim().min(1).max(200) })
|
||||
.parse(req.body);
|
||||
const user = await target(ctx.company.id, body.userId, body.month);
|
||||
if (await isPaid(user.id, body.month)) throw conflict('MONTH_PAID');
|
||||
const [row] = await db
|
||||
.insert(payAdjustments)
|
||||
.values({ companyId: ctx.company.id, userId: user.id, month: body.month, amount: body.amount, currency: user.pay?.currency ?? 'USD', note: body.note, createdBy: ctx.user.id })
|
||||
.returning({ id: payAdjustments.id });
|
||||
return { id: row!.id };
|
||||
});
|
||||
|
||||
app.delete('/api/payroll/adjustments/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
|
||||
const [row] = await db.select().from(payAdjustments).where(and(eq(payAdjustments.id, id), eq(payAdjustments.companyId, ctx.company.id)));
|
||||
if (!row) throw notFound();
|
||||
if (await isPaid(row.userId, row.month)) throw conflict('MONTH_PAID');
|
||||
await db.delete(payAdjustments).where(eq(payAdjustments.id, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** «Выплачено» за месяц. Сумма по умолчанию — всё начисленное; недоплата перейдёт в следующий открытый месяц. */
|
||||
app.post('/api/payroll/payouts', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const body = z
|
||||
.object({ userId: z.number().int(), month: Month, amount: z.number().min(0).max(100_000_000).optional(), paidAt: z.iso.date().optional(), note: z.string().trim().max(200).optional() })
|
||||
.parse(req.body);
|
||||
const user = await target(ctx.company.id, body.userId, body.month);
|
||||
if (await isPaid(user.id, body.month)) throw conflict('MONTH_PAID');
|
||||
const { from, to } = monthRange(body.month);
|
||||
const row = (await payroll(db, ctx.company.id, from, to, user.id)).agents[0]!;
|
||||
const [created] = await db
|
||||
.insert(payouts)
|
||||
.values({
|
||||
companyId: ctx.company.id, userId: user.id, month: body.month,
|
||||
amount: body.amount ?? Math.max(0, row.total), currency: row.currency,
|
||||
paidAt: body.paidAt ?? currentMonth().today, note: body.note || null,
|
||||
snapshot: { pay: user.pay, rates: await loadRates(db), total: row.total, recalc: row.recalc },
|
||||
createdBy: ctx.user.id,
|
||||
})
|
||||
.returning({ id: payouts.id });
|
||||
return { id: created!.id };
|
||||
});
|
||||
|
||||
/** Отмена отметки — только последней у агента: более поздние выплаты могли учесть её остаток. */
|
||||
app.delete('/api/payroll/payouts/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
|
||||
const [row] = await db.select().from(payouts).where(and(eq(payouts.id, id), eq(payouts.companyId, ctx.company.id)));
|
||||
if (!row) throw notFound();
|
||||
const [last] = await db.select({ id: payouts.id }).from(payouts).where(eq(payouts.userId, row.userId)).orderBy(desc(payouts.month)).limit(1);
|
||||
if (last!.id !== id) throw conflict('PAYOUT_NOT_LAST');
|
||||
await db.delete(payouts).where(eq(payouts.id, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** История выплат агента: руководителю — любого, агенту — своя. */
|
||||
app.get('/api/payroll/payouts', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = z.object({ userId: z.coerce.number().int().optional() }).parse(req.query);
|
||||
const userId = ctx.role === 'manager' ? (q.userId ?? ctx.user.id) : ctx.user.id;
|
||||
const rows = await db.select().from(payouts).where(and(eq(payouts.companyId, ctx.company.id), eq(payouts.userId, userId))).orderBy(desc(payouts.month));
|
||||
return rows.map((p) => ({ id: p.id, month: p.month, amount: p.amount, currency: p.currency, paidAt: p.paidAt, note: p.note, accrued: p.snapshot.total }));
|
||||
});
|
||||
|
||||
/** Динамика по месяцам: руководителю — по всем, агенту — своя. */
|
||||
app.get('/api/payroll/trend', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { months } = z.object({ months: z.coerce.number().int().min(2).max(24).default(6) }).parse(req.query);
|
||||
return payTrend(db, ctx.company.id, months, ctx.role === 'manager' ? undefined : ctx.user.id);
|
||||
});
|
||||
|
||||
/** Рейтинг агентов за месяц — всем участникам, без сумм. */
|
||||
app.get('/api/rating', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = z.object({ month: Month.optional() }).parse(req.query);
|
||||
const month = q.month ?? currentMonth().from.slice(0, 7);
|
||||
return { month, agents: await rating(db, ctx.company.id, month) };
|
||||
});
|
||||
|
||||
/** Свои условия и начисления за месяц (по умолчанию текущий) — агенту. */
|
||||
app.get('/api/my-pay', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = z.object({ month: Month.optional() }).parse(req.query);
|
||||
const { from, to } = q.month ? monthRange(q.month) : currentMonth();
|
||||
const result = await payroll(db, ctx.company.id, from, to, ctx.user.id);
|
||||
return { from, to, ...(result.agents[0] ?? null) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { PIPELINES, SYSTEM_CODES, type Pipeline } from '@lr/shared';
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Db, Tx } from '../db/client.ts';
|
||||
import { clients, events, leads, pipelineStatuses, rejectReasons } from '../db/schema.ts';
|
||||
import type { Guards } from '../leads/routes.ts';
|
||||
import { badRequest, conflict, notFound } from '../lib/errors.ts';
|
||||
|
||||
// Настройка воронок руководителем: промежуточные статусы и причины отказа можно переименовывать,
|
||||
// добавлять, удалять и переставлять. Системные статусы удалить и передвинуть нельзя — на них
|
||||
// завязаны пул, аналитика и биллинг.
|
||||
|
||||
const PipelineParam = z.object({ pipeline: z.enum(PIPELINES) });
|
||||
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||
const Name = z.string().trim().min(1).max(60);
|
||||
const Order = z.object({ ids: z.array(z.number().int().positive()).max(100) });
|
||||
|
||||
/** Системные статусы стоят на своих местах: «Новый», «Взят в работу» — в начале, «Сделка», «Отказ» — в конце. */
|
||||
const HEAD: Record<Pipeline, readonly string[]> = { object: ['new', 'taken'], client: ['new'] };
|
||||
const TAIL = ['deal', 'rejected'];
|
||||
|
||||
async function activeStatuses(tx: Tx, companyId: number, pipeline: Pipeline) {
|
||||
return tx
|
||||
.select()
|
||||
.from(pipelineStatuses)
|
||||
.where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline), isNull(pipelineStatuses.deletedAt)))
|
||||
.orderBy(asc(pipelineStatuses.position));
|
||||
}
|
||||
|
||||
/** Расставляет позиции: системные в начале, промежуточные в заданном порядке, «Сделка» и «Отказ» в конце. */
|
||||
async function reposition(tx: Tx, companyId: number, pipeline: Pipeline, middleIds?: number[]) {
|
||||
const all = await activeStatuses(tx, companyId, pipeline);
|
||||
const byCode = (code: string) => all.find((s) => s.code === code)!;
|
||||
const middle = all.filter((s) => !s.code);
|
||||
const ordered = middleIds ? middleIds.map((id) => middle.find((s) => s.id === id)) : middle;
|
||||
if (ordered.length !== middle.length || ordered.some((s) => !s) || new Set(middleIds ?? []).size !== (middleIds?.length ?? 0)) {
|
||||
throw badRequest('VALIDATION');
|
||||
}
|
||||
const next = [...HEAD[pipeline].map(byCode), ...(ordered as typeof middle), ...TAIL.map(byCode)];
|
||||
for (const [position, s] of next.entries()) {
|
||||
if (s.position !== position) await tx.update(pipelineStatuses).set({ position }).where(eq(pipelineStatuses.id, s.id));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPipelineRoutes(app: FastifyInstance, db: Db, g: Guards) {
|
||||
app.get('/api/pipelines/:pipeline', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { pipeline } = PipelineParam.parse(req.params);
|
||||
const table = pipeline === 'object' ? leads : clients;
|
||||
const [statuses, reasons, counts] = await Promise.all([
|
||||
activeStatuses(db, ctx.company.id, pipeline),
|
||||
db
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(and(eq(rejectReasons.companyId, ctx.company.id), eq(rejectReasons.pipeline, pipeline), isNull(rejectReasons.deletedAt)))
|
||||
.orderBy(asc(rejectReasons.position)),
|
||||
// Сколько карточек в каждом статусе — руководителю, чтобы решить, куда перевести их при удалении статуса.
|
||||
ctx.role === 'manager'
|
||||
? db
|
||||
.select({ statusId: table.statusId, n: sql<number>`count(*)::int` })
|
||||
.from(table)
|
||||
.where(eq(table.companyId, ctx.company.id))
|
||||
.groupBy(table.statusId)
|
||||
: [],
|
||||
]);
|
||||
const used = new Map(counts.map((c) => [c.statusId, c.n]));
|
||||
return {
|
||||
statuses: statuses.map((s) => ({ id: s.id, code: s.code, name: s.name, poolReturn: s.poolReturn, count: used.get(s.id) ?? 0 })),
|
||||
reasons: reasons.map((r) => ({ id: r.id, code: r.code, name: r.name })),
|
||||
};
|
||||
});
|
||||
|
||||
const ownStatus = async (tx: Tx, companyId: number, id: number) => {
|
||||
const [s] = await tx
|
||||
.select()
|
||||
.from(pipelineStatuses)
|
||||
.where(and(eq(pipelineStatuses.id, id), eq(pipelineStatuses.companyId, companyId), isNull(pipelineStatuses.deletedAt)));
|
||||
if (!s) throw notFound();
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Новый статус встаёт последним среди промежуточных — перед «Сделкой». */
|
||||
app.post('/api/pipelines/:pipeline/statuses', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { pipeline } = PipelineParam.parse(req.params);
|
||||
const { name } = z.object({ name: Name }).parse(req.body);
|
||||
return db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(pipelineStatuses)
|
||||
// Позиция 9000 — временная: reposition поставит статус после остальных промежуточных.
|
||||
.values({ companyId: ctx.company.id, pipeline, name, position: 9000 })
|
||||
.returning();
|
||||
const middle = (await activeStatuses(tx, ctx.company.id, pipeline)).filter((s) => !s.code).map((s) => s.id);
|
||||
await reposition(tx, ctx.company.id, pipeline, [...middle.filter((id) => id !== row!.id), row!.id]);
|
||||
return { id: row!.id };
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/api/pipeline-statuses/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const body = z.object({ name: Name.optional(), poolReturn: z.boolean().optional() }).parse(req.body);
|
||||
const s = await ownStatus(db, ctx.company.id, id);
|
||||
// Возврат в пул бывает только у объектов и только у статусов «в работе».
|
||||
if (body.poolReturn !== undefined && (s.pipeline !== 'object' || ['new', 'deal', 'rejected'].includes(s.code ?? ''))) {
|
||||
throw badRequest('VALIDATION');
|
||||
}
|
||||
if (body.name === undefined && body.poolReturn === undefined) throw badRequest('VALIDATION');
|
||||
await db.update(pipelineStatuses).set(body).where(eq(pipelineStatuses.id, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Удаление промежуточного статуса. Если в нём есть карточки, нужно указать `moveTo` —
|
||||
* другой промежуточный статус (или «Взят в работу»), куда они перейдут.
|
||||
*/
|
||||
app.delete('/api/pipeline-statuses/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { moveTo } = z.object({ moveTo: z.coerce.number().int().positive().optional() }).parse(req.query);
|
||||
await db.transaction(async (tx) => {
|
||||
const s = await ownStatus(tx, ctx.company.id, id);
|
||||
const pipeline = s.pipeline as Pipeline;
|
||||
if (s.code && (SYSTEM_CODES[pipeline] as readonly string[]).includes(s.code)) throw badRequest('SYSTEM_STATUS');
|
||||
const table = pipeline === 'object' ? leads : clients;
|
||||
const used = await tx.select({ id: table.id }).from(table).where(eq(table.statusId, id));
|
||||
if (used.length) {
|
||||
if (!moveTo) throw conflict('STATUS_IN_USE');
|
||||
const target = await ownStatus(tx, ctx.company.id, moveTo);
|
||||
const allowed = target.pipeline === pipeline && target.id !== id && (!target.code || target.code === 'taken');
|
||||
if (!allowed) throw badRequest('BAD_STATUS');
|
||||
await tx.update(table).set({ statusId: target.id }).where(eq(table.statusId, id));
|
||||
const ids = used.map((u) => u.id);
|
||||
await tx.insert(events).values(
|
||||
ids.map((entityId) => ({
|
||||
companyId: ctx.company.id, userId: ctx.user.id, type: 'status', oldValue: s.name, newValue: target.name, statusId: target.id,
|
||||
...(pipeline === 'object' ? { leadId: entityId } : { clientId: entityId }),
|
||||
})),
|
||||
);
|
||||
}
|
||||
await tx.update(pipelineStatuses).set({ deletedAt: new Date() }).where(eq(pipelineStatuses.id, id));
|
||||
await reposition(tx, ctx.company.id, pipeline);
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Новый порядок промежуточных статусов: `ids` — все они, слева направо. */
|
||||
app.post('/api/pipelines/:pipeline/order', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { pipeline } = PipelineParam.parse(req.params);
|
||||
const { ids } = Order.parse(req.body);
|
||||
await db.transaction((tx) => reposition(tx, ctx.company.id, pipeline, ids));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- причины отказа ---
|
||||
|
||||
const activeReasons = (tx: Tx, companyId: number, pipeline: Pipeline) =>
|
||||
tx
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(and(eq(rejectReasons.companyId, companyId), eq(rejectReasons.pipeline, pipeline), isNull(rejectReasons.deletedAt)))
|
||||
.orderBy(asc(rejectReasons.position));
|
||||
|
||||
const ownReason = async (tx: Tx, companyId: number, id: number) => {
|
||||
const [r] = await tx
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(and(eq(rejectReasons.id, id), eq(rejectReasons.companyId, companyId), isNull(rejectReasons.deletedAt)));
|
||||
if (!r) throw notFound();
|
||||
return r;
|
||||
};
|
||||
|
||||
app.post('/api/pipelines/:pipeline/reasons', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { pipeline } = PipelineParam.parse(req.params);
|
||||
const { name } = z.object({ name: Name }).parse(req.body);
|
||||
const last = (await activeReasons(db, ctx.company.id, pipeline)).at(-1);
|
||||
const [row] = await db
|
||||
.insert(rejectReasons)
|
||||
.values({ companyId: ctx.company.id, pipeline, name, position: (last?.position ?? -1) + 1 })
|
||||
.returning();
|
||||
return { id: row!.id };
|
||||
});
|
||||
|
||||
app.patch('/api/reject-reasons/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { name } = z.object({ name: Name }).parse(req.body);
|
||||
await ownReason(db, ctx.company.id, id);
|
||||
await db.update(rejectReasons).set({ name }).where(eq(rejectReasons.id, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Удалённая причина пропадает из списка выбора, но остаётся в уже закрытых карточках и в аналитике. */
|
||||
app.delete('/api/reject-reasons/:id', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
await db.transaction(async (tx) => {
|
||||
const r = await ownReason(tx, ctx.company.id, id);
|
||||
// «Это агент» ставит кнопка в карточке объекта.
|
||||
if (r.code) throw badRequest('SYSTEM_REASON');
|
||||
const rest = (await activeReasons(tx, ctx.company.id, r.pipeline as Pipeline)).filter((x) => x.id !== id);
|
||||
if (!rest.some((x) => !x.code)) throw badRequest('LAST_REASON');
|
||||
await tx.update(rejectReasons).set({ deletedAt: new Date() }).where(eq(rejectReasons.id, id));
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/pipelines/:pipeline/reasons-order', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { pipeline } = PipelineParam.parse(req.params);
|
||||
const { ids } = Order.parse(req.body);
|
||||
await db.transaction(async (tx) => {
|
||||
const all = await activeReasons(tx, ctx.company.id, pipeline);
|
||||
if (ids.length !== all.length || new Set(ids).size !== ids.length || ids.some((id) => !all.some((r) => r.id === id))) {
|
||||
throw badRequest('VALIDATION');
|
||||
}
|
||||
for (const [position, id] of ids.entries()) await tx.update(rejectReasons).set({ position }).where(eq(rejectReasons.id, id));
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Pipeline } from '@lr/shared';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Tx } from '../db/client.ts';
|
||||
import { pipelineStatuses } from '../db/schema.ts';
|
||||
|
||||
export type Status = typeof pipelineStatuses.$inferSelect;
|
||||
|
||||
/** Системный статус воронки по коду (new, taken, deal, rejected). */
|
||||
export async function statusByCode(tx: Tx, companyId: number, code: string, pipeline: Pipeline = 'object'): Promise<Status> {
|
||||
const [row] = await tx
|
||||
.select()
|
||||
.from(pipelineStatuses)
|
||||
.where(
|
||||
and(
|
||||
eq(pipelineStatuses.companyId, companyId),
|
||||
eq(pipelineStatuses.pipeline, pipeline),
|
||||
eq(pipelineStatuses.code, code),
|
||||
),
|
||||
);
|
||||
if (!row) throw new Error(`У компании ${companyId} нет системного статуса ${pipeline}/${code}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Все статусы воронки компании, включая удалённые (они нужны для истории). */
|
||||
export const pipelineOf = (tx: Tx, companyId: number, pipeline: Pipeline) =>
|
||||
tx
|
||||
.select()
|
||||
.from(pipelineStatuses)
|
||||
.where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline)));
|
||||
@@ -0,0 +1,113 @@
|
||||
import { and, eq, gte, isNull, lte } from 'drizzle-orm';
|
||||
import type { Db } from './db/client.ts';
|
||||
import { companies, deals, users } from './db/schema.ts';
|
||||
import type { Config } from './lib/config.ts';
|
||||
import { payroll } from './payroll.ts';
|
||||
import { enqueueRaw } from './telegram/outbox.ts';
|
||||
import { formatPrice } from './telegram/texts.ts';
|
||||
|
||||
// Отчёты руководителю в Telegram: сводка за неделю по понедельникам и итог месяца с ведомостью
|
||||
// первого числа. Включаются в настройках компании. Каждый отчёт уходит один раз — по отметке
|
||||
// в настройках.
|
||||
|
||||
const L = {
|
||||
ru: {
|
||||
week: 'Итоги недели', month: 'Итоги месяца', deals: 'Сделок', commission: 'комиссия', none: 'Сделок не было.',
|
||||
toPay: 'К выплате', total: 'Всего', planDone: 'план выполнен', planNot: 'план не выполнен', sheet: 'Ведомость', locale: 'ru-RU',
|
||||
},
|
||||
hy: {
|
||||
week: 'Շաբաթվա արդյունքները', month: 'Ամսվա արդյունքները', deals: 'Գործարքներ', commission: 'միջնորդավճար', none: 'Գործարքներ չեն եղել։',
|
||||
toPay: 'Վճարման ենթակա', total: 'Ընդամենը', planDone: 'պլանը կատարված է', planNot: 'պլանը կատարված չէ', sheet: 'Տեղեկագիր', locale: 'hy-AM',
|
||||
},
|
||||
en: {
|
||||
week: 'Week summary', month: 'Month summary', deals: 'Deals', commission: 'commission', none: 'No deals.',
|
||||
toPay: 'To pay', total: 'Total', planDone: 'plan met', planNot: 'plan not met', sheet: 'Payroll', locale: 'en-GB',
|
||||
},
|
||||
};
|
||||
type Lang = keyof typeof L;
|
||||
const dict = (lang: string | null) => L[(lang as Lang) in L ? (lang as Lang) : 'ru'];
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const addDays = (day: string, n: number) => iso(new Date(Date.parse(`${day}T00:00:00Z`) + n * 86_400_000));
|
||||
const sums = (byCurrency: Record<string, number>) =>
|
||||
Object.entries(byCurrency).filter(([, n]) => n).map(([cur, n]) => formatPrice(n, cur)).join(' + ') || '—';
|
||||
|
||||
/** Сводка за период: сколько сделок и комиссии у компании и у каждого агента (его часть). */
|
||||
async function summary(db: Db, companyId: number, from: string, to: string, t: (typeof L)[Lang]) {
|
||||
const rows = await db.select().from(deals).where(and(eq(deals.companyId, companyId), gte(deals.dealDate, from), lte(deals.dealDate, to)));
|
||||
if (!rows.length) return { lines: [t.none], agents: [] as Awaited<ReturnType<typeof payroll>>['agents'] };
|
||||
const total: Record<string, number> = {};
|
||||
for (const d of rows) total[d.currency] = (total[d.currency] ?? 0) + d.commission;
|
||||
const { agents } = await payroll(db, companyId, from, to);
|
||||
const active = agents.filter((a) => a.deals).sort((a, b) => b.deals - a.deals);
|
||||
return {
|
||||
lines: [`${t.deals}: ${rows.length} · ${t.commission}: ${sums(total)}`, '', ...active.map((a, i) => `${i + 1}. ${a.name} — ${a.deals} · ${sums(a.byCurrency)}`)],
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function weeklyReport(db: Db, companyId: number, monday: string, lang: string | null) {
|
||||
const t = dict(lang);
|
||||
const from = addDays(monday, -7);
|
||||
const to = addDays(monday, -1);
|
||||
const day = (s: string) => new Date(`${s}T00:00:00Z`).toLocaleDateString(t.locale, { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
return [`${t.week}: ${day(from)} — ${day(to)}`, ...(await summary(db, companyId, from, to, t)).lines].join('\n');
|
||||
}
|
||||
|
||||
export async function monthlyReport(db: Db, config: Config, companyId: number, month: string, lang: string | null) {
|
||||
const t = dict(lang);
|
||||
const [y, m] = month.split('-').map(Number) as [number, number];
|
||||
const from = `${month}-01`;
|
||||
const to = `${month}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}`;
|
||||
const title = new Date(`${from}T00:00:00Z`).toLocaleDateString(t.locale, { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
||||
const { lines } = await summary(db, companyId, from, to, t);
|
||||
// Ведомость — по месяцу целиком: с ручными записями, бонусами и перерасчётами.
|
||||
const { agents } = await payroll(db, companyId, from, to);
|
||||
const due = agents.filter((a) => a.total);
|
||||
const total: Record<string, number> = {};
|
||||
for (const a of due) total[a.currency] = (total[a.currency] ?? 0) + a.total;
|
||||
return [
|
||||
`${t.month}: ${title}`,
|
||||
...lines,
|
||||
'',
|
||||
`${t.toPay}:`,
|
||||
...due.map((a) => `• ${a.name} — ${formatPrice(a.total, a.currency)}${a.planDone === null ? '' : ` (${a.planDone ? t.planDone : t.planNot})`}`),
|
||||
`${t.total}: ${sums(total)}`,
|
||||
'',
|
||||
`${t.sheet}: ${config.appUrl}/team`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Рассылает положенные отчёты. Время — по Еревану: неделя закрывается в понедельник, месяц — первого числа, с 9 утра. */
|
||||
export async function runReportJobs(db: Db, config: Config, now = new Date()) {
|
||||
const local = new Date(now.getTime() + 4 * 3_600_000);
|
||||
if (local.getUTCHours() < 9) return 0;
|
||||
const today = iso(local);
|
||||
const isMonday = local.getUTCDay() === 1;
|
||||
const isFirst = local.getUTCDate() === 1;
|
||||
if (!isMonday && !isFirst) return 0;
|
||||
const lastMonth = iso(new Date(Date.UTC(local.getUTCFullYear(), local.getUTCMonth() - 1, 1))).slice(0, 7);
|
||||
let sent = 0;
|
||||
for (const company of await db.select().from(companies)) {
|
||||
const r = company.settings.reports;
|
||||
const weekly = Boolean(r?.weekly && isMonday && r.sentWeekly !== today);
|
||||
const monthly = Boolean(r?.monthly && isFirst && r.sentMonthly !== lastMonth);
|
||||
if (!weekly && !monthly) continue;
|
||||
const managers = await db
|
||||
.select({ id: users.id, lang: users.lang })
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, company.id), eq(users.role, 'manager'), isNull(users.removedAt)));
|
||||
await db.transaction(async (tx) => {
|
||||
for (const m of managers) {
|
||||
if (weekly) await enqueueRaw(tx, [m.id], await weeklyReport(db, company.id, today, m.lang));
|
||||
if (monthly) await enqueueRaw(tx, [m.id], await monthlyReport(db, config, company.id, lastMonth, m.lang));
|
||||
}
|
||||
await tx
|
||||
.update(companies)
|
||||
.set({ settings: { ...company.settings, reports: { weekly: Boolean(r?.weekly), monthly: Boolean(r?.monthly), sentWeekly: weekly ? today : r?.sentWeekly, sentMonthly: monthly ? lastMonth : r?.sentMonthly } } })
|
||||
.where(eq(companies.id, company.id));
|
||||
});
|
||||
sent += Number(weekly) + Number(monthly);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from './auth/password.ts';
|
||||
import { createSession } from './auth/sessions.ts';
|
||||
import sharp from 'sharp';
|
||||
import { clientPhotos, clients, companies, events, leads, listings, users } from './db/schema.ts';
|
||||
import { handleIntakeUpdate, parseBudget } from './intake/service.ts';
|
||||
import { runShowcaseJobs, slugify, validSlug } from './showcase/service.ts';
|
||||
import { renderShowcaseText, showcaseText } from './showcase/text.ts';
|
||||
import { handleUpdate } from './telegram/bot.ts';
|
||||
import { processOutbox } from './telegram/outbox.ts';
|
||||
import { fakeTelegram, testApp } from './testing.ts';
|
||||
|
||||
// Анкета соискателя в клиентском боте → клиент в CRM → страница подборки → запись на показ.
|
||||
|
||||
let t: Awaited<ReturnType<typeof testApp>>;
|
||||
let config: Awaited<ReturnType<typeof testApp>>['config'] & { clientBotToken: string; clientBotUsername: string };
|
||||
beforeAll(async () => {
|
||||
t = await testApp('showcase');
|
||||
// Приложение и обработчики ботов смотрят в один и тот же объект настроек.
|
||||
config = Object.assign(t.config, { clientBotToken: 'client:token', clientBotUsername: 'client_bot' });
|
||||
});
|
||||
afterAll(() => t.close());
|
||||
|
||||
/** Основной бот (агенты) и клиентский бот (соискатели) — две разные заглушки. */
|
||||
const main = fakeTelegram();
|
||||
const seekerBot = fakeTelegram();
|
||||
const flush = async () => { while ((await processOutbox({ db: t.db, tg: main.tg, config, sleep: async () => {} })) > 0); };
|
||||
let tgId = 9000;
|
||||
let updateId = 0;
|
||||
|
||||
const company = async (name = 'Арарат Недвижимость') => {
|
||||
const [sa] = await t.db
|
||||
.insert(users)
|
||||
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
|
||||
.returning();
|
||||
const admin = t.client(await createSession(t.db, sa!.id));
|
||||
const created = await admin.post('/api/admin/companies', {
|
||||
name, plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
const api = await t.register('Арам');
|
||||
await api.post(`/api/invites/${(created.body.managerUrl as string).split('/invite/')[1]}/accept`);
|
||||
const me = (await api.get('/api/me')).body;
|
||||
const chat = ++tgId;
|
||||
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, me.user.id));
|
||||
return { api, id: me.user.id as number, chat, slug: me.company.slug as string };
|
||||
};
|
||||
|
||||
const fromSeeker = (chat: number, part: object) => ({ update_id: ++updateId, ...part }) as never;
|
||||
const say = (chat: number, text: string) =>
|
||||
handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) },
|
||||
fromSeeker(chat, { message: { chat: { id: chat }, from: { id: chat, first_name: 'Анна', username: 'anna', language_code: 'ru' }, text } }));
|
||||
const press = (chat: number, data: string) =>
|
||||
handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config },
|
||||
fromSeeker(chat, { callback_query: { id: `cb${updateId}`, from: { id: chat, first_name: 'Анна', language_code: 'ru' }, data, message: { chat: { id: chat }, message_id: 1 } } }));
|
||||
const last = (chat: number) => seekerBot.inbox(chat).at(-1)!;
|
||||
|
||||
describe('адрес компании', () => {
|
||||
it('строится из названия; служебные адреса и мусор не проходят', () => {
|
||||
expect(slugify('Арарат Недвижимость')).toBe('ararat-nedvizhimost');
|
||||
expect(slugify('Հայ Տուն')).toBe('hay-tun');
|
||||
expect(validSlug('ararat')).toBe(true);
|
||||
expect([validSlug('leads'), validSlug('api'), validSlug('a'), validSlug('Арарат'), validSlug('-x-')]).toEqual([false, false, false, false, false]);
|
||||
});
|
||||
|
||||
it('у новой компании адрес из названия; руководитель меняет его, занятый и служебный — нельзя', async () => {
|
||||
const a = await company('Арарат Недвижимость');
|
||||
const b = await company('Арарат Недвижимость');
|
||||
expect(a.slug).toBe('ararat-nedvizhimost');
|
||||
expect(b.slug).toMatch(/^ararat-nedvizhimost-\d+$/);
|
||||
expect((await b.api.patch('/api/company', { slug: 'ararat-nedvizhimost' })).body.code).toBe('SLUG_TAKEN');
|
||||
expect((await b.api.patch('/api/company', { slug: 'leads' })).body.code).toBe('BAD_SLUG');
|
||||
expect((await b.api.patch('/api/company', { slug: 'Yerevan-Home' })).status).toBe(200);
|
||||
expect((await b.api.get('/api/me')).body.company.slug).toBe('yerevan-home');
|
||||
});
|
||||
});
|
||||
|
||||
describe('анкета в клиентском боте', () => {
|
||||
it('сумма бюджета понимается с валютой и без', () => {
|
||||
expect(parseBudget('600 000 драм')).toEqual({ amount: 600000, currency: 'AMD' });
|
||||
expect(parseBudget('1500$')).toEqual({ amount: 1500, currency: 'USD' });
|
||||
expect(parseBudget('350000')).toEqual({ amount: 350000, currency: 'AMD' });
|
||||
expect(parseBudget('120000', 'sale')).toEqual({ amount: 120000, currency: 'USD' });
|
||||
expect(parseBudget('дорого')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('соискатель отвечает на вопросы — клиент появляется в CRM у автора ссылки; затем подборка и запись на показ', async () => {
|
||||
const agent = await company('Дом Ереван');
|
||||
// Объекты агента: подходящий, «с детьми нельзя» и из другого района.
|
||||
const lead = async (title: string, over: object, features: object | null = null) => {
|
||||
const id = (await agent.api.post('/api/leads', { title, dealType: 'rent', propertyType: 'apartment', district: 'Кентрон', region: 'Ереван', price: 500_000, currency: 'AMD', rooms: 2, address: 'ул. Абовяна 10', url: 'https://www.list.am/item/1', ...over })).body.id as number;
|
||||
if (features) await t.db.update(leads).set({ features: features as never }).where(eq(leads.id, id));
|
||||
return id;
|
||||
};
|
||||
const good = await lead('Подходящая квартира', {}, {
|
||||
children: 'yes', pets: 'negotiable', amenities: [],
|
||||
attrs: [{ label: 'Можно с детьми', value: 'Да' }, { label: 'Можно с животными', value: 'По договоренности' }],
|
||||
});
|
||||
await lead('Без детей', {}, { children: 'no', amenities: [], attrs: [] });
|
||||
await lead('Другой район', { district: 'Аван' });
|
||||
|
||||
// Ссылку на анкету агент получает командой в основном боте.
|
||||
await handleUpdate({ db: t.db, tg: main.tg, config }, { update_id: ++updateId, message: { chat: { id: agent.chat }, from: { id: agent.chat }, text: '/anketa' } });
|
||||
const link = /https:\/\/t\.me\/client_bot\?start=(\S+)/.exec(main.inbox(agent.chat).at(-1)!.text)!;
|
||||
expect((await agent.api.get('/api/intake-link')).body.url).toBe(link[0]);
|
||||
|
||||
const seeker = 9500;
|
||||
await say(seeker, '/start');
|
||||
expect(last(seeker).text).toContain('по ссылке от вашего агента');
|
||||
await say(seeker, '/start wrong-token');
|
||||
expect(last(seeker).text).toContain('устарела');
|
||||
|
||||
// Первым делом бот спрашивает язык — сразу на трёх; пока язык не выбран, анкета не идёт.
|
||||
await say(seeker, `/start ${link[1]}`);
|
||||
expect(last(seeker).text).toBe('Выберите язык · Ընտրեք լեզուն · Choose your language');
|
||||
expect(last(seeker).keyboard.flat().map((b) => b.text)).toEqual(['Русский', 'Հայերեն', 'English']);
|
||||
await say(seeker, 'привет');
|
||||
expect(last(seeker).text).toContain('Choose your language');
|
||||
await press(seeker, 'q:lang:hy');
|
||||
expect(seekerBot.inbox(seeker).at(-2)!.text).toContain('հարցաթերթիկն է');
|
||||
expect(last(seeker).text).toBe('Ի՞նչ եք փնտրում');
|
||||
// Язык можно сменить по ходу командой /lang: анкета продолжается с того же вопроса.
|
||||
await say(seeker, '/lang');
|
||||
await press(seeker, 'q:lang:ru');
|
||||
expect(seekerBot.inbox(seeker).at(-2)!.text).toContain('Язык: русский');
|
||||
expect(last(seeker).text).toBe('Что вы ищете?');
|
||||
// Ответ текстом там, где нужны кнопки, анкету не двигает.
|
||||
await say(seeker, 'квартиру');
|
||||
expect(last(seeker).text).toContain('кнопкой');
|
||||
await press(seeker, 'q:deal:rent');
|
||||
await press(seeker, 'q:type:apartment');
|
||||
await press(seeker, 'q:place:Кентрон');
|
||||
expect(last(seeker).text).toContain('бюджет');
|
||||
await say(seeker, 'много');
|
||||
expect(last(seeker).text).toContain('Не понял сумму');
|
||||
await say(seeker, '600 000 драм');
|
||||
await press(seeker, 'q:rooms:2');
|
||||
expect(last(seeker).text).toBe('На какой срок нужна аренда?');
|
||||
await press(seeker, 'q:term:mid');
|
||||
await press(seeker, 'q:who:couple');
|
||||
await press(seeker, 'q:children:2');
|
||||
expect(last(seeker).text).toContain('Сколько лет детям');
|
||||
await say(seeker, '3 и 7 лет');
|
||||
// Животных нет — вопрос про их возраст пропускается.
|
||||
await press(seeker, 'q:pets:0');
|
||||
expect(last(seeker).text).toBe('Как вас зовут?');
|
||||
await press(seeker, 'q:name:keep');
|
||||
await say(seeker, 'abc');
|
||||
expect(last(seeker).text).toContain('не номер телефона');
|
||||
await say(seeker, '091 123 456');
|
||||
expect(last(seeker).text).toContain('анкета отправлена');
|
||||
|
||||
const [client] = await t.db.select().from(clients).where(eq(clients.assigneeId, agent.id));
|
||||
expect(client).toMatchObject({
|
||||
name: 'Анна', phone: '+37491123456', kind: 'tenant', source: 'Анкета в Telegram', rentTerm: 'mid', telegram: '@anna',
|
||||
request: { propertyTypes: ['apartment'], region: 'Ереван', districts: ['Кентрон'], priceTo: 600000, priceCurrency: 'AMD', rooms: [2] },
|
||||
household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } },
|
||||
});
|
||||
|
||||
// Фото после анкеты добавляется к карточке клиента.
|
||||
await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) },
|
||||
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }] } }));
|
||||
expect(last(seeker).text).toContain('Фото добавлено');
|
||||
expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1);
|
||||
|
||||
// Текст после анкеты не теряется: попадает в историю карточки, агенту приходит уведомление.
|
||||
await say(seeker, 'Удобно звонить после 18:00');
|
||||
expect(last(seeker).text).toContain('передали агенту');
|
||||
const notes = await t.db.select().from(events).where(and(eq(events.clientId, client!.id), eq(events.type, 'comment')));
|
||||
expect(notes.at(-1)!.text).toContain('после 18:00');
|
||||
await flush();
|
||||
expect(main.inbox(agent.chat).some((m) => m.text.includes('Удобно звонить после 18:00'))).toBe(true);
|
||||
|
||||
// Автору ссылки в основной бот: сводка и вопрос с кнопками.
|
||||
await flush();
|
||||
const prompt = main.inbox(agent.chat).find((m) => m.text.includes('Новая анкета'))!;
|
||||
expect(prompt.text).toContain('Новая анкета: Анна — арендатор');
|
||||
expect(prompt.keyboard.flat().map((b) => ('callback' in b ? b.callback : ''))).toEqual([`match:${client!.id}`, `nomatch:${client!.id}`]);
|
||||
|
||||
// «Показать объекты»: подходящий объект прикреплён, клиенту в его бот ушла ссылка на подборку.
|
||||
await handleUpdate({ db: t.db, tg: main.tg, config, clientTg: seekerBot.tg },
|
||||
{ update_id: ++updateId, callback_query: { id: 'm1', from: { id: agent.chat }, data: `match:${client!.id}`, message: { chat: { id: agent.chat }, message_id: 5 } } });
|
||||
expect(main.inbox(agent.chat).at(-1)!.text).toContain('Подобрано объектов: 1');
|
||||
expect(last(seeker).text).toContain('?lang=ru');
|
||||
const url = /https:\/\/app\.test\/([^\s/]+)\/([^\s?]+)/.exec(last(seeker).text)!;
|
||||
expect(url[1]).toBe(agent.slug);
|
||||
|
||||
// Страница клиента открывается без входа и не выдаёт адрес, телефон и ссылку на объявление.
|
||||
const page = await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`);
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.body).toMatchObject({ company: 'Дом Ереван', agent: 'Арам', client: 'Анна' });
|
||||
expect(page.body.objects).toMatchObject([{ id: good, district: 'Кентрон', price: 500000, viewingDay: null }]);
|
||||
// Текст — карточка по шаблону из характеристик объекта.
|
||||
expect(page.body.objects[0].text).toBe('🏡 Кентрон\n\n🛏 Комнат: 2\n\n🧑🧑🧒 Дети: Да\n🐶 Животные: По договоренности\n\n💵 Цена: 500.000 драм');
|
||||
expect(Object.keys(page.body.objects[0])).not.toEqual(expect.arrayContaining(['address', 'url', 'phone', 'description', 'title']));
|
||||
expect(JSON.stringify(page.body)).not.toContain('Подходящая квартира');
|
||||
expect(JSON.stringify(page.body)).not.toContain('Абовяна');
|
||||
expect((await t.client().get(`/api/public/pages/${url[1]}/wrong-token-123`)).body.code).toBe('PAGE_NOT_FOUND');
|
||||
expect((await t.client().get(`/api/public/pages/other-company/${url[2]}`)).status).toBe(404);
|
||||
|
||||
// Запись на показ: агент получает уведомление, запись видна в карточке клиента.
|
||||
const day = new Date(Date.now() + 3 * 86_400_000 + 4 * 3_600_000).toISOString().slice(0, 10);
|
||||
const book = (body: object) => t.client().post(`/api/public/pages/${url[1]}/${url[2]}/viewings`, body);
|
||||
expect((await book({ leadId: good, day: '2020-01-01' })).body.code).toBe('BAD_DAY');
|
||||
expect((await book({ leadId: 999_999, day })).body.code).toBe('OBJECT_NOT_FOUND');
|
||||
expect((await book({ leadId: good, day, note: 'После 18:00' })).body).toEqual({ day });
|
||||
await flush();
|
||||
expect(main.inbox(agent.chat).at(-1)!.text).toBe(`📅 Анна записался на показ ${day}: Подходящая квартира\nКомментарий: После 18:00`);
|
||||
// Повторная запись на тот же день уведомление не дублирует.
|
||||
const before = main.inbox(agent.chat).length;
|
||||
await book({ leadId: good, day, note: 'После 18:00' });
|
||||
await flush();
|
||||
expect(main.inbox(agent.chat)).toHaveLength(before);
|
||||
|
||||
const card = (await agent.api.get(`/api/clients/${client!.id}`)).body;
|
||||
expect(card.viewings).toMatchObject([{ leadId: good, day, title: 'Подходящая квартира', note: 'После 18:00' }]);
|
||||
expect(card.pageUrl).toBe(url[0]);
|
||||
// Свой домен компании: ссылка уходит на него, путь тот же; сохранение настроек руководителем домен не теряет.
|
||||
const [co] = await t.db.select().from(companies).where(eq(companies.slug, url[1]!));
|
||||
await t.db.update(companies).set({ settings: { ...co!.settings, clientHost: 'arev.yandp.tech' } }).where(eq(companies.id, co!.id));
|
||||
await agent.api.patch('/api/company', { agentsSeeAll: true });
|
||||
expect((await agent.api.get(`/api/clients/${client!.id}`)).body.pageUrl).toBe(`https://arev.yandp.tech/${url[1]}/${url[2]}`);
|
||||
await t.db.update(companies).set({ settings: co!.settings }).where(eq(companies.id, co!.id));
|
||||
expect(new Date(card.nextActionAt).toISOString()).toBe(new Date(`${day}T09:00:00+04:00`).toISOString());
|
||||
expect(card.events.map((e: any) => e.type)).toContain('viewing');
|
||||
expect((await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`)).body.objects[0].viewingDay).toBe(day);
|
||||
|
||||
// Агент закрывает страницу — ссылка больше не открывается.
|
||||
expect((await agent.api.del(`/api/clients/${client!.id}/page`)).status).toBe(200);
|
||||
expect((await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('страница из кабинета: кнопка с подбором прикрепляет подходящие объекты; чужой агент ссылку создать не может', async () => {
|
||||
const agent = await company('Кабинет');
|
||||
const stranger = await company('Чужие');
|
||||
const id = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'buyer', request: { districts: ['Арабкир'] } })).body.id as number;
|
||||
await agent.api.post('/api/leads', { title: 'Дом в Арабкире', dealType: 'sale', propertyType: 'house', district: 'Арабкир' });
|
||||
expect((await stranger.api.post(`/api/clients/${id}/page`, {})).status).toBe(404);
|
||||
const made = (await agent.api.post(`/api/clients/${id}/page`, { match: true })).body;
|
||||
expect(made.added).toBe(1);
|
||||
// Повторный запрос отдаёт ту же ссылку.
|
||||
expect((await agent.api.post(`/api/clients/${id}/page`, {})).body.url).toBe(made.url);
|
||||
const [, slug, token] = /app\.test\/(\S+)\/(\S+)/.exec(made.url)!;
|
||||
expect((await t.client().get(`/api/public/pages/${slug}/${token}`)).body.objects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('фото на странице клиента: полный набор из объявления, обрезаны сверху и отдаются самим сервисом', async () => {
|
||||
const agent = await company('Фото');
|
||||
const id = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'tenant' })).body.id as number;
|
||||
const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment', district: 'Кентрон' })).body.id as number;
|
||||
// Объект из объявления: в базе парсера у него только обложка.
|
||||
const [listing] = await t.db.insert(listings).values({ externalId: 'x777', url: 'https://www.list.am/item/777', title: 'Квартира', photos: ['https://img.list.am/f/1/1.webp'] }).returning();
|
||||
await t.db.update(leads).set({ listingId: listing!.id, photos: ['https://img.list.am/f/1/1.webp'], description: 'Рядом парк.' }).where(eq(leads.id, leadId));
|
||||
await agent.api.post(`/api/clients/${id}/leads`, { leadId, state: 'offered' });
|
||||
const { url } = (await agent.api.post(`/api/clients/${id}/page`, {})).body;
|
||||
const [, slug, token] = /app\.test\/(\S+)\/(\S+)/.exec(url)!;
|
||||
const objects = async () => (await t.client().get(`/api/public/pages/${slug}/${token}`)).body.objects;
|
||||
expect((await objects())[0].photos).toEqual([`/api/public/pages/${slug}/${token}/photos/${leadId}/0`]);
|
||||
|
||||
// Фоновая задача догружает все фото объявления и сохраняет текст; второй запуск ничего не делает.
|
||||
const gallery = ['https://img.list.am/f/1/1.webp', 'https://img.list.am/f/2/2.webp', 'https://img.list.am/f/3/3.webp'];
|
||||
let calls = 0;
|
||||
const source = { gallery: async (ext: string) => { calls += 1; expect(ext).toBe('x777'); return { photos: gallery, features: { amenities: [], attrs: [{ label: 'Ремонт', value: 'Евроремонт' }] } }; } };
|
||||
// Вместе с ним готовятся объекты прошлых сценариев (у них объявления нет — только текст).
|
||||
expect(await runShowcaseJobs({ db: t.db, config, source, limit: 50 })).toBeGreaterThanOrEqual(1);
|
||||
expect(await runShowcaseJobs({ db: t.db, config, source })).toBe(0);
|
||||
expect(calls).toBe(1);
|
||||
const [object] = await objects();
|
||||
expect(object.photos).toHaveLength(3);
|
||||
// Характеристики, которых не было в базе, взяты со страницы и попали в текст.
|
||||
expect(object.text).toContain('🆕 Состояние: Евроремонт');
|
||||
expect(JSON.stringify(object)).not.toContain('list.am');
|
||||
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.photos).toEqual(gallery);
|
||||
|
||||
// Фото: JPEG, сверху срезано 8 % высоты (200 → 184).
|
||||
const photo = await t.app.inject({ method: 'GET', url: object.photos[2] });
|
||||
expect(photo.statusCode).toBe(200);
|
||||
expect(photo.headers['content-type']).toBe('image/jpeg');
|
||||
expect(await sharp(photo.rawPayload).metadata()).toMatchObject({ format: 'jpeg', width: 100, height: 184 });
|
||||
expect((await t.app.inject({ method: 'GET', url: `/api/public/pages/${slug}/${token}/photos/${leadId}/9` })).statusCode).toBe(404);
|
||||
expect((await t.app.inject({ method: 'GET', url: `/api/public/pages/${slug}/wrong-token-12/photos/${leadId}/0` })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('фото: снятое объявление сразу получает отметку; устаревшие фото загружаются заново при добавлении клиенту', async () => {
|
||||
const agent = await company('Обновление фото');
|
||||
const id = (await agent.api.post('/api/clients', { name: 'Ани', kind: 'tenant' })).body.id as number;
|
||||
const make = async (ext: string) => {
|
||||
const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment' })).body.id as number;
|
||||
const [listing] = await t.db.insert(listings).values({ externalId: ext, url: `https://www.list.am/item/${ext}`, title: 'Квартира', photos: ['https://img.list.am/f/1/1.webp'] }).returning();
|
||||
await t.db.update(leads).set({ listingId: listing!.id, photos: listing!.photos }).where(eq(leads.id, leadId));
|
||||
return { leadId, listingId: listing!.id };
|
||||
};
|
||||
const gone = await make('x901');
|
||||
const old = await make('x902');
|
||||
await t.db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, gone.leadId));
|
||||
const source = { gallery: async (ext: string) => (ext === 'x901' ? { photos: [], removed: true } : { photos: ['https://img.list.am/f/9/9.webp'] }) };
|
||||
await runShowcaseJobs({ db: t.db, config, source, limit: 50 });
|
||||
const [listing] = await t.db.select().from(listings).where(eq(listings.id, gone.listingId));
|
||||
expect(listing!.active).toBe(false);
|
||||
expect(listing!.removedAt).not.toBeNull();
|
||||
|
||||
// Фото загружены двое суток назад — при добавлении клиенту загружаются заново.
|
||||
await t.db.update(leads).set({ galleryAt: new Date(Date.now() - 2 * 86_400_000) }).where(eq(leads.id, old.leadId));
|
||||
await agent.api.post(`/api/clients/${id}/leads`, { leadId: old.leadId, state: 'offered' });
|
||||
expect((await t.db.select().from(leads).where(eq(leads.id, old.leadId)))[0]!.galleryAt).toBeNull();
|
||||
await runShowcaseJobs({ db: t.db, config, source, limit: 50 });
|
||||
expect((await t.db.select().from(leads).where(eq(leads.id, old.leadId)))[0]!.photos).toEqual(['https://img.list.am/f/9/9.webp']);
|
||||
});
|
||||
|
||||
it('карточка объекта: телефон собственника вносит агент; фото и текст из той же логики', async () => {
|
||||
const agent = await company('Карточка');
|
||||
const [listing] = await t.db.insert(listings).values({ externalId: 'x888', url: 'https://www.list.am/item/888', title: 'Квартира', photos: ['https://img.list.am/f/8/8.webp'] }).returning();
|
||||
const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment', district: 'Аван', price: 900, currency: 'USD', rooms: 1 })).body.id as number;
|
||||
await t.db.update(leads).set({ listingId: listing!.id, photos: ['https://img.list.am/f/8/8.webp'] }).where(eq(leads.id, leadId));
|
||||
|
||||
// Телефон: приводится к общему виду, попадает и в общую базу объявлений; мусор не принимается.
|
||||
expect((await agent.api.post(`/api/leads/${leadId}/phone`, { phone: 'позвоните' })).body.code).toBe('BAD_PHONE');
|
||||
expect((await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '091 55-66-77' })).status).toBe(200);
|
||||
let card = (await agent.api.get(`/api/leads/${leadId}`)).body;
|
||||
expect(card.phone).toBe('+37491556677');
|
||||
expect(card.events.some((e: any) => e.type === 'phone' && e.newValue === '+37491556677')).toBe(true);
|
||||
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBe('+37491556677');
|
||||
// Компания поправила свой вариант — в общей базе он заменился; пустая строка убирает и вариант, и номер.
|
||||
await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '077 000 111' });
|
||||
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBe('+37477000111');
|
||||
await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '' });
|
||||
expect((await agent.api.get(`/api/leads/${leadId}`)).body).toMatchObject({ phone: null, phoneVariants: [] });
|
||||
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBeNull();
|
||||
// Другая компания номер этому лиду поставить не может.
|
||||
const stranger = await company('Посторонние');
|
||||
expect((await stranger.api.post(`/api/leads/${leadId}/phone`, { phone: '091 55-66-77' })).status).toBe(404);
|
||||
|
||||
// Карточка: текст для клиента есть сразу; открытие ставит объект в очередь на догрузку фото.
|
||||
expect(card.clientText).toBe('🏡 Аван\n\n🛏 Комнат: 1\n\n💵 Цена: $900');
|
||||
expect(card).toMatchObject({ gallery: 'pending', photos: [`/api/leads/${leadId}/photos/0`] });
|
||||
const source = { gallery: async () => ({ photos: ['https://img.list.am/f/8/8.webp', 'https://img.list.am/f/9/9.webp'] }) };
|
||||
expect(await runShowcaseJobs({ db: t.db, config, source, limit: 50 })).toBeGreaterThanOrEqual(1);
|
||||
card = (await agent.api.get(`/api/leads/${leadId}`)).body;
|
||||
expect(card).toMatchObject({ gallery: 'ready', photos: [`/api/leads/${leadId}/photos/0`, `/api/leads/${leadId}/photos/1`] });
|
||||
|
||||
// Фото в карточке — обрезанное; без входа и из чужой компании оно недоступно.
|
||||
const token = (await t.client().post('/api/auth/login', { email: (await agent.api.get('/api/me')).body.user.email, password: 'password-123' })).body.token as string;
|
||||
const photo = await t.app.inject({ method: 'GET', url: card.photos[1], headers: { authorization: `Bearer ${token}` } });
|
||||
expect(await sharp(photo.rawPayload).metadata()).toMatchObject({ format: 'jpeg', height: 184 });
|
||||
expect((await t.app.inject({ method: 'GET', url: card.photos[1] })).statusCode).toBe(401);
|
||||
expect((await stranger.api.get(card.photos[1])).status).toBe(404);
|
||||
});
|
||||
|
||||
it('в общей базе все варианты номера: совпал у двух компаний — 80 %, у трёх — 100 %', async () => {
|
||||
const [listing] = await t.db.insert(listings).values({ externalId: 'x999', url: 'https://www.list.am/item/999', title: 'Дом' }).returning();
|
||||
const agents = [];
|
||||
for (const name of ['Первые', 'Вторые', 'Третьи', 'Четвёртые']) {
|
||||
const a = await company(name);
|
||||
const leadId = (await a.api.post('/api/leads', { title: 'Дом', dealType: 'sale', propertyType: 'house' })).body.id as number;
|
||||
await t.db.update(leads).set({ listingId: listing!.id }).where(eq(leads.id, leadId));
|
||||
agents.push({ ...a, leadId });
|
||||
}
|
||||
const [a, b, c, d] = agents as [typeof agents[0], typeof agents[0], typeof agents[0], typeof agents[0]];
|
||||
const set = (x: typeof a, phone: string) => x.api.post(`/api/leads/${x.leadId}/phone`, { phone });
|
||||
const variants = async (x: typeof a) => (await x.api.get(`/api/leads/${x.leadId}`)).body.phoneVariants;
|
||||
const base = async () => (await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone;
|
||||
|
||||
// Первый агент ошибся в цифре: номер один, никем не подтверждён.
|
||||
await set(a, '091 111 110');
|
||||
expect(await variants(a)).toEqual([{ phone: '+37491111110', votes: 1, confidence: null }]);
|
||||
// Двое указали верный — в базе оба варианта, верный впереди с точностью 80 %.
|
||||
await set(b, '091 111 111');
|
||||
await set(c, '+374 91 111111');
|
||||
expect(await variants(d)).toEqual([
|
||||
{ phone: '+37491111111', votes: 2, confidence: 80 },
|
||||
{ phone: '+37491111110', votes: 1, confidence: null },
|
||||
]);
|
||||
expect(await base()).toBe('+37491111111');
|
||||
// Третье совпадение — 100 %. Четвёртая компания видит варианты ещё до того, как внесла свой.
|
||||
await set(d, '091111111');
|
||||
expect((await variants(a))[0]).toEqual({ phone: '+37491111111', votes: 3, confidence: 100 });
|
||||
// Первый исправил свой вариант: ошибочный номер из базы исчез.
|
||||
await set(a, '091 111 111');
|
||||
expect(await variants(a)).toEqual([{ phone: '+37491111111', votes: 4, confidence: 100 }]);
|
||||
|
||||
// Агент, который объект не ведёт, вариантов не видит — как и самого телефона.
|
||||
const invite = (await a.api.post('/api/invites')).body.token as string;
|
||||
const other = await t.register('Коллега');
|
||||
await other.post(`/api/invites/${invite}/accept`);
|
||||
await a.api.patch('/api/company', { agentsSeeAll: true });
|
||||
expect((await other.get(`/api/leads/${a.leadId}`)).body).toMatchObject({ phone: null, phoneVariants: [] });
|
||||
});
|
||||
|
||||
it('текст по шаблону и дополнение моделью; при сбое модели остаётся шаблон', async () => {
|
||||
const lead = {
|
||||
title: 'Квартира', description: 'Тихий двор, рядом метро.', dealType: 'rent', district: 'Арабкир', region: 'Ереван', price: 3000, currency: 'USD',
|
||||
rooms: 3, area: 84, floor: 2, floorsTotal: 9,
|
||||
features: {
|
||||
amenities: [{ group: 'Бытовая техника', items: ['Утюг', 'Холодильник'] }, { group: 'Удобства', items: ['Кондиционер'] }, { group: 'Виды из окон', items: ['Вид на двор'] }],
|
||||
attrs: [{ label: 'Новостройка', value: 'Да' }, { label: 'Тип здания', value: 'Монолит' }, { label: 'Ремонт', value: 'Евроремонт' }, { label: 'Количество санузлов', value: '2' }, { label: 'Статус доступности', value: 'Свободно сейчас' }],
|
||||
},
|
||||
};
|
||||
const card = renderShowcaseText(lead);
|
||||
expect(card).toBe(['🏡 Арабкир', '', '🏢 Тип дома: Новостройка, Монолит', '🆕 Состояние: Евроремонт', '', '📐 Площадь: 84 м²', '🛏 Комнат: 3', '🚿 Санузлов: 2', '🏙 Этаж: 2/9', '',
|
||||
'⚙️ Оснащение:', '✅ Кондиционер', '✅ Холодильник', '✅ Утюг', '', '💵 Цена: $3.000', '', '📅 Готова к заселению'].join('\n'));
|
||||
expect(renderShowcaseText({ ...lead, features: null, price: null, district: null })).toBe('🏡 Ереван\n\n📐 Площадь: 84 м²\n🛏 Комнат: 3\n🏙 Этаж: 2/9');
|
||||
|
||||
const keyed = { ...config, openRouterKey: 'key' };
|
||||
const reply = (content: string) => (async () => new Response(JSON.stringify({ choices: [{ message: { content } }] }))) as unknown as typeof fetch;
|
||||
expect(await showcaseText(keyed, lead, reply(`${card}\n\nТихий двор, метро рядом.`))).toBe(`${card}\n\nТихий двор, метро рядом.`);
|
||||
// Модель вернула не карточку или упала — берём шаблон.
|
||||
expect(await showcaseText(keyed, lead, reply('Вот ваш текст: ...'))).toBe(card);
|
||||
expect(await showcaseText(keyed, lead, (async () => new Response('', { status: 500 })) as unknown as typeof fetch)).toBe(card);
|
||||
// Без ключа модель не вызывается вовсе.
|
||||
expect(await showcaseText(config, lead, (async () => { throw new Error('не должен вызываться'); }) as unknown as typeof fetch)).toBe(card);
|
||||
});
|
||||
|
||||
it('без клиентского бота ссылка на анкету не выдаётся', async () => {
|
||||
const agent = await company('Без бота');
|
||||
Object.assign(t.config, { clientBotToken: undefined });
|
||||
expect((await agent.api.get('/api/intake-link')).body.url).toBeNull();
|
||||
Object.assign(t.config, { clientBotToken: 'client:token' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
|
||||
// Фото для страницы клиента (логика из бота list-am-bot, image/crop.py): фото скачивается,
|
||||
// сверху срезается полоса в процентах от высоты, результат отдаётся в JPEG и кладётся на диск,
|
||||
// чтобы второй раз не скачивать и не обрезать.
|
||||
|
||||
export type ImageFetcher = (url: string) => Promise<Buffer>;
|
||||
|
||||
export const fetchImage: ImageFetcher = async (url) => {
|
||||
const res = await fetch(url, {
|
||||
headers: { referer: 'https://www.list.am/', 'user-agent': 'Mozilla/5.0 (compatible; LeadRadar/1.0)' },
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Фото не скачалось: ${res.status}`);
|
||||
const data = Buffer.from(await res.arrayBuffer());
|
||||
if (data.length > 15 * 1024 * 1024) throw new Error('Фото слишком большое');
|
||||
return data;
|
||||
};
|
||||
|
||||
/** Срезать сверху `percent` % высоты и вернуть JPEG. */
|
||||
export async function cropTop(data: Buffer, percent: number): Promise<Buffer> {
|
||||
const image = sharp(data).rotate();
|
||||
const { width, height } = await image.metadata();
|
||||
const cut = width && height ? Math.floor((height * percent) / 100) : 0;
|
||||
const cropped = cut > 0 && cut < height! ? image.extract({ left: 0, top: cut, width: width!, height: height! - cut }) : image;
|
||||
return cropped.jpeg({ quality: 88, mozjpeg: true }).toBuffer();
|
||||
}
|
||||
|
||||
/** Обработанное фото: с диска, если уже готово, иначе скачать, обрезать и сохранить. */
|
||||
export async function croppedPhoto(config: Config, url: string, fetcher: ImageFetcher = fetchImage): Promise<Buffer> {
|
||||
const name = `${createHash('sha256').update(`${url}|${config.cropTopPercent}`).digest('hex').slice(0, 32)}.jpg`;
|
||||
const dir = join(config.mediaDir, name.slice(0, 2));
|
||||
const file = join(dir, name);
|
||||
const cached = await readFile(file).catch(() => null);
|
||||
if (cached) return cached;
|
||||
const result = await cropTop(await fetcher(url), config.cropTopPercent);
|
||||
await mkdir(dir, { recursive: true });
|
||||
// Пишем во временный файл и переименовываем: параллельный запрос не увидит половину файла.
|
||||
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, result);
|
||||
await rename(tmp, file);
|
||||
return result;
|
||||
}
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user