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:
1 parent
a848bb13e2
commit
ac786cc5cc
30 files changed
+4311
-11
No files matched your search
@@ -15,7 +15,8 @@
|
||||
"dev:initdata": "tsx scripts/dev-initdata.ts",
|
||||
"dev:demo": "tsx scripts/dev-demo.ts",
|
||||
"dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts",
|
||||
"dev:chats-demo": "tsx scripts/dev-chats-demo.ts"
|
||||
"dev:chats-demo": "tsx scripts/dev-chats-demo.ts",
|
||||
"dev:crm-demo": "tsx scripts/dev-crm-demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1152.0",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Локальная разработка: демо для CRM продажника и раздела «Партнёры» (этап 4).
|
||||
* pnpm --filter @barev/api dev:crm-demo
|
||||
* Создаёт продажника «Давид Саргсян», лиды по этапам с точками на карте Еревана, историю и задачи.
|
||||
* Повторный запуск пересоздаёт демо-лиды (по имени продажника).
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { env } from '../src/env.ts';
|
||||
|
||||
if (env.isProd) throw new Error('Только для разработки');
|
||||
|
||||
const db = getDb();
|
||||
const H = 3600_000;
|
||||
const now = Date.now();
|
||||
const ago = (h: number) => new Date(now - h * H);
|
||||
|
||||
const sales =
|
||||
(await db.adminUser.findFirst({ where: { name: 'Давид Саргсян' } })) ?? (await db.adminUser.create({ data: { name: 'Давид Саргсян', roles: ['SALES'] } }));
|
||||
const owner = await db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } });
|
||||
|
||||
await db.salesLead.deleteMany({ where: { ownerId: sales.id, partnerId: null, agencyId: null } });
|
||||
await db.salesTask.deleteMany({ where: { assigneeId: sales.id } });
|
||||
|
||||
const stages = Object.fromEntries((await db.salesStage.findMany()).map((s) => [s.id, s]));
|
||||
const S = { new: 'stg_new', contact: 'stg_contact', meeting: 'stg_meeting', talks: 'stg_talks', lost: 'stg_lost' } as const;
|
||||
if (!stages[S.new]) throw new Error('Нет этапов воронки — примените миграции');
|
||||
|
||||
type Act = { type: 'CALL' | 'MEETING' | 'NOTE' | 'STAGE'; text: string; h: number };
|
||||
async function lead(
|
||||
data: { kind: 'VENUE' | 'AGENCY'; name: string; category?: string; contactName?: string; phone?: string; telegram?: string; address?: string; lat?: number; lng?: number; note?: string; lostReason?: string },
|
||||
stageId: string,
|
||||
stageH: number,
|
||||
acts: Act[] = [],
|
||||
tasks: { title: string; inH: number }[] = [],
|
||||
) {
|
||||
const l = await db.salesLead.create({ data: { ...data, stageId, stageChangedAt: ago(stageH), ownerId: sales.id, createdAt: ago(Math.max(stageH, ...acts.map((a) => a.h)) + 2) } });
|
||||
for (const a of acts) await db.salesActivity.create({ data: { leadId: l.id, authorId: sales.id, type: a.type, text: a.text, createdAt: ago(a.h) } });
|
||||
for (const t of tasks) await db.salesTask.create({ data: { leadId: l.id, assigneeId: sales.id, title: t.title, dueAt: new Date(now + t.inH * H), createdById: sales.id } });
|
||||
return l;
|
||||
}
|
||||
|
||||
await lead({ kind: 'VENUE', name: 'Gemini Coffee', category: 'cafe', address: 'Абовяна 12', lat: 40.1835, lng: 44.5181, phone: '+374 91 222 333' }, S.new, 20);
|
||||
await lead({ kind: 'VENUE', name: 'Tumanyan Shaurma', category: 'restaurant', address: 'Туманяна 40', lat: 40.1868, lng: 44.5108 }, S.new, 6, [], [{ title: 'Позвонить', inH: 3 }]);
|
||||
await lead(
|
||||
{ kind: 'VENUE', name: 'Ankyun Wine Bar', category: 'restaurant', contactName: 'Ани, управляющая', phone: '+374 93 555 010', telegram: '@ankyun_bar', address: 'Сарьяна 8', lat: 40.1822, lng: 44.5064 },
|
||||
S.contact,
|
||||
50,
|
||||
[
|
||||
{ type: 'CALL', text: 'Интересно, просят условия письменно. Собственник решает по средам.', h: 50 },
|
||||
{ type: 'STAGE', text: 'Контакт', h: 50 },
|
||||
],
|
||||
[{ title: 'Отправить условия', inH: -5 }],
|
||||
);
|
||||
await lead(
|
||||
{ kind: 'VENUE', name: 'Lavash Restaurant', category: 'restaurant', contactName: 'Армен, собственник', phone: '+374 99 100 200', address: 'Туманяна 21', lat: 40.1859, lng: 44.5137 },
|
||||
S.meeting,
|
||||
30,
|
||||
[
|
||||
{ type: 'CALL', text: 'Договорились о встрече в четверг 15:00', h: 80 },
|
||||
{ type: 'MEETING', text: 'Готовы дать −10% по карте и десерт в подарок для Elite. Нужен макет наклейки на дверь.', h: 30 },
|
||||
],
|
||||
[{ title: 'Привезти наклейку и согласовать предложения', inH: 26 }],
|
||||
);
|
||||
await lead(
|
||||
{ kind: 'VENUE', name: 'Salon Nuar', category: 'beauty', contactName: 'Нуне', phone: '+374 77 300 400', address: 'Пушкина 33', lat: 40.1801, lng: 44.5089 },
|
||||
S.talks,
|
||||
12,
|
||||
[{ type: 'MEETING', text: 'Хотят −15% на первое посещение, обсуждают с партнёром.', h: 12 }],
|
||||
[{ title: 'Узнать решение', inH: 48 }],
|
||||
);
|
||||
await lead({ kind: 'AGENCY', name: 'Yerevan Homes', contactName: 'Лусине, директор', phone: '+374 98 777 888', telegram: '@yerevanhomes' }, S.contact, 26, [{ type: 'CALL', text: 'Каждый месяц ~15 клиентов из РФ. Интересует подарочный месяц для клиентов.', h: 26 }], [{ title: 'Встреча в офисе', inH: 20 }]);
|
||||
await lead({ kind: 'AGENCY', name: 'Relocate.am', contactName: 'Гор', telegram: '@relocate_am' }, S.talks, 70, [{ type: 'MEETING', text: 'Готовы, ждут ставку 12% вместо 10%.', h: 70 }]);
|
||||
await lead({ kind: 'VENUE', name: 'Kond Bakery', category: 'cafe', address: 'Конд', lat: 40.1773, lng: 44.4991, lostReason: 'Не устроили условия' }, S.lost, 100, [{ type: 'STAGE', text: 'Отказ: Не устроили условия', h: 100 }]);
|
||||
|
||||
// подключённые: точка на карте у существующих партнёров, продажник — у части
|
||||
const partners = await db.partner.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
const spots: [number, number][] = [
|
||||
[40.1811, 44.5136],
|
||||
[40.1776, 44.5126],
|
||||
[40.1893, 44.5153],
|
||||
];
|
||||
for (const [i, p] of partners.entries()) {
|
||||
const s = spots[i % spots.length]!;
|
||||
await db.partner.update({ where: { id: p.id }, data: { lat: s[0] + i * 0.0012, lng: s[1] - i * 0.0009, salesId: i === 0 ? sales.id : (p.salesId ?? owner?.id ?? null) } });
|
||||
}
|
||||
const agency = await db.agency.findFirst({ orderBy: { createdAt: 'asc' } });
|
||||
if (agency) await db.agency.update({ where: { id: agency.id }, data: { salesId: sales.id } });
|
||||
// задача без лида
|
||||
await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Обойти Каскад: кафе без партнёрства', dueAt: new Date(now + 30 * H), createdById: sales.id } });
|
||||
|
||||
console.log(`CRM демо готово: продажник ${sales.name}, лидов 8, партнёров на карте ${partners.length}.`);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,257 @@
|
||||
import { convertLead, moveLead, salesBonus, getSettings } from '@barev/services';
|
||||
import type { Prisma } from '@barev/db';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
|
||||
|
||||
/** CRM ведут продажники; суперадмин видит всех и настраивает воронку */
|
||||
const salesProcedure = rolesProcedure('SALES');
|
||||
const CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
|
||||
|
||||
/** Продажник видит только свои лиды */
|
||||
function scope(ctx: { isSuper: boolean; admin: { id: string } }, ownerId?: string): Prisma.SalesLeadWhereInput {
|
||||
if (!ctx.isSuper) return { ownerId: ctx.admin.id };
|
||||
return ownerId ? { ownerId: ownerId === 'none' ? null : ownerId } : {};
|
||||
}
|
||||
|
||||
async function ownLead(ctx: { db: import('@barev/db').Db; isSuper: boolean; admin: { id: string } }, id: string) {
|
||||
const lead = await ctx.db.salesLead.findUnique({ where: { id } });
|
||||
if (!lead || (!ctx.isSuper && lead.ownerId !== ctx.admin.id)) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
return lead;
|
||||
}
|
||||
|
||||
const leadInput = z.object({
|
||||
kind: z.enum(['VENUE', 'AGENCY']),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
category: z.enum(CATEGORIES).nullable().optional(),
|
||||
contactName: z.string().trim().max(120).nullable().optional(),
|
||||
phone: z.string().trim().max(40).nullable().optional(),
|
||||
telegram: z.string().trim().max(80).nullable().optional(),
|
||||
address: z.string().trim().max(200).nullable().optional(),
|
||||
lat: z.number().min(-90).max(90).nullable().optional(),
|
||||
lng: z.number().min(-180).max(180).nullable().optional(),
|
||||
note: z.string().max(4000).nullable().optional(),
|
||||
ownerId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const monthRange = (key: string) => {
|
||||
const [y, m] = key.split('-').map(Number);
|
||||
return { from: new Date(Date.UTC(y!, m! - 1, 1) - 4 * 3600_000), to: new Date(Date.UTC(y!, m!, 1) - 4 * 3600_000) };
|
||||
};
|
||||
|
||||
export const boCrmRouter = router({
|
||||
stages: salesProcedure.query(({ ctx }) => ctx.db.salesStage.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' } })),
|
||||
|
||||
/** Воронка: этапы целиком (порядок, названия, цвета); этап с лидами не удаляется, а скрывается */
|
||||
saveStages: superProcedure
|
||||
.input(z.array(z.object({ id: z.string().optional(), name: z.string().trim().min(1).max(40), kind: z.enum(['OPEN', 'WON', 'LOST']), color: z.string().max(12) })).min(2).max(12))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!input.some((s) => s.kind === 'WON') || !input.some((s) => s.kind === 'OPEN')) throw new TRPCError({ code: 'BAD_REQUEST', message: 'STAGES_INVALID' });
|
||||
const keep = new Set(input.map((s) => s.id).filter(Boolean));
|
||||
const all = await ctx.db.salesStage.findMany({ include: { _count: { select: { leads: true } } } });
|
||||
for (const s of all) {
|
||||
if (keep.has(s.id)) continue;
|
||||
if (s._count.leads) await ctx.db.salesStage.update({ where: { id: s.id }, data: { active: false } });
|
||||
else await ctx.db.salesStage.delete({ where: { id: s.id } });
|
||||
}
|
||||
for (const [i, s] of input.entries()) {
|
||||
if (s.id) await ctx.db.salesStage.update({ where: { id: s.id }, data: { name: s.name, kind: s.kind, color: s.color, sortOrder: i, active: true } });
|
||||
else await ctx.db.salesStage.create({ data: { name: s.name, kind: s.kind, color: s.color, sortOrder: i } });
|
||||
}
|
||||
}),
|
||||
|
||||
/** Доска: лиды по этапам */
|
||||
board: salesProcedure
|
||||
.input(z.object({ kind: z.enum(['VENUE', 'AGENCY']).optional(), ownerId: z.string().optional(), search: z.string().trim().max(100).optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const leads = await ctx.db.salesLead.findMany({
|
||||
where: {
|
||||
...scope(ctx, input.ownerId),
|
||||
...(input.kind ? { kind: input.kind } : {}),
|
||||
...(input.search ? { OR: [{ name: { contains: input.search, mode: 'insensitive' } }, { contactName: { contains: input.search, mode: 'insensitive' } }, { phone: { contains: input.search } }, { address: { contains: input.search, mode: 'insensitive' } }] } : {}),
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true } },
|
||||
tasks: { where: { doneAt: null }, orderBy: { dueAt: 'asc' }, take: 1 },
|
||||
activities: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true, type: true } },
|
||||
},
|
||||
orderBy: { stageChangedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
return leads.map((l) => ({
|
||||
id: l.id,
|
||||
kind: l.kind,
|
||||
name: l.name,
|
||||
category: l.category,
|
||||
contactName: l.contactName,
|
||||
phone: l.phone,
|
||||
address: l.address,
|
||||
stageId: l.stageId,
|
||||
stageChangedAt: l.stageChangedAt,
|
||||
owner: l.owner,
|
||||
converted: !!(l.partnerId || l.agencyId),
|
||||
nextTask: l.tasks[0] ? { title: l.tasks[0].title, dueAt: l.tasks[0].dueAt, overdue: l.tasks[0].dueAt < now } : null,
|
||||
lastTouch: l.activities[0]?.createdAt ?? l.createdAt,
|
||||
}));
|
||||
}),
|
||||
|
||||
get: salesProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
await ownLead(ctx, input.id);
|
||||
const l = await ctx.db.salesLead.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
stage: true,
|
||||
owner: { select: { id: true, name: true } },
|
||||
partner: { select: { id: true, name: true, ownerInviteCode: true, _count: { select: { members: true } } } },
|
||||
agency: { select: { id: true, name: true, code: true, _count: { select: { clients: true } } } },
|
||||
activities: { orderBy: { createdAt: 'desc' }, take: 200 },
|
||||
tasks: { orderBy: [{ doneAt: 'asc' }, { dueAt: 'asc' }], include: { assignee: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
const authorIds = [...new Set(l.activities.map((a) => a.authorId).filter((x): x is string => !!x))];
|
||||
const authors = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: authorIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
|
||||
return {
|
||||
...l,
|
||||
activities: l.activities.map((a) => ({ id: a.id, type: a.type, text: a.text, file: a.fileKey ? { name: a.fileName, mime: a.mime, size: a.size } : null, author: a.authorId ? (authors.get(a.authorId) ?? null) : null, at: a.createdAt })),
|
||||
tasks: l.tasks.map((t) => ({ id: t.id, title: t.title, dueAt: t.dueAt, doneAt: t.doneAt, assignee: t.assignee.name })),
|
||||
partner: l.partner
|
||||
? { id: l.partner.id, name: l.partner.name, ownerLinked: l.partner._count.members > 0, ownerInviteUrl: l.partner.ownerInviteCode ? `https://t.me/${process.env.PRO_BOT_USERNAME ?? 'barev_pro_bot'}?start=po-${l.partner.ownerInviteCode}` : null }
|
||||
: null,
|
||||
agency: l.agency ? { id: l.agency.id, name: l.agency.name, code: l.agency.code, clients: l.agency._count.clients } : null,
|
||||
};
|
||||
}),
|
||||
|
||||
create: salesProcedure.input(leadInput).mutation(async ({ ctx, input }) => {
|
||||
const first = await ctx.db.salesStage.findFirst({ where: { active: true, kind: 'OPEN' }, orderBy: { sortOrder: 'asc' } });
|
||||
if (!first) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'STAGES_INVALID' });
|
||||
const { ownerId, ...data } = input;
|
||||
const lead = await ctx.db.salesLead.create({ data: { ...data, stageId: first.id, ownerId: ctx.isSuper ? (ownerId === undefined ? ctx.admin.id : ownerId) : ctx.admin.id } });
|
||||
return { id: lead.id };
|
||||
}),
|
||||
|
||||
update: salesProcedure.input(leadInput.partial().extend({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await ownLead(ctx, input.id);
|
||||
const { id, ownerId, kind: _kind, ...data } = input;
|
||||
await ctx.db.salesLead.update({ where: { id }, data: { ...data, ...(ctx.isSuper && ownerId !== undefined ? { ownerId } : {}) } });
|
||||
}),
|
||||
|
||||
remove: salesProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const l = await ownLead(ctx, input.id);
|
||||
if (l.partnerId || l.agencyId) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ALREADY_CONVERTED' });
|
||||
await ctx.db.salesLead.delete({ where: { id: input.id } });
|
||||
}),
|
||||
|
||||
move: salesProcedure.input(z.object({ id: z.string(), stageId: z.string(), lostReason: z.string().max(500).optional() })).mutation(async ({ ctx, input }) => {
|
||||
await ownLead(ctx, input.id);
|
||||
await moveLead(ctx.svc, { leadId: input.id, stageId: input.stageId, byAdminId: ctx.admin.id, lostReason: input.lostReason });
|
||||
}),
|
||||
|
||||
/** «Подключён»: создаём партнёра или агентство и отдаём ссылку */
|
||||
convert: salesProcedure
|
||||
.input(z.object({ id: z.string(), agencyCode: z.string().max(32).optional(), commissionPct: z.number().int().min(0).max(100).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ownLead(ctx, input.id);
|
||||
return convertLead(ctx.svc, { leadId: input.id, byAdminId: ctx.admin.id, agencyCode: input.agencyCode, commissionPct: ctx.isSuper ? input.commissionPct : undefined });
|
||||
}),
|
||||
|
||||
/** Звонок, встреча, заметка */
|
||||
addActivity: salesProcedure
|
||||
.input(z.object({ id: z.string(), type: z.enum(['CALL', 'MEETING', 'NOTE']), text: z.string().trim().min(1).max(4000) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ownLead(ctx, input.id);
|
||||
await ctx.db.salesActivity.create({ data: { leadId: input.id, authorId: ctx.admin.id, type: input.type, text: input.text } });
|
||||
}),
|
||||
|
||||
// ───── задачи ─────
|
||||
|
||||
tasks: salesProcedure.input(z.object({ assigneeId: z.string().optional(), done: z.boolean().default(false) }).optional()).query(async ({ ctx, input }) => {
|
||||
const assigneeId = ctx.isSuper ? input?.assigneeId : ctx.admin.id;
|
||||
const rows = await ctx.db.salesTask.findMany({
|
||||
where: { ...(assigneeId ? { assigneeId } : ctx.isSuper ? {} : { assigneeId: ctx.admin.id }), doneAt: input?.done ? { not: null } : null },
|
||||
orderBy: input?.done ? { doneAt: 'desc' } : { dueAt: 'asc' },
|
||||
take: 200,
|
||||
include: { lead: { select: { id: true, name: true, phone: true } }, assignee: { select: { id: true, name: true } } },
|
||||
});
|
||||
return rows.map((t) => ({ id: t.id, title: t.title, dueAt: t.dueAt, doneAt: t.doneAt, lead: t.lead, assignee: t.assignee }));
|
||||
}),
|
||||
|
||||
addTask: salesProcedure
|
||||
.input(z.object({ leadId: z.string().optional(), title: z.string().trim().min(1).max(200), dueAt: z.date(), assigneeId: z.string().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.leadId) await ownLead(ctx, input.leadId);
|
||||
await ctx.db.salesTask.create({ data: { leadId: input.leadId, title: input.title, dueAt: input.dueAt, assigneeId: ctx.isSuper && input.assigneeId ? input.assigneeId : ctx.admin.id, createdById: ctx.admin.id } });
|
||||
}),
|
||||
|
||||
setTaskDone: salesProcedure.input(z.object({ id: z.string(), done: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
const t = await ctx.db.salesTask.findUnique({ where: { id: input.id } });
|
||||
if (!t || (!ctx.isSuper && t.assigneeId !== ctx.admin.id)) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await ctx.db.salesTask.update({ where: { id: t.id }, data: { doneAt: input.done ? ctx.svc.now() : null } });
|
||||
}),
|
||||
|
||||
removeTask: salesProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const t = await ctx.db.salesTask.findUnique({ where: { id: input.id } });
|
||||
if (!t || (!ctx.isSuper && t.assigneeId !== ctx.admin.id)) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await ctx.db.salesTask.delete({ where: { id: t.id } });
|
||||
}),
|
||||
|
||||
// ───── карта, команда, бонус ─────
|
||||
|
||||
/** Точки на карте: свои лиды-заведения и уже подключённые партнёры */
|
||||
map: salesProcedure.query(async ({ ctx }) => {
|
||||
const [leads, partners] = await Promise.all([
|
||||
ctx.db.salesLead.findMany({ where: { ...scope(ctx), kind: 'VENUE', lat: { not: null }, lng: { not: null }, partnerId: null }, include: { stage: true } }),
|
||||
ctx.db.partner.findMany({ where: { lat: { not: null }, lng: { not: null } }, select: { id: true, name: true, category: true, lat: true, lng: true, active: true, address: true } }),
|
||||
]);
|
||||
return {
|
||||
leads: leads.map((l) => ({ id: l.id, name: l.name, lat: l.lat!, lng: l.lng!, stage: l.stage.name, color: l.stage.color, lost: l.stage.kind === 'LOST', address: l.address })),
|
||||
partners: partners.map((p) => ({ id: p.id, name: p.name, lat: p.lat!, lng: p.lng!, active: p.active, address: p.address })),
|
||||
};
|
||||
}),
|
||||
|
||||
/** Продажники — для фильтра и передачи лида (видит суперадмин) */
|
||||
team: salesProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.isSuper) return [{ id: ctx.admin.id, name: ctx.admin.name }];
|
||||
return ctx.db.adminUser.findMany({ where: { active: true, roles: { hasSome: ['SALES', 'SUPERADMIN'] } }, orderBy: { name: 'asc' }, select: { id: true, name: true } });
|
||||
}),
|
||||
|
||||
/** Бонус за месяц: свой (продажник) или любого (суперадмин) */
|
||||
bonus: salesProcedure.input(z.object({ month: z.string().regex(/^\d{4}-\d{2}$/), salesId: z.string().optional() })).query(async ({ ctx, input }) => {
|
||||
const { from, to } = monthRange(input.month);
|
||||
const who = ctx.isSuper && input.salesId ? input.salesId : ctx.admin.id;
|
||||
return salesBonus(ctx.svc, who, from, to);
|
||||
}),
|
||||
|
||||
/** Сводка воронки по этапам и итоги месяца — для шапки CRM */
|
||||
summary: salesProcedure.input(z.object({ ownerId: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
|
||||
const where = scope(ctx, input?.ownerId);
|
||||
const now = ctx.svc.now();
|
||||
const [byStage, overdue, s] = await Promise.all([
|
||||
ctx.db.salesLead.groupBy({ by: ['stageId'], where, _count: true }),
|
||||
ctx.db.salesTask.count({ where: { doneAt: null, dueAt: { lt: now }, ...(ctx.isSuper ? {} : { assigneeId: ctx.admin.id }) } }),
|
||||
getSettings(ctx.db),
|
||||
]);
|
||||
return {
|
||||
byStage: Object.fromEntries(byStage.map((b) => [b.stageId, b._count])),
|
||||
overdue,
|
||||
bonusConfigured: s['sales.bonusPerVenue'] + s['sales.bonusPerAgency'] + s['sales.bonusAgencyPct'] > 0,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Формула бонуса (меняет суперадмин) */
|
||||
bonusRules: salesProcedure.query(async ({ ctx }) => {
|
||||
const s = await getSettings(ctx.db);
|
||||
return { perVenue: s['sales.bonusPerVenue'], perAgency: s['sales.bonusPerAgency'], pct: s['sales.bonusAgencyPct'], months: s['sales.bonusAgencyMonths'] };
|
||||
}),
|
||||
setBonusRules: superProcedure
|
||||
.input(z.object({ perVenue: z.number().int().min(0), perAgency: z.number().int().min(0), pct: z.number().int().min(0).max(100), months: z.number().int().min(1).max(60) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const pairs: [string, number][] = [
|
||||
['sales.bonusPerVenue', input.perVenue],
|
||||
['sales.bonusPerAgency', input.perAgency],
|
||||
['sales.bonusAgencyPct', input.pct],
|
||||
['sales.bonusAgencyMonths', input.months],
|
||||
];
|
||||
for (const [key, value] of pairs) await ctx.db.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
|
||||
}),
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { boDashboardRouter } from './dashboard.ts';
|
||||
import { boChatsRouter } from './chats.ts';
|
||||
import { boOrdersRouter } from './orders.ts';
|
||||
import { boProvidersRouter } from './providers.ts';
|
||||
import { boCrmRouter } from './crm.ts';
|
||||
import { boPartnersRouter } from './partners.ts';
|
||||
|
||||
/** API новой админки (apps/backoffice) */
|
||||
export const backofficeRouter = router({
|
||||
@@ -16,4 +18,6 @@ export const backofficeRouter = router({
|
||||
chats: boChatsRouter,
|
||||
orders: boOrdersRouter,
|
||||
providers: boProvidersRouter,
|
||||
crm: boCrmRouter,
|
||||
partners: boPartnersRouter,
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { DAY, normalizeAgencyCode } from '@barev/core';
|
||||
import { agencyLink, createOwnerInvite, savePartnerOffer } from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { router, superProcedure } from '../../trpc.ts';
|
||||
|
||||
/** Партнёры (агентства и заведения) — деньги и условия, поэтому только суперадмин */
|
||||
const p = superProcedure;
|
||||
const localized = z.object({ ru: z.string().max(300), en: z.string().max(300), zh: z.string().max(300), hy: z.string().max(300), es: z.string().max(300) });
|
||||
const CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
|
||||
const proBotUrl = (payload: string) => `https://t.me/${process.env.PRO_BOT_USERNAME ?? 'barev_pro_bot'}?start=${payload}`;
|
||||
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) => [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
|
||||
const monthStart = (d: Date) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||
const monthKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 7);
|
||||
|
||||
export const boPartnersRouter = router({
|
||||
// ───── агентства ─────
|
||||
|
||||
agencies: p.query(async ({ ctx }) => {
|
||||
const now = ctx.svc.now();
|
||||
const agencies = await ctx.db.agency.findMany({
|
||||
orderBy: [{ active: 'desc' }, { name: 'asc' }],
|
||||
include: { sales: { select: { id: true, name: true } }, _count: { select: { clients: true } } },
|
||||
});
|
||||
const ids = agencies.map((a) => a.id);
|
||||
const [gifts, accrued, accruedMonth, paid] = await Promise.all([
|
||||
ctx.db.subscription.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids }, source: 'AGENCY_GIFT' }, _count: true }),
|
||||
ctx.db.agencyCommission.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids } }, _sum: { amount: true } }),
|
||||
ctx.db.agencyCommission.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids }, period: monthKey(now) }, _sum: { amount: true } }),
|
||||
ctx.db.payout.groupBy({ by: ['agencyId'], where: { agencyId: { in: ids }, status: 'PAID' }, _sum: { amount: true } }),
|
||||
]);
|
||||
const m = <T extends { agencyId: string | null }>(rows: T[], f: (r: T) => number) => new Map(rows.map((r) => [r.agencyId, f(r)]));
|
||||
const g = m(gifts, (r) => r._count);
|
||||
const acc = m(accrued, (r) => r._sum.amount ?? 0);
|
||||
const accM = m(accruedMonth, (r) => r._sum.amount ?? 0);
|
||||
const pd = m(paid, (r) => r._sum.amount ?? 0);
|
||||
return agencies.map((a) => ({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
commissionPct: a.commissionPct,
|
||||
active: a.active,
|
||||
sales: a.sales,
|
||||
clients: a._count.clients,
|
||||
gifts: g.get(a.id) ?? 0,
|
||||
thisMonth: accM.get(a.id) ?? 0,
|
||||
accrued: acc.get(a.id) ?? 0,
|
||||
balance: (acc.get(a.id) ?? 0) - (pd.get(a.id) ?? 0),
|
||||
link: agencyLink(a.code),
|
||||
}));
|
||||
}),
|
||||
|
||||
agency: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const a = await ctx.db.agency.findUnique({ where: { id: input.id }, include: { sales: { select: { id: true, name: true } } } });
|
||||
if (!a) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
const [clients, commissions, payouts, gifts] = await Promise.all([
|
||||
ctx.db.client.findMany({
|
||||
where: { agencyId: a.id },
|
||||
orderBy: { agencyAttachedAt: 'desc' },
|
||||
take: 100,
|
||||
include: { user: { select: { firstName: true, lastName: true, username: true } }, subscriptions: { where: { status: 'ACTIVE' }, select: { plan: true, startsAt: true, endsAt: true, source: true } }, _count: { select: { orders: true } } },
|
||||
}),
|
||||
ctx.db.agencyCommission.groupBy({ by: ['period'], where: { agencyId: a.id }, _sum: { amount: true }, _count: true, orderBy: { period: 'desc' }, take: 12 }),
|
||||
ctx.db.payout.findMany({ where: { agencyId: a.id }, orderBy: { createdAt: 'desc' } }),
|
||||
ctx.db.subscription.count({ where: { agencyId: a.id, source: 'AGENCY_GIFT' } }),
|
||||
]);
|
||||
const now = ctx.svc.now();
|
||||
const accrued = commissions.reduce((s, c) => s + (c._sum.amount ?? 0), 0);
|
||||
const paid = payouts.filter((x) => x.status === 'PAID').reduce((s, x) => s + x.amount, 0);
|
||||
return {
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
commissionPct: a.commissionPct,
|
||||
contact: a.contact,
|
||||
note: a.note,
|
||||
active: a.active,
|
||||
sales: a.sales,
|
||||
createdAt: a.createdAt,
|
||||
link: agencyLink(a.code),
|
||||
gifts,
|
||||
clients: clients.map((c) => {
|
||||
const sub = c.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now);
|
||||
return { id: c.id, name: name(c.user), attachedAt: c.agencyAttachedAt, plan: sub?.plan ?? null, orders: c._count.orders };
|
||||
}),
|
||||
months: commissions.map((c) => ({ period: c.period, amount: c._sum.amount ?? 0, orders: c._count })),
|
||||
accrued,
|
||||
paid,
|
||||
balance: accrued - paid,
|
||||
payouts: payouts.map((x) => ({ id: x.id, amount: x.amount, paidAt: x.paidAt, note: x.note })),
|
||||
};
|
||||
}),
|
||||
|
||||
saveAgency: p
|
||||
.input(z.object({ id: z.string().optional(), code: z.string().max(32), name: z.string().trim().min(1).max(100), commissionPct: z.number().int().min(0).max(100), contact: z.string().max(200).nullable(), note: z.string().max(2000).nullable(), active: z.boolean(), salesId: z.string().nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const code = normalizeAgencyCode(input.code);
|
||||
if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' });
|
||||
const clash = await ctx.db.agency.findUnique({ where: { code } });
|
||||
if (clash && clash.id !== input.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_TAKEN' });
|
||||
const { id, ...data } = input;
|
||||
const a = id ? await ctx.db.agency.update({ where: { id }, data: { ...data, code } }) : await ctx.db.agency.create({ data: { ...data, code } });
|
||||
return { id: a.id };
|
||||
}),
|
||||
|
||||
/** Выплата вознаграждения агентству */
|
||||
agencyPayout: p.input(z.object({ id: z.string(), amount: z.number().int().min(1), note: z.string().max(300).optional() })).mutation(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const last = await ctx.db.payout.findFirst({ where: { agencyId: input.id }, orderBy: { periodEnd: 'desc' } });
|
||||
await ctx.db.payout.create({ data: { agencyId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? monthStart(now), periodEnd: now } });
|
||||
}),
|
||||
|
||||
// ───── заведения ─────
|
||||
|
||||
venues: p.query(async ({ ctx }) => {
|
||||
const since = new Date(ctx.svc.now().getTime() - 30 * DAY);
|
||||
const list = await ctx.db.partner.findMany({
|
||||
orderBy: [{ active: 'desc' }, { name: 'asc' }],
|
||||
include: { sales: { select: { id: true, name: true } }, _count: { select: { offers: { where: { active: true } }, members: true } } },
|
||||
});
|
||||
const ids = list.map((x) => x.id);
|
||||
const [visits, last] = await Promise.all([
|
||||
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { partnerId: { in: ids }, offerId: null, createdAt: { gte: since } }, _count: true }),
|
||||
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { partnerId: { in: ids } }, _max: { createdAt: true } }),
|
||||
]);
|
||||
const v = new Map(visits.map((x) => [x.partnerId, x._count]));
|
||||
const l = new Map(last.map((x) => [x.partnerId, x._max.createdAt]));
|
||||
return list.map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
category: x.category,
|
||||
address: x.address,
|
||||
active: x.active,
|
||||
sales: x.sales,
|
||||
offers: x._count.offers,
|
||||
members: x._count.members,
|
||||
visits30: v.get(x.id) ?? 0,
|
||||
lastVisit: l.get(x.id) ?? null,
|
||||
onMap: x.lat != null && x.lng != null,
|
||||
}));
|
||||
}),
|
||||
|
||||
venue: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const v = await ctx.db.partner.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
sales: { select: { id: true, name: true } },
|
||||
offers: { orderBy: { sortOrder: 'asc' } },
|
||||
members: { include: { user: { select: { firstName: true, lastName: true, username: true } } }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!v) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
const since = new Date(ctx.svc.now().getTime() - 8 * 7 * DAY);
|
||||
const [recent, weekly] = await Promise.all([
|
||||
ctx.db.cardUsage.findMany({
|
||||
where: { partnerId: v.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
include: { client: { include: { user: { select: { firstName: true, lastName: true, username: true } } } }, offer: { select: { title: true } } },
|
||||
}),
|
||||
ctx.db.cardUsage.findMany({ where: { partnerId: v.id, createdAt: { gte: since } }, select: { createdAt: true, offerId: true } }),
|
||||
]);
|
||||
const memberName = new Map(v.members.map((m) => [m.userId, name(m.user)]));
|
||||
// проверки карты по неделям (последние 8)
|
||||
const weeks = Array.from({ length: 8 }, (_, i) => ({ from: new Date(since.getTime() + i * 7 * DAY), checks: 0, redeemed: 0 }));
|
||||
for (const u of weekly) {
|
||||
const w = weeks[Math.min(7, Math.floor((u.createdAt.getTime() - since.getTime()) / (7 * DAY)))];
|
||||
if (!w) continue;
|
||||
if (u.offerId) w.redeemed++;
|
||||
else w.checks++;
|
||||
}
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
category: v.category,
|
||||
offer: v.offer as Record<string, string>,
|
||||
address: v.address,
|
||||
phone: v.phone,
|
||||
lat: v.lat,
|
||||
lng: v.lng,
|
||||
note: v.note,
|
||||
active: v.active,
|
||||
sales: v.sales,
|
||||
createdAt: v.createdAt,
|
||||
ownerInviteUrl: v.ownerInviteCode ? proBotUrl(`po-${v.ownerInviteCode}`) : null,
|
||||
offers: v.offers.map((o) => ({ id: o.id, title: o.title as Record<string, string>, minLevel: o.minLevel, limit: o.limit, limitCount: o.limitCount, active: o.active })),
|
||||
members: v.members.map((m) => ({ id: m.id, role: m.role, name: name(m.user), username: m.user.username, since: m.createdAt })),
|
||||
recent: recent.map((u) => ({ id: u.id, at: u.createdAt, client: name(u.client.user), clientId: u.clientId, level: u.level, offer: (u.offer?.title as Record<string, string> | undefined) ?? null, by: u.memberUserId ? (memberName.get(u.memberUserId) ?? null) : null })),
|
||||
weeks,
|
||||
};
|
||||
}),
|
||||
|
||||
saveVenue: p
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
category: z.enum(CATEGORIES),
|
||||
offer: localized,
|
||||
address: z.string().max(200).nullable(),
|
||||
phone: z.string().max(40).nullable(),
|
||||
lat: z.number().min(-90).max(90).nullable(),
|
||||
lng: z.number().min(-180).max(180).nullable(),
|
||||
note: z.string().max(2000).nullable(),
|
||||
active: z.boolean(),
|
||||
salesId: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
const v = id ? await ctx.db.partner.update({ where: { id }, data }) : await ctx.db.partner.create({ data });
|
||||
return { id: v.id };
|
||||
}),
|
||||
|
||||
saveOffer: p
|
||||
.input(z.object({ id: z.string().optional(), partnerId: z.string(), title: localized.extend({ ru: z.string().trim().min(1).max(300) }), minLevel: z.enum(['BAREV_PLUS', 'ELITE']).nullable(), limit: z.enum(['NONE', 'ONCE', 'WEEK', 'MONTH']), limitCount: z.number().int().min(1).max(100), active: z.boolean() }))
|
||||
.mutation(({ ctx, input }) => savePartnerOffer(ctx.svc, input).then((o) => ({ id: o.id }))),
|
||||
|
||||
/** Новая одноразовая ссылка для собственника (рабочий бот) */
|
||||
ownerInvite: p.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => createOwnerInvite(ctx.svc, input.id)),
|
||||
|
||||
removeMember: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.partnerMember.delete({ where: { id: input.id } });
|
||||
}),
|
||||
|
||||
/** Кому из команды можно приписать партнёра (продажники) */
|
||||
salesTeam: p.query(({ ctx }) => ctx.db.adminUser.findMany({ where: { active: true, roles: { hasSome: ['SALES', 'SUPERADMIN'] } }, orderBy: { name: 'asc' }, select: { id: true, name: true } })),
|
||||
});
|
||||
@@ -205,6 +205,41 @@ app.get<{ Params: { fileId: string } }>('/files/bo/order-file/:fileId', async (r
|
||||
return sendChatFile(reply, { fileKey: f.storageKey, tgFileId: null, tgBot: null, mime: f.mime, fileName: `order-photo.${f.mime.split('/')[1] ?? 'jpg'}` });
|
||||
});
|
||||
|
||||
// ───── CRM продажника (этап 4) ─────
|
||||
|
||||
/** Продажник — свои лиды; суперадмин — все */
|
||||
async function leadForStaff(req: FastifyRequest, leadId: string) {
|
||||
const staff = await staffFromCookie(req);
|
||||
if (!staff) return null;
|
||||
const isSuper = staff.admin.roles.includes('SUPERADMIN');
|
||||
if (!isSuper && !staff.admin.roles.includes('SALES')) return null;
|
||||
const lead = await db.salesLead.findUnique({ where: { id: leadId } });
|
||||
if (!lead || (!isSuper && lead.ownerId !== staff.admin.id)) return null;
|
||||
return { staff, lead };
|
||||
}
|
||||
|
||||
/** Фото витрины, меню, договор — в историю лида */
|
||||
app.post<{ Params: { leadId: string } }>('/files/bo/lead/:leadId', async (req, reply) => {
|
||||
const found = await leadForStaff(req, req.params.leadId);
|
||||
if (!found) return reply.code(404).send({ error: 'NOT_FOUND' });
|
||||
const part = await req.file();
|
||||
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
||||
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
||||
const body = await part.toBuffer();
|
||||
const fileName = safeName(part.filename);
|
||||
const fileKey = await putFile(`leads/${found.lead.id}`, body, part.mimetype, fileName);
|
||||
const a = await db.salesActivity.create({
|
||||
data: { leadId: found.lead.id, authorId: found.staff.admin.id, type: 'FILE', text: (multipartField(part, 'text') ?? '').trim().slice(0, 2000) || null, fileKey, fileName, mime: part.mimetype, size: body.length },
|
||||
});
|
||||
return { id: a.id };
|
||||
});
|
||||
|
||||
app.get<{ Params: { activityId: string } }>('/files/bo/lead-file/:activityId', async (req, reply) => {
|
||||
const a = await db.salesActivity.findUnique({ where: { id: req.params.activityId } });
|
||||
if (!a?.fileKey || !(await leadForStaff(req, a.leadId))) return reply.code(404).send({ error: 'NOT_FOUND' });
|
||||
return sendChatFile(reply, { fileKey: a.fileKey, tgFileId: null, tgBot: null, mime: a.mime, fileName: a.fileName });
|
||||
});
|
||||
|
||||
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
|
||||
function clientFromInit(req: FastifyRequest) {
|
||||
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
|
||||
|
||||
Reference in new issue
Block a user