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

+257
View File
@@ -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 } });
}),
});
+4
View File
@@ -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,
});
+228
View File
@@ -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 } })),
});
+35
View File
@@ -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;