Backoffice stage 4: partners (agencies, venues) and sales CRM

- CRM: configurable pipeline (default: New → Contact → Meeting → Negotiation →
  Connected / Lost), drag-and-drop board, lead card with call/meeting/note history,
  photos and files, tasks with Telegram reminders at the due time, map of venues
  (OpenStreetMap via Leaflet), lost reason. Sales reps see only their own leads.
- "Connected" in one click: a venue becomes a club card partner with a one-time
  owner link to the work bot; an agency gets a code and a client link. The sales rep
  is recorded on the partner for the bonus.
- Sales bonus formula in settings (per venue, per agency, % of revenue from the
  rep's agency clients for N months); defaults are 0 until the owner sets them.
- Agencies (superadmin): clients, gift subscriptions, reward by month, payouts,
  client link, edit. Venues: offers by membership level (5 languages), staff,
  card checks, 8-week activity, owner link, map point.
- Schema: SalesStage/SalesLead/SalesActivity/SalesTask; salesId on Agency and
  Partner; lat/lng/phone/note on Partner; agencyId on Payout.
- Tests for stage rules, conversion, task reminders and bonus; dev:crm-demo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 03:02:03 +04:00
1 parent a848bb13e2
commit ac786cc5cc
30 files changed
+4311 -11

No files matched your search

+15 -1
View File
@@ -4,7 +4,7 @@
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { autoAssign, chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { autoAssign, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
@@ -286,6 +286,18 @@ async function handle(event: DomainEvent) {
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
return;
}
case 'sales.taskDue': {
const task = await db.salesTask.findUnique({ where: { id: event.taskId }, include: { assignee: true, lead: true } });
if (!task || task.doneAt || !task.assignee.telegramId || !task.assignee.active) return;
const link =
BACKOFFICE_URL.startsWith('https://') && task.leadId ? [{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/crm/${task.leadId}` }] : [];
await proBot.send({
chatId: task.assignee.telegramId,
text: `⏰ <b>${escapeHtml(task.title)}</b>${task.lead ? `\n${escapeHtml(task.lead.name)}${task.lead.phone ? ` · ${escapeHtml(task.lead.phone)}` : ''}` : ''}`,
buttons: link.length ? [link] : [],
});
return;
}
case 'chat.assigned': {
const [thread, assignee, by] = await Promise.all([
db.chatThread.findUnique({ where: { id: event.threadId }, include: { client: { include: { user: true } }, provider: true } }),
@@ -367,6 +379,8 @@ async function tick() {
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
// задачи продажников, у которых наступил срок
await dueSalesTasks(ctx);
} catch (e) {
console.error('[worker] tick failed', e);
} finally {