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:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 })),
|
||||
}),
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user