Bariki cashback, order edit, and current work in progress

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-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+3 -1
View File
@@ -16,7 +16,9 @@
"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:crm-demo": "tsx scripts/dev-crm-demo.ts"
"dev:crm-demo": "tsx scripts/dev-crm-demo.ts",
"dev:partners-demo": "tsx scripts/dev-partners-demo.ts",
"dev:points-demo": "tsx scripts/dev-points-demo.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
+81
View File
@@ -0,0 +1,81 @@
/**
* Локальная разработка: демо партнёра услуг (клининг) — заказы у партнёра, сотрудники, доход,
* и одноразовая ссылка на кабинет менеджера в браузере.
* pnpm --filter @barev/api dev:partners-demo
* Клиенты демо — telegramId 100003000+. Повторный запуск пересоздаёт их заказы.
*/
import { fromYerevan, toYerevan } from '@barev/core';
import { getDb } from '@barev/db';
import { createOrder, createProWebLogin, dispatcherAssign, giveConsent, markPaid, offerOrder, startClient, transitionOrder, type Ctx } from '@barev/services';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() };
const TG_FROM = 100003000n;
const PRO_APP = process.env.PRO_WEB_URL ?? 'http://localhost:5187';
// ── чистим прошлое демо ──
const old = await db.user.findMany({ where: { telegramId: { gte: TG_FROM, lt: TG_FROM + 100n } }, include: { client: true } });
const oldClients = old.map((u) => u.client?.id).filter((x): x is string => !!x);
const oldOrders = (await db.order.findMany({ where: { clientId: { in: oldClients } }, select: { id: true } })).map((o) => o.id);
await db.partnerEarning.deleteMany({ where: { orderId: { in: oldOrders } } });
await db.agencyCommission.deleteMany({ where: { orderId: { in: oldOrders } } });
await db.order.deleteMany({ where: { id: { in: oldOrders } } });
let company = await db.providerCompany.findFirst({ where: { active: true, roles: { has: 'CLEANER' } } });
if (!company) throw new Error('Нет компании-партнёра — запустите seed');
company = await db.providerCompany.update({ where: { id: company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 40, fotPct: null, taxPct: null } });
const manager = await db.companyDispatcher.findFirstOrThrow({ where: { companyId: company.id } });
const staff = await db.provider.findMany({ where: { companyId: company.id, active: true } });
// график всем сотрудникам: каждый день 9–20, лимит 3 заказа в день у первого
for (const [i, p] of staff.entries()) {
await db.providerSchedule.deleteMany({ where: { providerId: p.id } });
await db.providerSchedule.createMany({ data: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ providerId: p.id, weekday, startMinute: 9 * 60, endMinute: 20 * 60 })) });
await db.provider.update({ where: { id: p.id }, data: { maxOrdersPerDay: i === 0 ? 3 : null, maxHoursPerDay: i === 0 ? 8 : null } });
}
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const admin = await db.adminUser.findFirstOrThrow({ where: { email: 'owner@barev.local' } });
const at = (dayOffset: number, hour: number) => {
const p = toYerevan(new Date(Date.now() + dayOffset * 24 * 3600_000));
return fromYerevan(p.year, p.month, p.day, hour * 60);
};
const NAMES = ['Ольга', 'James', 'Ирина', 'Arpi', 'Лилит'];
async function demoOrder(i: number, when: Date, rooms: number) {
const s = await startClient(ctx, { id: Number(TG_FROM) + i, first_name: NAMES[i % NAMES.length]!, language_code: i % 2 ? 'en' : 'ru' });
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms, bathrooms: 1 }, scheduledAt: when, addressText: `Абовяна ${10 + i}`, districtId: kentron.id, allowBusySlot: true });
}
// 1) два заказа ждут назначения у партнёра
for (const [i, d] of [
[0, 2],
[1, 3],
] as const) {
const o = await demoOrder(i, at(d, 11), 2 + i);
await offerOrder(ctx, { orderId: o.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
}
// 2) один назначен на сотрудника
const assigned = await demoOrder(2, at(2, 15), 1);
await offerOrder(ctx, { orderId: assigned.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
const linked = staff.find((p) => p.userId) ?? staff[0]!;
await dispatcherAssign(ctx, { orderId: assigned.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId });
// 3) один выполнен и оплачен — доход партнёра
const done = await demoOrder(3, at(1, 10), 3);
await offerOrder(ctx, { orderId: done.id, adminId: admin.id, target: { kind: 'company', id: company.id } });
await dispatcherAssign(ctx, { orderId: done.id, companyId: company.id, providerId: linked.id, dispatcherUserId: manager.userId });
for (const to of ['EN_ROUTE', 'CLEANING'] as const) await transitionOrder(ctx, { orderId: done.id, to, actor: 'ADMIN', actorId: admin.id, event: 'demo' });
await db.orderChecklistItem.updateMany({ where: { orderId: done.id }, data: { checkedAt: new Date() } });
await db.order.update({ where: { id: done.id }, data: { status: 'DONE', completedAt: new Date() } });
await markPaid(ctx, { orderId: done.id, by: { type: 'ADMIN', id: admin.id } });
// 4) новый заказ без исполнителя — оператор выбирает, кому отдать (видно, что выгоднее)
const fresh = await demoOrder(4, at(4, 12), 2);
console.log(`Заказ для назначения: #${fresh.number} (${fresh.id})`);
const code = await createProWebLogin(ctx, manager.userId);
console.log(`Партнёр: ${company.name}`);
console.log(`Кабинет менеджера в браузере (ссылка одноразовая, 15 минут):\n${PRO_APP}/?login=${code}#/company`);
process.exit(0);
+46
View File
@@ -0,0 +1,46 @@
/**
* Локальная разработка: демо бариков у клиента «Анна» (telegramId 100000001):
* начисления (доступные и в ожидании), бонус, списание, открытый запрос и предложение системы.
* pnpm --filter @barev/api dev:points-demo
*/
import { DAY } from '@barev/core';
import { getDb } from '@barev/db';
import { accruePoints, redeemPoints, requestRedemption, type Ctx } from '@barev/services';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const ctx: Ctx = { db, notify: { emit: async () => {} }, now: () => new Date() };
const OWNER = { adminId: null, owner: true };
const user = await db.user.findUniqueOrThrow({ where: { telegramId: 100000001n }, include: { client: true } });
const clientId = user.client!.id;
// чистим прошлое демо этого клиента
await db.pointsRedemptionItem.deleteMany({ where: { redemption: { clientId } } });
await db.pointsRedemption.deleteMany({ where: { clientId } });
await db.pointsReversal.deleteMany({ where: { clientId } });
await db.pointsSuggestion.deleteMany({ where: { clientId } });
await db.pointsAccrual.deleteMany({ where: { clientId } });
await db.client.update({ where: { id: clientId }, data: { pointsActive: 0, pointsPending: 0, pointsDebt: 0, streakCount: 0, streakLastAt: null, pointsActivityAt: null } });
const cat = (code: string) => db.pointsCategory.findUniqueOrThrow({ where: { code } });
const now = Date.now();
const ago = (d: number) => new Date(now - d * DAY);
const cleaning = await cat('cleaning');
for (const [i, d] of [50, 25, 3].entries()) {
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 18_000 + i * 2000, serviceDate: ago(d), orderRef: `DEMO-C${i}`, channel: 'WEB' }, OWNER);
}
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat('documents')).id, orderAmount: 45_000, excludedAmount: 15_000, serviceDate: ago(10), orderRef: 'DEMO-D1', channel: 'WEB' }, OWNER);
// ожидание 48 ч от даты услуги: старые уже доступны — активируем
const { activatePendingPoints } = await import('@barev/services');
await activatePendingPoints(ctx);
// свежая уборка — ещё в ожидании
await accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: cleaning.id, orderAmount: 19_000, serviceDate: new Date(), orderRef: 'DEMO-C3', channel: 'AUTO' }, OWNER);
const bonus = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'housewarming' } });
await accruePoints(ctx, { clientId, kind: 'BONUS', bonusTypeId: bonus.id, amount: 5000, serviceDate: new Date(), channel: 'WEB', commentForClient: 'Yerevan Home' }, OWNER);
await redeemPoints(ctx, { clientId, amount: 2000, orderAmount: 20_000, orderRef: 'DEMO-R1', reason: 'оплата по звонку', channel: 'WEB' }, OWNER);
await requestRedemption(ctx, { clientId, amount: 1500, note: 'Уборка 30 октября, 18 000 ֏' });
await db.pointsSuggestion.create({ data: { clientId, mechanic: 'first_month_route', dedupeKey: `route:${clientId}`, payload: { categories: ['cleaning', 'documents', 'learning'] }, suggestedAmount: 3000 } });
const c = await db.client.findUniqueOrThrow({ where: { id: clientId } });
console.log(`Анна: доступно ${c.pointsActive}, в ожидании ${c.pointsPending}, серия ${c.streakCount}`);
process.exit(0);
+2
View File
@@ -7,6 +7,7 @@ import { boOrdersRouter } from './orders.ts';
import { boProvidersRouter } from './providers.ts';
import { boCrmRouter } from './crm.ts';
import { boPartnersRouter } from './partners.ts';
import { boPointsRouter } from './points.ts';
import { boProductsRouter } from './products.ts';
import { boMovesRouter, boRequestsRouter } from './moves.ts';
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
@@ -25,6 +26,7 @@ export const backofficeRouter = router({
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
points: boPointsRouter,
products: boProductsRouter,
broadcasts: boBroadcastsRouter,
journal: boJournalRouter,
+22 -3
View File
@@ -9,6 +9,9 @@ import {
getServiceByCode,
getSettings,
markPaid,
previewOrderEdit,
editOrderComposition,
staffChecklistItem,
offerOrder,
orderPayout,
quoteForClient,
@@ -210,7 +213,7 @@ export const boOrdersRouter = router({
const pending = o.offers.find((x) => x.status === 'OFFERED');
const adminIds = [...new Set(o.offers.map((x) => x.createdById).filter((x): x is string => !!x))];
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider) : null;
const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider, { fotPct: (await getSettings(ctx.db))['partner.fotPct'] }) : null;
const sub = o.client.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now);
return {
id: o.id,
@@ -246,7 +249,7 @@ export const boOrdersRouter = router({
respondedAt: x.respondedAt,
})),
history: await historyView(ctx.db, o),
checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId })),
checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId, waived: c.photoWaivedReason, byStaff: !!c.checkedByAdminId })),
photos: o.files.filter((f) => f.kind === 'PHOTO_REPORT').map((f) => f.id),
rating: o.rating ? { score: o.rating.score, comment: o.rating.comment } : null,
// деньги — только суперадмину; статус оплаты видят все (поддержка отмечает оплату наличными)
@@ -267,7 +270,7 @@ export const boOrdersRouter = router({
/** Подсказка: свободные кандидаты по рейтингу подбора и занятые */
candidates: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const { ranked, busy } = await assignmentCandidates(ctx.svc, input.id);
const { ranked, busy, bestId } = await assignmentCandidates(ctx.svc, input.id);
const tried = new Set((await ctx.db.orderOffer.findMany({ where: { orderId: input.id }, select: { providerId: true, companyId: true, status: true } })).filter((o) => o.status !== 'OFFERED').map((o) => o.providerId ?? o.companyId));
const view = (c: (typeof ranked)[number] | (typeof busy)[number]) => ({
kind: c.kind,
@@ -279,6 +282,12 @@ export const boOrdersRouter = router({
onShift: c.onShift,
reasons: 'reasons' in c ? c.reasons : [],
tried: tried.has(c.id),
/** самый выгодный для нас из доступных — подсвечивается всем; суммы видит только суперадмин */
best: `${c.kind}:${c.id}` === bestId,
cost: ctx.isSuper ? c.cost : null,
margin: ctx.isSuper ? c.margin : null,
partnerFree: c.partnerFree ?? null,
partnerStaff: c.partnerStaff ?? null,
});
return { free: ranked.map(view), busy: busy.map(view) };
}),
@@ -320,6 +329,16 @@ export const boOrdersRouter = router({
// ───── новый заказ за клиента ─────
/** Услуги, которые можно заказать сейчас, с формой параметров */
/** Изменить состав заказа: предпросмотр и сохранение (пересчёт цены или своя цена с причиной) */
editPreview: ordersProcedure.input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()) })).query(({ ctx, input }) => previewOrderEdit(ctx.svc, { orderId: input.id, params: input.params })),
edit: ordersProcedure
.input(z.object({ id: z.string(), params: z.record(z.string(), z.unknown()), totalPrice: z.number().int().min(0).nullable().optional(), reason: z.string().trim().max(500).nullable().optional(), notify: z.boolean().default(false) }))
.mutation(({ ctx, input }) => editOrderComposition(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, params: input.params, totalPrice: input.totalPrice, reason: input.reason, notify: input.notify })),
/** Чек-лист за исполнителя */
checklist: ordersProcedure
.input(z.object({ id: z.string(), code: z.string().max(60), checked: z.boolean(), reason: z.string().trim().max(500).nullable().optional() }))
.mutation(({ ctx, input }) => staffChecklistItem(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, code: input.code, checked: input.checked, reason: input.reason })),
services: ordersProcedure.query(async ({ ctx }) => {
const list = await ctx.db.service.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { code: true } });
return Promise.all(
+124 -2
View File
@@ -1,5 +1,5 @@
import { DAY, normalizeAgencyCode } from '@barev/core';
import { agencyLink, createOwnerInvite, savePartnerOffer } from '@barev/services';
import { agencyLink, createOwnerInvite, getSettings, managerInviteCode, partnerMoney, savePartnerOffer, termsOf } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { router, superProcedure } from '../../trpc.ts';
@@ -39,6 +39,8 @@ export const boPartnersRouter = router({
code: a.code,
name: a.name,
commissionPct: a.commissionPct,
giftPlan: a.giftPlan,
giftMonths: a.giftMonths,
active: a.active,
sales: a.sales,
clients: a._count.clients,
@@ -72,6 +74,10 @@ export const boPartnersRouter = router({
code: a.code,
name: a.name,
commissionPct: a.commissionPct,
giftPlan: a.giftPlan,
giftMonths: a.giftMonths,
housewarmingPoints: a.housewarmingPoints,
housewarmingDays: a.housewarmingDays,
contact: a.contact,
note: a.note,
active: a.active,
@@ -92,7 +98,7 @@ export const boPartnersRouter = router({
}),
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() }))
.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), giftPlan: z.enum(['BAREV_PLUS', 'ELITE']).nullable(), giftMonths: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(6), z.literal(12)]), housewarmingPoints: z.number().int().min(0).max(1_000_000).default(0), housewarmingDays: z.number().int().min(1).max(1000).default(60), 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' });
@@ -110,6 +116,122 @@ export const boPartnersRouter = router({
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 } });
}),
// ───── партнёры услуг (клининговые компании) ─────
servicePartners: p.query(async ({ ctx }) => {
const settings = await getSettings(ctx.db);
const list = await ctx.db.providerCompany.findMany({
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { _count: { select: { employees: { where: { active: true } }, dispatchers: true } } },
});
const ids = list.map((c) => c.id);
const now = ctx.svc.now();
const [active, month, accrued, paid] = await Promise.all([
ctx.db.order.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } }, _count: true }),
ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, period: monthKey(now) }, _sum: { amount: true } }),
ctx.db.partnerEarning.groupBy({ by: ['companyId'], where: { companyId: { in: ids } }, _sum: { amount: true } }),
ctx.db.payout.groupBy({ by: ['companyId'], where: { companyId: { in: ids }, status: 'PAID' }, _sum: { amount: true } }),
]);
const m = <T extends { companyId: string | null }>(rows: T[], f: (r: T) => number) => new Map(rows.map((r) => [r.companyId, f(r)]));
const act = m(active, (r) => r._count);
const mon = m(month, (r) => r._sum.amount ?? 0);
const acc = m(accrued, (r) => r._sum.amount ?? 0);
const pd = m(paid, (r) => r._sum.amount ?? 0);
return list.map((c) => ({
id: c.id,
name: c.name,
active: c.active,
deal: c.deal,
terms: termsOf(c, settings),
autoAssign: c.autoAssign,
staff: c._count.employees,
managers: c._count.dispatchers,
activeOrders: act.get(c.id) ?? 0,
thisMonth: mon.get(c.id) ?? 0,
balance: (acc.get(c.id) ?? 0) - (pd.get(c.id) ?? 0),
}));
}),
servicePartner: p.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const c = await ctx.db.providerCompany.findUnique({
where: { id: input.id },
include: { employees: { orderBy: [{ active: 'desc' }, { name: 'asc' }] }, dispatchers: { include: { user: true }, orderBy: { createdAt: 'asc' } } },
});
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
const settings = await getSettings(ctx.db);
const [money, activeOrders] = await Promise.all([
partnerMoney(ctx.db, c.id),
ctx.db.order.findMany({
where: { companyId: c.id, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED'] } },
orderBy: { scheduledAt: 'asc' },
take: 30,
include: { service: { select: { code: true } }, provider: { select: { name: true } } },
}),
]);
return {
id: c.id,
name: c.name,
contactPhone: c.contactPhone,
note: c.note,
active: c.active,
deal: c.deal,
commissionPct: c.commissionPct,
profitSharePct: c.profitSharePct,
fotPct: c.fotPct,
taxPct: c.taxPct,
terms: termsOf(c, settings),
defaults: { fotPct: settings['partner.fotPct'], taxPct: settings['partner.taxPct'] },
autoAssign: c.autoAssign,
travelGapMinutes: c.travelGapMinutes,
createdAt: c.createdAt,
managerLink: proBotUrl(c.inviteCode),
managers: c.dispatchers.map((d) => ({ id: d.id, name: name(d.user), since: d.createdAt })),
staff: c.employees.map((e) => ({ id: e.id, name: e.name, phone: e.phone, active: e.active, linked: !!e.userId, maxOrdersPerDay: e.maxOrdersPerDay, maxHoursPerDay: e.maxHoursPerDay })),
orders: activeOrders.map((o) => ({ id: o.id, number: o.number, service: o.service.code, scheduledAt: o.scheduledAt, status: o.status, staff: o.provider?.name ?? null })),
money,
};
}),
saveServicePartner: p
.input(
z.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(100),
contactPhone: z.string().trim().max(30).nullable(),
note: z.string().max(2000).nullable(),
active: z.boolean(),
deal: z.enum(['PROFIT_SHARE', 'COMMISSION']),
commissionPct: z.number().int().min(0).max(100),
profitSharePct: z.number().int().min(0).max(100),
fotPct: z.number().int().min(0).max(100).nullable(),
taxPct: z.number().int().min(0).max(100).nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
// пока партнёры услуг — только клининг
const c = id
? await ctx.db.providerCompany.update({ where: { id }, data })
: await ctx.db.providerCompany.create({ data: { ...data, roles: ['CLEANER'], inviteCode: managerInviteCode() } });
return { id: c.id };
}),
/** Новая ссылка для менеджеров — старая перестаёт работать (уже подключённые остаются) */
resetManagerLink: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const c = await ctx.db.providerCompany.update({ where: { id: input.id }, data: { inviteCode: managerInviteCode() } });
return { link: proBotUrl(c.inviteCode) };
}),
removeManager: p.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.companyDispatcher.delete({ where: { id: input.id } });
}),
servicePartnerPayout: 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: { companyId: input.id }, orderBy: { periodEnd: 'desc' } });
await ctx.db.payout.create({ data: { companyId: 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 }) => {
+398
View File
@@ -0,0 +1,398 @@
import { DAY, membershipLevel } from '@barev/core';
import type { Db } from '@barev/db';
import {
acceptSuggestion,
accruePoints,
confirmRedemption,
getSettings,
pointsHistory,
previewAccrual,
redeemPoints,
rejectRedemption,
rejectSuggestion,
reverseAccrual,
reversePreview,
reverseRedemption,
type PointsActor,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/**
* Барики в новой админке. Управляет суперадмин; поддержка — только если включено в настройках,
* с лимитами на операцию и на сутки (выше — подтверждение суперадмина).
*/
const pointsProcedure = rolesProcedure('SUPPORT').use(async ({ ctx, next }) => {
if (!ctx.isSuper && !(await getSettings(ctx.db))['points.supportCanManage']) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return next({ ctx: { actor: { adminId: ctx.admin.id, owner: ctx.isSuper } satisfies PointsActor } });
});
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) => [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
const L5 = z.object({ ru: z.string().max(100), en: z.string().max(100), zh: z.string().max(100), hy: z.string().max(100), es: z.string().max(100) });
const idem = z.string().min(8).max(80).optional();
/** Клиент по телефону, @username, имени или ID */
async function findClients(db: Db, q: string) {
const s = q.trim().replace(/^@/, '');
const digits = s.replace(/[^\d]/g, '');
return db.client.findMany({
where: {
OR: [
{ id: s },
{ cardCode: s.toUpperCase() },
{ user: { username: { equals: s, mode: 'insensitive' } } },
{ user: { firstName: { contains: s, mode: 'insensitive' } } },
{ user: { lastName: { contains: s, mode: 'insensitive' } } },
...(digits.length >= 5 ? [{ user: { phone: { contains: digits } } }] : []),
],
},
include: { user: true, subscriptions: { where: { status: 'ACTIVE' } } },
take: 20,
orderBy: { createdAt: 'desc' },
});
}
export const boPointsRouter = router({
/** Справочники формы: категории, типы бонусов, правила */
dictionaries: pointsProcedure.query(async ({ ctx }) => {
const [categories, bonusTypes, s] = await Promise.all([
ctx.db.pointsCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
ctx.db.pointsBonusType.findMany({ orderBy: { sortOrder: 'asc' } }),
getSettings(ctx.db),
]);
return {
categories: categories.map((c) => ({ ...c, name: c.name as Record<string, string> })),
bonusTypes: bonusTypes.map((b) => ({ ...b, name: b.name as Record<string, string> })),
rules: { pendingHours: s['points.pendingHours'], sharePct: s['points.maxSharePct'], min: s['points.minRedeem'], burnDays: s['points.burnDays'], diffPct: s['points.diffHighlightPct'] },
owner: ctx.isSuper,
};
}),
search: pointsProcedure.input(z.object({ q: z.string().trim().min(1).max(100) })).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
return (await findClients(ctx.db, input.q)).map((c) => ({
id: c.id,
name: name(c.user),
username: c.user.username,
phone: c.user.phone,
tier: membershipLevel(c.subscriptions, now),
balance: c.pointsActive,
pending: c.pointsPending,
}));
}),
/** Карточка клиента: баланс, ожидание, сгорание, источник, флаг проверки, последние операции */
client: pointsProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const now = ctx.svc.now();
const c = await ctx.db.client.findUnique({ where: { id: input.id }, include: { user: true, agency: { select: { name: true } }, subscriptions: { where: { status: 'ACTIVE' } } } });
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
const [history, reversed30, pendingNext] = await Promise.all([
pointsHistory(ctx.db, c.id, { take: 10 }),
ctx.db.pointsReversal.count({ where: { clientId: c.id, targetType: 'ACCRUAL', createdAt: { gte: new Date(now.getTime() - 30 * DAY) } } }),
ctx.db.pointsAccrual.findFirst({ where: { clientId: c.id, status: 'PENDING' }, orderBy: { availableAt: 'asc' } }),
]);
return {
id: c.id,
name: name(c.user),
phone: c.user.phone,
username: c.user.username,
tier: membershipLevel(c.subscriptions, now),
agency: c.agency?.name ?? null,
balance: c.pointsActive,
pending: c.pointsPending,
pendingNextAt: pendingNext?.availableAt ?? null,
debt: c.pointsDebt,
burnAt: c.pointsActive > 0 && c.pointsActivityAt ? new Date(c.pointsActivityAt.getTime() + s['points.burnDays'] * DAY) : null,
streak: c.streakCount,
flagged: reversed30 > 2,
history,
};
}),
preview: pointsProcedure
.input(z.object({ clientId: z.string(), categoryId: z.string(), orderAmount: z.number().int().min(0), excludedAmount: z.number().int().min(0).default(0), paidWithPoints: z.number().int().min(0).default(0), serviceDate: z.coerce.date() }))
.query(async ({ ctx, input }) => previewAccrual(ctx.db, input, await getSettings(ctx.db), ctx.svc.now())),
/** Начисление по заказу или бонус */
accrue: pointsProcedure
.input(
z.object({
clientId: z.string(),
kind: z.enum(['ORDER', 'BONUS']),
categoryId: z.string().optional(),
bonusTypeId: z.string().optional(),
orderRef: z.string().trim().max(60).nullable().optional(),
orderAmount: z.number().int().min(0).optional(),
excludedAmount: z.number().int().min(0).optional(),
paidWithPoints: z.number().int().min(0).optional(),
serviceDate: z.coerce.date(),
amount: z.number().int().min(1).max(10_000_000).optional(),
reason: z.string().trim().max(500).nullable().optional(),
commentForClient: z.string().trim().max(300).nullable().optional(),
noWait: z.boolean().optional(),
ttlDays: z.number().int().min(1).max(1000).nullable().optional(),
idempotencyKey: idem,
}),
)
.mutation(({ ctx, input }) => accruePoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)),
/** Списание командой: по заказу вне системы или по звонку (с причиной) */
redeem: pointsProcedure
.input(z.object({ clientId: z.string(), amount: z.number().int().min(1), orderRef: z.string().trim().max(60).nullable().optional(), orderAmount: z.number().int().min(1).nullable().optional(), reason: z.string().trim().min(1).max(500), idempotencyKey: idem }))
.mutation(({ ctx, input }) => redeemPoints(ctx.svc, { ...input, channel: 'WEB' }, ctx.actor)),
/** Запросы клиентов «Оплатить бариками» */
requests: pointsProcedure.query(async ({ ctx }) => {
const list = await ctx.db.pointsRedemption.findMany({ where: { status: 'REQUESTED' }, orderBy: { requestedAt: 'asc' }, include: { client: { include: { user: true } }, order: { select: { id: true, number: true, totalPrice: true, pointsUsed: true } } } });
return list.map((r) => ({ id: r.id, clientId: r.clientId, name: name(r.client.user), balance: r.client.pointsActive, amount: r.amount, note: r.note, order: r.order, requestedAt: r.requestedAt, expiresAt: r.expiresAt }));
}),
confirm: pointsProcedure
.input(z.object({ id: z.string(), orderAmount: z.number().int().min(1), amount: z.number().int().min(1).optional() }))
.mutation(({ ctx, input }) => confirmRedemption(ctx.svc, { redemptionId: input.id, orderAmount: input.orderAmount, amount: input.amount }, ctx.actor)),
reject: pointsProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason }, ctx.actor)),
reversePreview: pointsProcedure.input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string() })).query(({ ctx, input }) => reversePreview(ctx.db, input)),
reverse: pointsProcedure
.input(z.object({ type: z.enum(['ACCRUAL', 'REDEMPTION']), id: z.string(), reason: z.string().trim().min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
if (input.type === 'ACCRUAL') await reverseAccrual(ctx.svc, { accrualId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor);
else await reverseRedemption(ctx.svc, { redemptionId: input.id, reason: input.reason, channel: 'WEB' }, ctx.actor);
}),
/** Журнал: все операции с автором и каналом; фильтры; выгрузка — на клиенте */
ledger: pointsProcedure
.input(
z.object({
clientId: z.string().optional(),
adminId: z.string().optional(),
type: z.enum(['accrual', 'bonus', 'redemption', 'reversal']).optional(),
categoryId: z.string().optional(),
channel: z.enum(['WEB', 'BOT', 'CSV', 'AUTO', 'CLIENT']).optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
take: z.number().int().min(1).max(2000).default(200),
}),
)
.query(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const at = input.from || input.to ? { gte: input.from, lt: input.to } : undefined;
const want = (t: string) => !input.type || input.type === t;
const [acc, red, rev, admins] = await Promise.all([
want('accrual') || want('bonus')
? ctx.db.pointsAccrual.findMany({
where: { clientId: input.clientId, createdById: input.adminId, categoryId: input.categoryId, channel: input.channel, createdAt: at, ...(input.type === 'accrual' ? { kind: 'ORDER' } : input.type === 'bonus' ? { kind: 'BONUS' } : {}) },
include: { client: { include: { user: true } }, category: true, bonusType: true },
orderBy: { createdAt: 'desc' },
take: input.take,
})
: [],
want('redemption') && !input.categoryId
? ctx.db.pointsRedemption.findMany({ where: { clientId: input.clientId, confirmedById: input.adminId, channel: input.channel, status: { in: ['CONFIRMED', 'REVERSED', 'REJECTED'] }, requestedAt: at }, include: { client: { include: { user: true } }, order: { select: { number: true } } }, orderBy: { requestedAt: 'desc' }, take: input.take })
: [],
want('reversal') && !input.categoryId ? ctx.db.pointsReversal.findMany({ where: { clientId: input.clientId, createdById: input.adminId, channel: input.channel, createdAt: at }, orderBy: { createdAt: 'desc' }, take: input.take }) : [],
ctx.db.adminUser.findMany({ select: { id: true, name: true } }),
]);
const adminName = new Map(admins.map((a) => [a.id, a.name]));
const revClients = rev.length ? new Map((await ctx.db.client.findMany({ where: { id: { in: [...new Set(rev.map((r) => r.clientId))] } }, include: { user: true } })).map((c) => [c.id, name(c.user)])) : new Map<string, string>();
const rows = [
...acc.map((a) => {
const diff = a.suggestedAmount ? Math.abs(a.amount - a.suggestedAmount) / Math.max(1, a.suggestedAmount) : 0;
return {
id: a.id,
at: a.createdAt,
type: a.kind === 'BONUS' ? ('bonus' as const) : ('accrual' as const),
clientId: a.clientId,
client: name(a.client.user),
amount: a.amount,
suggested: a.suggestedAmount,
highlight: a.kind === 'ORDER' && diff * 100 > s['points.diffHighlightPct'],
status: a.status as string,
what: a.kind === 'BONUS' ? ((a.bonusType?.name as Record<string, string>) ?? null) : ((a.category?.name as Record<string, string>) ?? null),
orderRef: a.orderRef,
orderAmount: a.orderAmount,
tier: a.tier,
ratePct: a.ratePct,
author: a.createdById ? (adminName.get(a.createdById) ?? '—') : null,
channel: a.channel as string,
reason: a.reason,
comment: a.commentForClient,
canReverse: a.status === 'PENDING' || a.status === 'ACTIVE',
};
}),
...red.map((r) => ({
id: r.id,
at: r.confirmedAt ?? r.requestedAt,
type: 'redemption' as const,
clientId: r.clientId,
client: name(r.client.user),
amount: -r.amount,
suggested: null,
highlight: false,
status: r.status as string,
what: null,
orderRef: r.order ? `#${r.order.number}` : r.orderRef,
orderAmount: r.orderAmount,
tier: null,
ratePct: null,
author: r.confirmedById ? (adminName.get(r.confirmedById) ?? '—') : null,
channel: r.channel as string,
reason: r.reason,
comment: r.note,
canReverse: r.status === 'CONFIRMED',
})),
...rev.map((r) => ({
id: r.id,
at: r.createdAt,
type: 'reversal' as const,
clientId: r.clientId,
client: revClients.get(r.clientId) ?? '—',
amount: r.targetType === 'ACCRUAL' ? -r.amount : r.amount,
suggested: null,
highlight: r.debt > 0,
status: r.targetType,
what: null,
orderRef: null,
orderAmount: null,
tier: null,
ratePct: null,
author: r.createdById ? (adminName.get(r.createdById) ?? '—') : null,
channel: r.channel as string,
reason: r.reason,
comment: r.debt > 0 ? `debt:${r.debt}` : null,
canReverse: false,
})),
];
return rows.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, input.take);
}),
/** CSV: телефон, категория, сумма заказа, дата, номер заказа. dryRun — предпросмотр с ошибками */
import: pointsProcedure
.input(z.object({ dryRun: z.boolean(), rows: z.array(z.object({ phone: z.string().max(40), category: z.string().max(40), orderAmount: z.number().int().min(0), date: z.string().max(20), orderRef: z.string().max(60) })).max(1000) }))
.mutation(async ({ ctx, input }) => {
const s = await getSettings(ctx.db);
const now = ctx.svc.now();
const cats = await ctx.db.pointsCategory.findMany();
const seen = new Set<string>();
const out: { line: number; clientId?: string; client?: string; amount?: number; error?: string }[] = [];
for (const [i, r] of input.rows.entries()) {
const line = i + 1;
const digits = r.phone.replace(/[^\d]/g, '');
const cat = cats.find((c) => c.code === r.category.trim().toLowerCase() || Object.values(c.name as Record<string, string>).some((n) => n.toLowerCase() === r.category.trim().toLowerCase()));
const date = new Date(`${r.date.trim()}T12:00:00+04:00`);
const ref = r.orderRef.trim();
if (!cat) {
out.push({ line, error: 'category' });
continue;
}
if (Number.isNaN(date.getTime())) {
out.push({ line, error: 'date' });
continue;
}
const users = digits.length >= 8 ? await ctx.db.user.findMany({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } }) : [];
const cl = users.find((u) => u.client)?.client;
if (!cl) {
out.push({ line, error: 'client' });
continue;
}
if (!ref || seen.has(ref) || (await ctx.db.pointsAccrual.findFirst({ where: { orderRef: ref, status: { not: 'REVERSED' } } }))) {
out.push({ line, error: 'duplicate' });
continue;
}
seen.add(ref);
const p = await previewAccrual(ctx.db, { clientId: cl.id, categoryId: cat.id, orderAmount: r.orderAmount, serviceDate: date }, s, now);
const u = users.find((x) => x.client)!;
if (!input.dryRun) {
await accruePoints(ctx.svc, { clientId: cl.id, kind: 'ORDER', categoryId: cat.id, orderAmount: r.orderAmount, orderRef: ref, serviceDate: date, channel: 'CSV', idempotencyKey: `csv:${ref}` }, ctx.actor);
}
out.push({ line, clientId: cl.id, client: name(u), amount: p.suggested });
}
return { rows: out, total: out.reduce((sum, r) => sum + (r.amount ?? 0), 0), errors: out.filter((r) => r.error).length };
}),
/** Очередь «Предложено системой» */
suggestions: pointsProcedure.query(async ({ ctx }) => {
const list = await ctx.db.pointsSuggestion.findMany({ where: { status: 'OPEN' }, orderBy: { createdAt: 'asc' }, include: { client: { include: { user: true } } }, take: 200 });
const admins = new Map((await ctx.db.adminUser.findMany({ select: { id: true, name: true } })).map((a) => [a.id, a.name]));
return list.map((x) => {
const p = x.payload as Record<string, unknown>;
return { id: x.id, clientId: x.clientId, name: name(x.client.user), mechanic: x.mechanic, amount: x.suggestedAmount, payload: p, by: typeof p.adminId === 'string' ? (admins.get(p.adminId) ?? null) : null, createdAt: x.createdAt };
});
}),
acceptSuggestion: superProcedure
.input(z.object({ id: z.string(), amount: z.number().int().min(1).optional(), reason: z.string().trim().max(500).nullable().optional() }))
.mutation(({ ctx, input }) => acceptSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })),
rejectSuggestion: superProcedure.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500) })).mutation(({ ctx, input }) => rejectSuggestion(ctx.svc, input, { adminId: ctx.admin.id, owner: true })),
/** Справочники — только суперадмин */
saveCategory: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, rateBarev: z.number().int().min(0).max(100), ratePlus: z.number().int().min(0).max(100), rateElite: z.number().int().min(0).max(100), active: z.boolean(), sortOrder: z.number().int().default(0) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.pointsCategory.update({ where: { id }, data }) : ctx.db.pointsCategory.create({ data });
}),
saveBonusType: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().regex(/^[a-z_]{2,30}$/), name: L5, defaultAmount: z.number().int().min(0), ttlDays: z.number().int().min(1).max(1000).nullable(), active: z.boolean(), sortOrder: z.number().int().default(0) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return id ? ctx.db.pointsBonusType.update({ where: { id }, data }) : ctx.db.pointsBonusType.create({ data });
}),
/** Отчёт за период: начислено, потрачено, сгорело, отменено, обязательства, redemption rate, повторные заказы, топы */
report: superProcedure
.input(z.object({ from: z.coerce.date(), to: z.coerce.date(), categoryId: z.string().optional(), agencyId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const at = { gte: input.from, lt: input.to };
const clientWhere = input.agencyId ? { agencyId: input.agencyId } : {};
const accWhere = { createdAt: at, categoryId: input.categoryId, client: clientWhere };
const [accrued, spent, expired, reversed, liabilities, byCategory, topClients, topAgencies, payers] = await Promise.all([
ctx.db.pointsAccrual.aggregate({ where: accWhere, _sum: { amount: true } }),
ctx.db.pointsRedemption.aggregate({ where: { status: 'CONFIRMED', confirmedAt: at, client: clientWhere }, _sum: { amount: true } }),
// сгоревшее — по начислениям этого периода (дату сгорания журнал хранит в статусе)
ctx.db.pointsAccrual.aggregate({ where: { ...accWhere, expiredAmount: { gt: 0 } }, _sum: { expiredAmount: true } }),
ctx.db.pointsReversal.aggregate({ where: { targetType: 'ACCRUAL', createdAt: at }, _sum: { amount: true } }),
ctx.db.pointsAccrual.aggregate({ where: { status: { in: ['ACTIVE', 'PENDING'] }, categoryId: input.categoryId, client: clientWhere }, _sum: { remaining: true } }),
ctx.db.$queryRaw<{ code: string; name: Record<string, string>; points: number; revenue: number }[]>`
SELECT pc.code, pc.name, COALESCE(SUM(a.amount),0)::int AS points, COALESCE(SUM(COALESCE(a."orderAmount",0) - a."excludedAmount" - a."paidWithPoints"),0)::int AS revenue
FROM "PointsAccrual" a JOIN "PointsCategory" pc ON pc.id = a."categoryId"
WHERE a.kind = 'ORDER' AND a.status <> 'REVERSED' AND a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to}
GROUP BY pc.code, pc.name`,
ctx.db.client.findMany({ where: { pointsActive: { gt: 0 }, ...clientWhere }, orderBy: { pointsActive: 'desc' }, take: 10, include: { user: true } }),
ctx.db.$queryRaw<{ name: string; n: number }[]>`
SELECT ag.name, COUNT(*)::int AS n FROM "PointsAccrual" a
JOIN "PointsBonusType" bt ON bt.id = a."bonusTypeId" AND bt.code = 'housewarming'
JOIN "Client" c ON c.id = a."clientId" JOIN "Agency" ag ON ag.id = c."agencyId"
WHERE a."createdAt" >= ${input.from} AND a."createdAt" < ${input.to}
GROUP BY ag.name ORDER BY n DESC LIMIT 10`,
// повторный заказ за 30 дней: у клиентов с бариками и без
ctx.db.$queryRaw<{ with_points: boolean; clients: number; repeat: number; multi: number }[]>`
WITH firsts AS (
SELECT o."clientId", MIN(o."createdAt") AS first_at, COUNT(DISTINCT s."pointsCategoryId") AS cats
FROM "Order" o JOIN "Service" s ON s.id = o."serviceId"
WHERE o.status <> 'CANCELLED' AND o."createdAt" >= ${input.from} AND o."createdAt" < ${input.to}
GROUP BY o."clientId"
)
SELECT (c."pointsActive" + c."pointsPending" > 0 OR EXISTS (SELECT 1 FROM "PointsAccrual" a WHERE a."clientId" = f."clientId")) AS with_points,
COUNT(*)::int AS clients,
COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o2 WHERE o2."clientId" = f."clientId" AND o2.status <> 'CANCELLED' AND o2."createdAt" > f.first_at AND o2."createdAt" <= f.first_at + interval '30 days'))::int AS repeat,
COUNT(*) FILTER (WHERE f.cats >= 2)::int AS multi
FROM firsts f JOIN "Client" c ON c.id = f."clientId"
GROUP BY 1`,
]);
const a = accrued._sum.amount ?? 0;
const sp = spent._sum.amount ?? 0;
return {
accrued: a,
spent: sp,
expired: expired._sum.expiredAmount ?? 0,
reversed: reversed._sum.amount ?? 0,
liabilities: liabilities._sum.remaining ?? 0,
redemptionRate: a ? Math.round((sp / a) * 1000) / 10 : 0,
byCategory: byCategory.map((c) => ({ ...c, costPct: c.revenue ? Math.round((c.points / c.revenue) * 1000) / 10 : 0 })),
topClients: topClients.map((c) => ({ id: c.id, name: name(c.user), balance: c.pointsActive })),
topAgencies,
retention: payers.map((p) => ({ withPoints: p.with_points, clients: p.clients, repeatPct: p.clients ? Math.round((p.repeat / p.clients) * 1000) / 10 : 0, multi: p.multi })),
};
}),
});
+6 -5
View File
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
import { averageRating, DAY, LOCALES, PROVIDER_ROLES, startOfYerevanDay } from '@barev/core';
import type { Prisma } from '@barev/db';
import { orderPayout, rateLabel } from '@barev/services';
import { getSettings, orderPayout, rateLabel } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
@@ -30,7 +30,7 @@ const profileInput = z.object({
rate: z.object({ rateType: z.enum(['FIXED', 'PERCENT']), rateValue: z.number().int().min(0), payoutPresetId: z.string().nullable() }).optional(),
});
const payoutInclude = { company: { select: { rateType: true, rateValue: true } }, payoutPreset: { select: { name: true, rules: true } } } as const;
const payoutInclude = { company: { select: { deal: true, fotPct: true } }, payoutPreset: { select: { name: true, rules: true } } } as const;
export const boProvidersRouter = router({
list: providersProcedure
@@ -97,7 +97,7 @@ export const boProvidersRouter = router({
where: { id: input.id },
include: {
...payoutInclude,
company: { select: { id: true, name: true, rateType: true, rateValue: true } },
company: { select: { id: true, name: true, deal: true, fotPct: true } },
user: { select: { username: true, firstName: true } },
districts: true,
schedule: { orderBy: [{ weekday: 'asc' }, { startMinute: 'asc' }] },
@@ -127,19 +127,20 @@ export const boProvidersRouter = router({
// деньги: заработано (по ставке исполнителя) минус выплачено = к выплате
let money = null;
if (ctx.isSuper) {
const fot = { fotPct: (await getSettings(ctx.db))['partner.fotPct'] };
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
const [doneAll, payouts] = await Promise.all([
ctx.db.order.findMany({ where: { providerId: p.id, ...DONE }, select: { completedAt: true, totalPrice: true, basePrice: true, params: true, priceBreakdown: true, service: { select: { pricing: true, paramsSchema: true } } } }),
ctx.db.payout.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' } }),
]);
const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p) }));
const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p, fot) }));
const sum = (f: (x: (typeof earn)[number]) => boolean) => earn.filter(f).reduce((s, x) => s + x.sum, 0);
const earnedTotal = sum(() => true);
const paidTotal = payouts.filter((x) => x.status === 'PAID').reduce((s, x) => s + x.amount, 0);
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: payouts.map((x) => x.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
money = {
rate: rateLabel(p),
rate: rateLabel(p, fot),
thisMonth: sum((x) => x.at >= monthStart),
prevMonth: sum((x) => x.at >= prevMonthStart && x.at < monthStart),
earnedTotal,
+23 -1
View File
@@ -1,5 +1,9 @@
import { defaultParams, discountFor, LOCALES } from '@barev/core';
import { rateOrder,
clientPoints,
pointsHistory,
redeemQuote,
requestRedemption,
applyAgencyCode,
availableSlots,
cancelByClient,
@@ -54,6 +58,8 @@ export const clientRouter = router({
membership,
agencyName: agency?.name ?? null,
giftUsed: !!ctx.client.giftUsedAt,
/** барики: доступный баланс */
points: ctx.client.pointsActive,
cardCode: ctx.client.cardCode,
barevPlusPrice: settings['barevPlus.monthlyPrice'],
freeCancelHours: settings['cancel.freeHours'],
@@ -185,7 +191,9 @@ export const clientRouter = router({
.input(z.object({ serviceCode: z.string(), params: paramsInput, promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const r = await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params, input.promoCode);
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null };
// барики: сколько можно списать в этом заказе (с промокодом — нельзя)
const points = await redeemQuote(ctx.db, ctx.client.id, r.quote.totalPrice, await getSettings(ctx.db));
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null, points };
}),
/** ORD-3 */
@@ -212,6 +220,8 @@ export const clientRouter = router({
repeatOfId: z.string().optional(),
/** промокод: применится, если выгоднее скидки подписки */
promoCode: z.string().trim().max(40).optional(),
/** оплатить часть бариками (с промокодом нельзя) */
usePoints: z.number().int().min(0).max(10_000_000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -219,6 +229,18 @@ export const clientRouter = router({
return { id: order.id, number: order.number };
}),
/** Барики: экран, история, запрос на оплату заказа вне приложения или уже созданного заказа */
points: router({
summary: clientProcedure.query(({ ctx }) => clientPoints(ctx.svc, ctx.client.id)),
history: clientProcedure.input(z.object({ before: z.date().optional() }).optional()).query(({ ctx, input }) => pointsHistory(ctx.db, ctx.client.id, { take: 30, before: input?.before })),
request: consentedClientProcedure
.input(z.object({ amount: z.number().int().min(1).max(10_000_000), orderId: z.string().optional(), note: z.string().trim().max(300).optional() }))
.mutation(async ({ ctx, input }) => {
const r = await requestRedemption(ctx.svc, { clientId: ctx.client.id, ...input });
return { id: r.id };
}),
}),
orders: router({
list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)),
get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)),
+66 -3
View File
@@ -16,13 +16,38 @@ import {
partnerCabinet,
redeemOffer,
removePartnerMember,
companyStaffCandidates,
exchangeProWebLogin,
getSettings,
partnerAddStaff,
partnerCabinetData,
partnerDeclineOrder,
partnerRemoveManager,
partnerResetStaffInvite,
partnerSaveSettings,
partnerUnassign,
partnerUpdateStaff,
revokeProWebSession,
} from '@barev/services';
import { LOCALES } from '@barev/core';
import { env } from '../env.ts';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { signedUrl } from '../infra.ts';
import { proUserProcedure, providerProcedure, router } from '../trpc.ts';
import { proUserProcedure, providerProcedure, publicProcedure, router } from '../trpc.ts';
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const botLink = (code: string) => `https://t.me/${env.PRO_BOT_USERNAME}?start=${code}`;
const staffInput = z.object({
name: z.string().trim().min(1).max(100),
phone: z.string().trim().max(30).nullable().optional(),
languages: z.array(z.enum(LOCALES)).min(1),
districtIds: z.array(z.string()).max(30),
schedule: z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) })).max(21),
maxOrdersPerDay: z.number().int().min(0).max(20).nullable().optional(),
maxHoursPerDay: z.number().int().min(0).max(24).nullable().optional(),
});
const co = z.object({ companyId: z.string() });
/** Кабинет партнёра клуба: собственник и сотрудники */
const partnerRouter = router({
@@ -50,6 +75,7 @@ export const proRouter = router({
: null,
companies: ctx.identity.companies.map((c) => ({ id: c.id, name: c.name })),
partners: ctx.identity.partners,
web: ctx.web,
};
}),
@@ -98,15 +124,52 @@ export const proRouter = router({
return { url: await signedUrl(file.storageKey) };
}),
/** PRO-7: диспетчер компании */
/** Вход в кабинет из браузера: одноразовый код из бота → токен на 30 дней */
webLogin: publicProcedure.input(z.object({ code: z.string().min(10).max(100) })).mutation(async ({ ctx, input }) => ({ token: await exchangeProWebLogin(ctx.svc, input.code) })),
webLogout: publicProcedure.mutation(async ({ ctx }) => {
const auth = ctx.req.headers.authorization;
if (auth?.startsWith('pro ')) await revokeProWebSession(ctx.db, auth.slice(4));
}),
/** PRO-7: менеджер партнёра (диспетчер компании) */
dispatcher: router({
board: proUserProcedure.input(z.object({ companyId: z.string() })).query(({ ctx, input }) => companyBoard(ctx.svc, input.companyId, ctx.identity.user.id)),
/** Кабинет: заказы, сотрудники, доход, настройки */
cabinet: proUserProcedure.input(co).query(async ({ ctx, input }) => {
const d = await partnerCabinetData(ctx.svc, input.companyId, ctx.identity.user.id);
return {
...d,
company: { ...d.company, managerInvite: undefined, managerLink: botLink(d.company.managerInvite) },
staff: d.staff.map((p) => ({ ...p, inviteCode: undefined, inviteLink: p.inviteCode ? botLink(p.inviteCode) : null })),
};
}),
staffForOrder: proUserProcedure.input(z.object({ companyId: z.string(), orderId: z.string() })).query(async ({ ctx, input }) => {
if (!ctx.identity.companies.some((c) => c.id === input.companyId)) throw new TRPCError({ code: 'FORBIDDEN' });
return companyStaffForOrder(ctx.svc, input.companyId, input.orderId);
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, select: { companyId: true } });
if (order?.companyId !== input.companyId) throw new TRPCError({ code: 'NOT_FOUND' });
return companyStaffCandidates(ctx.db, input.companyId, input.orderId, await getSettings(ctx.db));
}),
assign: proUserProcedure
.input(z.object({ companyId: z.string(), orderId: z.string(), providerId: z.string() }))
.mutation(({ ctx, input }) => dispatcherAssign(ctx.svc, { ...input, dispatcherUserId: ctx.identity.user.id })),
/** Отказаться от заказа — он вернётся оператору */
decline: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerDeclineOrder(ctx.svc, { ...input, userId: ctx.identity.user.id })),
/** Снять сотрудника с заказа (переназначить) */
unassign: proUserProcedure.input(co.extend({ orderId: z.string() })).mutation(({ ctx, input }) => partnerUnassign(ctx.svc, { ...input, userId: ctx.identity.user.id })),
addStaff: proUserProcedure.input(co.extend({ staff: staffInput })).mutation(async ({ ctx, input }) => {
const r = await partnerAddStaff(ctx.svc, { companyId: input.companyId, userId: ctx.identity.user.id, staff: input.staff });
return { id: r.id, inviteLink: botLink(r.inviteCode) };
}),
updateStaff: proUserProcedure
.input(co.extend({ providerId: z.string(), staff: staffInput, active: z.boolean().optional() }))
.mutation(({ ctx, input }) => partnerUpdateStaff(ctx.svc, { ...input, userId: ctx.identity.user.id })),
resetStaffInvite: proUserProcedure.input(co.extend({ providerId: z.string() })).mutation(async ({ ctx, input }) => {
const r = await partnerResetStaffInvite(ctx.svc, { ...input, userId: ctx.identity.user.id });
return { inviteLink: botLink(r.inviteCode) };
}),
saveSettings: proUserProcedure
.input(co.extend({ autoAssign: z.boolean(), travelGapMinutes: z.number().int().min(0).max(240) }))
.mutation(({ ctx, input }) => partnerSaveSettings(ctx.svc, { ...input, userId: ctx.identity.user.id })),
removeManager: proUserProcedure.input(co.extend({ managerId: z.string() })).mutation(({ ctx, input }) => partnerRemoveManager(ctx.svc, { ...input, userId: ctx.identity.user.id })),
}),
});
+4 -3
View File
@@ -83,9 +83,10 @@ export const publicRouter = router({
.input(
z.object({
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])),
preferredAt: z.iso.datetime().transform((v) => new Date(v)),
districtCode: z.string().max(40),
addressText: z.string().max(300),
// обязательны только параметры уборки и телефон; остальное бот доспросит или уточнит оператор
preferredAt: z.iso.datetime().transform((v) => new Date(v)).optional(),
districtCode: z.string().max(40).optional(),
addressText: z.string().max(300).optional(),
note: z.string().max(1000).optional(),
phone: z.string().max(30),
name: z.string().max(80).optional(),
+24 -1
View File
@@ -4,7 +4,7 @@ import multipart, { type MultipartFile } from '@fastify/multipart';
import * as Sentry from '@sentry/node';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { getFlow } from '@barev/core';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend, startTextOverrides } from '@barev/services';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffChecklistItem, staffSend, startTextOverrides } from '@barev/services';
import { getFile, putFile } from '@barev/services/storage';
import { TelegramSender } from '@barev/services/telegram';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
@@ -187,6 +187,29 @@ app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId
return { id: doc.id };
});
/** Команда загружает фото к пункту чек-листа за исполнителя и отмечает пункт */
app.post<{ Params: { orderId: string } }>('/files/bo/checklist-photo/:orderId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
const order = await db.order.findUnique({ where: { id: req.params.orderId } });
if (!order) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
const code = multipartField(part, 'code');
if (!code) return reply.code(400).send({ error: 'BAD_INPUT' });
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
const file = await db.orderFile.create({ data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'ADMIN', uploadedById: staff.admin.id } });
try {
await staffChecklistItem(svc, { orderId: order.id, adminId: staff.admin.id, code, checked: true, fileId: file.id });
} catch (e) {
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
throw e;
}
return { fileId: file.id };
});
app.get<{ Params: { docId: string } }>('/files/bo/provider-doc-file/:docId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
+11 -2
View File
@@ -2,7 +2,7 @@
import type {} from '@fastify/cookie';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { initTRPC, TRPCError } from '@trpc/server';
import { DomainError, getClientByTelegramId, proIdentity, startClient } from '@barev/services';
import { DomainError, getClientByTelegramId, proIdentity, proWebSessionUser, startClient } from '@barev/services';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
@@ -86,12 +86,21 @@ export const consentedClientProcedure = clientProcedure.use(async ({ ctx, next }
/** Mini App исполнителя: initData бота исполнителей; пользователь должен быть привязан инвайтом */
export const proUserProcedure = publicProcedure.use(async ({ ctx, next }) => {
// кабинет менеджера партнёра в браузере: токен вместо initData (вход по одноразовой ссылке из бота)
const auth = ctx.req.headers.authorization;
if (auth?.startsWith('pro ')) {
const telegramId = await proWebSessionUser(ctx.db, auth.slice(4), ctx.svc.now());
if (!telegramId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'WEB_SESSION_EXPIRED' });
const identity = await proIdentity(ctx.db, telegramId);
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
return next({ ctx: { tgUser: { id: Number(telegramId), first_name: identity.user.firstName ?? '' }, identity, web: true } });
}
const init = checkInitData(ctx.req, env.proBotToken);
const identity = await proIdentity(ctx.db, BigInt(init.user.id));
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
}
return next({ ctx: { tgUser: init.user, identity } });
return next({ ctx: { tgUser: init.user, identity, web: false } });
});
export const providerProcedure = proUserProcedure.use(async ({ ctx, next }) => {