Backoffice stage 3: orders and providers, auto-assignment

- Auto-assignment: a new order is offered to the best free provider (same ranking as
  the dispatcher hint); a decline or timeout passes it to the next one; nobody is
  offered the same order twice; after N tries it waits for a dispatcher and the team
  is notified once. The worker retries waiting orders every minute. Settings
  assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
  done, cancelled), periods and search; order card with progress, provider or ranked
  candidates ("offer" / "pick automatically"), details, checklist and photos,
  history in plain words, client, money (superadmin), payment, status change,
  reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
  address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
  quality, ratings, upcoming orders, schedule and districts, admission documents,
  block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
  and documents (ProviderDoc).
- Tests for auto-assignment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 02:25:41 +04:00
1 parent 2772837e29
commit e7de4f5893
33 files changed
+4521 -45

No files matched your search

+5 -3
View File
@@ -82,9 +82,11 @@ export const boChatsRouter = router({
}),
/** Написать клиенту из карточки: открывает диалог в канале, куда он пишет (или в указанном) */
openFor: chatProcedure.input(z.object({ clientId: z.string(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => {
const channel = input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined);
const t = await openThreadFor(ctx.svc, { clientId: input.clientId }, channel);
openFor: chatProcedure.input(z.object({ clientId: z.string().optional(), providerId: z.string().optional(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => {
if (!input.clientId && !input.providerId) throw new TRPCError({ code: 'BAD_REQUEST' });
// исполнитель пишет только в поддержку
const channel = input.providerId ? 'SUPPORT' : (input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined));
const t = await openThreadFor(ctx.svc, input.providerId ? { providerId: input.providerId } : { clientId: input.clientId! }, channel);
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return { id: t.id };
}),
+4
View File
@@ -3,6 +3,8 @@ import { boAuthRouter, boTeamRouter } from './auth.ts';
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
import { boDashboardRouter } from './dashboard.ts';
import { boChatsRouter } from './chats.ts';
import { boOrdersRouter } from './orders.ts';
import { boProvidersRouter } from './providers.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
@@ -12,4 +14,6 @@ export const backofficeRouter = router({
clients: boClientsRouter,
segments: boSegmentsRouter,
chats: boChatsRouter,
orders: boOrdersRouter,
providers: boProvidersRouter,
});
+397
View File
@@ -0,0 +1,397 @@
import { DAY, getFlow, kanbanColumn, ORDER_STATUSES, startOfYerevanDay, type ServiceFlow } from '@barev/core';
import type { Db, Prisma } from '@barev/db';
import {
assignmentCandidates,
autoAssign,
availableSlots,
breakdownOf,
createOrder,
getServiceByCode,
getSettings,
markPaid,
offerOrder,
orderPayout,
quoteForClient,
rescheduleByAdmin,
transitionOrder,
unassignOrder,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Заказы ведёт поддержка (диспетчер); суперадмину — всё */
const ordersProcedure = rolesProcedure('SUPPORT');
/** Группы списка: ждёт исполнителя · предложен · назначен · в работе · готово · отменён */
export const ORDER_GROUPS = ['new', 'pending', 'assigned', 'in_progress', 'done', 'cancelled'] as const;
type Group = (typeof ORDER_GROUPS)[number];
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) =>
[u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
const listInclude = {
service: { select: { code: true, flow: true, category: { select: { code: true } } } },
district: { select: { code: true } },
provider: { select: { id: true, name: true } },
company: { select: { id: true, name: true } },
client: { include: { user: { select: { firstName: true, lastName: true, username: true } } } },
offers: { where: { status: 'OFFERED' as const }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
rating: { select: { score: true } },
history: { where: { event: { in: ['autoNoCandidate', 'autoGaveUp', 'autoOffered', 'offered', 'assignedToCompany'] } }, orderBy: { createdAt: 'desc' as const }, take: 1 },
} satisfies Prisma.OrderInclude;
type ListRow = Prisma.OrderGetPayload<{ include: typeof listInclude }>;
function groupOf(o: ListRow): Group {
const col = kanbanColumn(o.service.flow as ServiceFlow, o.status, { hasProvider: !!o.providerId, hasPendingOffer: o.offers.length > 0, hasCompany: !!o.companyId });
if (col === 'assigned' && !o.providerId) return 'pending';
return col === 'new' ? 'new' : col;
}
function rowView(o: ListRow, showMoney: boolean) {
const offer = o.offers[0];
const lastAuto = o.history[0]?.event ?? null;
return {
id: o.id,
number: o.number,
service: o.service.code,
category: o.service.category.code,
flow: o.service.flow,
status: o.status,
group: groupOf(o),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
district: o.district?.code ?? null,
address: o.addressText,
isOnline: o.isOnline,
client: { id: o.clientId, name: name(o.client.user) },
provider: o.provider,
company: o.company,
/** ждёт ответа: кому предложено и до какого времени; auto — предложила система */
pending: offer ? { name: offer.provider?.name ?? offer.company?.name ?? '', expiresAt: offer.expiresAt, auto: !offer.createdById, company: !offer.providerId } : null,
/** автораспределение не нашло исполнителя — нужен человек */
needsDispatcher: !o.providerId && !offer && (lastAuto === 'autoNoCandidate' || lastAuto === 'autoGaveUp'),
total: showMoney ? o.totalPrice : null,
paymentStatus: o.paymentStatus,
rating: o.rating?.score ?? null,
};
}
const periodInput = z.enum(['today', 'tomorrow', 'week', 'past', 'all']);
function periodWhere(period: z.infer<typeof periodInput>, now: Date): Prisma.OrderWhereInput {
const today = startOfYerevanDay(now);
switch (period) {
case 'today':
return { scheduledAt: { gte: today, lt: new Date(today.getTime() + DAY) } };
case 'tomorrow':
return { scheduledAt: { gte: new Date(today.getTime() + DAY), lt: new Date(today.getTime() + 2 * DAY) } };
case 'week':
return { scheduledAt: { gte: today, lt: new Date(today.getTime() + 7 * DAY) } };
case 'past':
return { scheduledAt: { lt: today } };
default:
return {};
}
}
function searchWhere(q: string | undefined): Prisma.OrderWhereInput {
const s = q?.trim();
if (!s) return {};
const n = Number(s.replace(/^[#№]/, ''));
return {
OR: [
...(Number.isInteger(n) && n > 0 ? [{ number: n }] : []),
{ addressText: { contains: s, mode: 'insensitive' as const } },
{ client: { user: { OR: [{ firstName: { contains: s, mode: 'insensitive' as const } }, { lastName: { contains: s, mode: 'insensitive' as const } }, { username: { contains: s.replace(/^@/, ''), mode: 'insensitive' as const } }, { phone: { contains: s } }] } } },
{ provider: { name: { contains: s, mode: 'insensitive' as const } } },
],
};
}
/** Активные заказы и закрытые за последние 7 дней — из них считаем группы */
async function loadRows(db: Db, where: Prisma.OrderWhereInput, now: Date) {
const weekAgo = new Date(now.getTime() - 7 * DAY);
return db.order.findMany({
where: { AND: [where, { OR: [{ status: { notIn: ['DONE', 'COMPLETED', 'PLAN_SENT', 'CANCELLED', 'LESSON_DONE'] } }, { updatedAt: { gte: weekAgo } }, { scheduledAt: { gte: weekAgo } }] }] },
include: listInclude,
orderBy: { scheduledAt: 'asc' },
take: 600,
});
}
/** История заказа понятными словами: кто (имя сотрудника / исполнителя / клиента / система) */
async function historyView(db: Db, o: { client: { user: { firstName: string | null; lastName: string | null; username: string | null } }; history: { id: string; fromStatus: string | null; toStatus: string; event: string | null; actorType: string; actorId: string | null; comment: string | null; createdAt: Date }[] }) {
const ids = (t: string) => [...new Set(o.history.filter((h) => h.actorType === t && h.actorId).map((h) => h.actorId!))];
const [admins, providers, dispatchers] = await Promise.all([
db.adminUser.findMany({ where: { id: { in: ids('ADMIN') } }, select: { id: true, name: true } }),
db.provider.findMany({ where: { id: { in: [...ids('PROVIDER'), ...ids('SYSTEM')] } }, select: { id: true, name: true } }),
db.user.findMany({ where: { id: { in: ids('DISPATCHER') } }, select: { id: true, firstName: true, lastName: true, username: true } }),
]);
const who = new Map<string, string>([...admins.map((a) => [a.id, a.name] as const), ...providers.map((p) => [p.id, p.name] as const), ...dispatchers.map((u) => [u.id, name(u)] as const)]);
return o.history.map((h) => ({
id: h.id,
from: h.fromStatus,
to: h.toStatus,
event: h.event,
actor: h.actorType,
actorName: h.actorType === 'CLIENT' ? name(o.client.user) : h.actorId ? (who.get(h.actorId) ?? null) : null,
comment: h.comment,
at: h.createdAt,
}));
}
const idInput = z.object({ id: z.string() });
export const boOrdersRouter = router({
/** Счётчики групп (без учёта фильтра группы) */
counts: ordersProcedure.input(z.object({ period: periodInput.default('all'), search: z.string().max(100).optional() }).optional()).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const rows = await loadRows(ctx.db, { ...periodWhere(input?.period ?? 'all', now), ...searchWhere(input?.search) }, now);
const counts = Object.fromEntries(ORDER_GROUPS.map((g) => [g, 0])) as Record<Group, number>;
let needsDispatcher = 0;
for (const r of rows) {
const v = rowView(r, false);
counts[v.group]++;
if (v.needsDispatcher) needsDispatcher++;
}
return { ...counts, needsDispatcher };
}),
list: ordersProcedure
.input(
z.object({
group: z.enum([...ORDER_GROUPS, 'active', 'all']).default('active'),
period: periodInput.default('all'),
search: z.string().max(100).optional(),
service: z.string().max(40).optional(),
providerId: z.string().optional(),
clientId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const where: Prisma.OrderWhereInput = {
...periodWhere(input.period, now),
...searchWhere(input.search),
...(input.service ? { service: { code: input.service } } : {}),
...(input.providerId ? { providerId: input.providerId } : {}),
...(input.clientId ? { clientId: input.clientId } : {}),
};
const rows = (await loadRows(ctx.db, where, now)).map((r) => rowView(r, ctx.isSuper));
const filtered = rows.filter((r) => (input.group === 'all' ? true : input.group === 'active' ? !['done', 'cancelled'].includes(r.group) : r.group === input.group));
// закрытые — свежие сверху; активные — ближайшие сверху
if (input.group === 'done' || input.group === 'cancelled') filtered.reverse();
return filtered;
}),
get: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const o = await ctx.db.order.findUnique({
where: { id: input.id },
include: {
service: { include: { category: true } },
district: true,
provider: { include: { company: true, payoutPreset: true, user: { select: { username: true } } } },
company: true,
client: { include: { user: true, subscriptions: { where: { status: 'ACTIVE' } } } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
offers: { orderBy: { createdAt: 'desc' }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
files: { where: { deletedAt: null }, select: { id: true, kind: true, mime: true, createdAt: true } },
paymentMethod: true,
rating: true,
},
});
if (!o) throw new TRPCError({ code: 'NOT_FOUND' });
const flow = getFlow(o.service.flow);
const now = ctx.svc.now();
// «Назначен» — только через предложение исполнителю; «Новый» — через «Снять исполнителя»
const adminTransitions = [...new Set(flow.transitions.filter((t) => t.from === o.status && t.by.includes('ADMIN') && t.to !== flow.initial && t.to !== flow.assigned).map((t) => t.to))];
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 sub = o.client.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now);
return {
id: o.id,
number: o.number,
service: o.service.code,
category: o.service.category.code,
flow: o.service.flow,
status: o.status,
steps: flow.steps,
group: kanbanColumn(o.service.flow as ServiceFlow, o.status, { hasProvider: !!o.providerId, hasPendingOffer: !!pending, hasCompany: !!o.companyId }),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
params: o.params as Record<string, unknown>,
isOnline: o.isOnline,
district: o.district?.code ?? null,
address: o.addressText,
note: o.note,
locale: o.locale,
createdAt: o.createdAt,
client: { id: o.clientId, name: name(o.client.user), username: o.client.user.username, phone: o.client.user.phone, plan: sub?.plan ?? null },
provider: o.provider ? { id: o.provider.id, name: o.provider.name, phone: o.provider.phone, username: o.provider.user?.username ?? null } : null,
company: o.company ? { id: o.company.id, name: o.company.name } : null,
pending: pending ? { id: pending.id, name: pending.provider?.name ?? pending.company?.name ?? '', expiresAt: pending.expiresAt, auto: !pending.createdById, company: !pending.providerId } : null,
offers: o.offers.map((x) => ({
id: x.id,
name: x.provider?.name ?? x.company?.name ?? '',
company: !x.providerId,
status: x.status,
by: x.createdById ? (admins.get(x.createdById) ?? null) : null,
createdAt: x.createdAt,
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 })),
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,
// деньги — только суперадмину; статус оплаты видят все (поддержка отмечает оплату наличными)
money: ctx.isSuper
? { base: o.basePrice, discountPct: o.discountPct, discount: o.discountAmount, total: o.totalPrice, cancelFee: o.cancelFee, payout, breakdown: breakdownOf(o) }
: null,
paymentStatus: o.paymentStatus,
paidAt: o.paidAt,
paymentMethod: o.paymentMethod ? (o.paymentMethod.name as Record<string, string>) : null,
cancelReason: o.cancelReason,
adminTransitions,
canAssign: o.status === flow.initial && !o.providerId,
canUnassign: (o.status === flow.initial && (!!pending || !!o.companyId)) || (o.status === flow.assigned && !!o.providerId),
canReschedule: o.status === flow.initial || o.status === flow.assigned,
canCancel: o.status !== 'CANCELLED' && !flow.done.includes(o.status),
};
}),
/** Подсказка: свободные кандидаты по рейтингу подбора и занятые */
candidates: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const { ranked, busy } = 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,
id: c.id,
name: c.name,
languages: c.languages,
rating: c.rating,
load: c.load,
onShift: c.onShift,
reasons: 'reasons' in c ? c.reasons : [],
tried: tried.has(c.id),
});
return { free: ranked.map(view), busy: busy.map(view) };
}),
/** Предложить конкретному исполнителю или компании (текущее предложение отзывается) */
assign: ordersProcedure
.input(z.object({ id: z.string(), target: z.object({ kind: z.enum(['provider', 'company']), id: z.string() }) }))
.mutation(({ ctx, input }) => offerOrder(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, target: input.target }).then((o) => ({ offerId: o.id }))),
/** «Подобрать автоматически» — следующий лучший, даже если автораспределение выключено */
auto: ordersProcedure.input(idInput).mutation(({ ctx, input }) => autoAssign(ctx.svc, input.id, { force: true })),
/** Снять исполнителя / отозвать предложение — заказ снова ждёт исполнителя */
unassign: ordersProcedure.input(idInput).mutation(({ ctx, input }) => unassignOrder(ctx.svc, { orderId: input.id, adminId: ctx.admin.id })),
/** Сменить статус вручную — те же правила, что у исполнителя */
transition: ordersProcedure
.input(z.object({ id: z.string(), to: z.enum(ORDER_STATUSES), comment: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, { orderId: input.id, to: input.to, actor: 'ADMIN', actorId: ctx.admin.id, comment: input.comment });
}),
/** Отмена командой; удержание — вручную (по умолчанию 0) */
cancel: ordersProcedure
.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500), fee: z.number().int().min(0).default(0) }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, { orderId: input.id, to: 'CANCELLED', actor: 'ADMIN', actorId: ctx.admin.id, comment: input.reason, data: { cancelFee: input.fee, cancelReason: input.reason } });
await ctx.db.orderOffer.updateMany({ where: { orderId: input.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } });
}),
reschedule: ordersProcedure
.input(z.object({ id: z.string(), scheduledAt: z.date(), allowBusySlot: z.boolean().default(false), comment: z.string().max(500).optional() }))
.mutation(({ ctx, input }) => rescheduleByAdmin(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, scheduledAt: input.scheduledAt, allowBusySlot: input.allowBusySlot, comment: input.comment })),
markPaid: ordersProcedure
.input(z.object({ id: z.string(), paymentMethodId: z.string().optional() }))
.mutation(({ ctx, input }) => markPaid(ctx.svc, { orderId: input.id, by: { type: 'ADMIN', id: ctx.admin.id }, paymentMethodId: input.paymentMethodId })),
// ───── новый заказ за клиента ─────
/** Услуги, которые можно заказать сейчас, с формой параметров */
services: ordersProcedure.query(async ({ ctx }) => {
const list = await ctx.db.service.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { code: true } });
return Promise.all(
list.map(async ({ code }) => {
const s = await getServiceByCode(ctx.db, code);
return { code: s.code, category: s.category, isOnline: s.isOnline, paramsSchema: s.paramsSchema };
}),
);
}),
dictionaries: ordersProcedure.query(async ({ ctx }) => ({
districts: (await ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })).map((d) => ({ id: d.id, code: d.code })),
paymentMethods: (await ctx.db.paymentMethod.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' } })).map((m) => ({ id: m.id, code: m.code, name: m.name as Record<string, string> })),
})),
/** Цена и длительность для клиента (с его скидкой по подписке) */
quote: ordersProcedure
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()) }))
.query(async ({ ctx, input }) => {
const { quote: q, membership } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params);
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level };
}),
slots: ordersProcedure
.input(z.object({ service: z.string(), params: z.record(z.string(), z.unknown()), dateKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), excludeOrderId: z.string().optional() }))
.query(async ({ ctx, input }) => (await availableSlots(ctx.svc, { serviceCode: input.service, params: input.params, dateKey: input.dateKey, excludeOrderId: input.excludeOrderId })).map((s) => ({ start: s.start, capacity: s.capacity }))),
create: ordersProcedure
.input(
z.object({
clientId: z.string(),
service: z.string(),
params: z.record(z.string(), z.unknown()),
scheduledAt: z.date(),
addressText: z.string().trim().max(300).optional(),
districtId: z.string().optional(),
note: z.string().trim().max(1000).optional(),
paymentMethodId: z.string().optional(),
allowBusySlot: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const o = await createOrder(ctx.svc, {
clientId: input.clientId,
serviceCode: input.service,
params: input.params,
scheduledAt: input.scheduledAt,
addressText: input.addressText,
districtId: input.districtId,
note: input.note,
paymentMethodId: input.paymentMethodId,
allowBusySlot: input.allowBusySlot,
createdBy: { type: 'ADMIN', id: ctx.admin.id },
});
return { id: o.id, number: o.number };
}),
// ───── автораспределение ─────
autoSetting: ordersProcedure.query(async ({ ctx }) => {
const s = await getSettings(ctx.db);
return { auto: s['assign.auto'], maxTries: s['assign.maxAutoOffers'], timeoutMinutes: s['offer.acceptTimeoutMinutes'] };
}),
setAutoSetting: superProcedure
.input(z.object({ auto: z.boolean().optional(), maxTries: z.number().int().min(1).max(20).optional(), timeoutMinutes: z.number().int().min(1).max(1440).optional() }))
.mutation(async ({ ctx, input }) => {
const pairs: [string, unknown][] = [
['assign.auto', input.auto],
['assign.maxAutoOffers', input.maxTries],
['offer.acceptTimeoutMinutes', input.timeoutMinutes],
];
for (const [key, value] of pairs) {
if (value === undefined) continue;
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
}
}),
});
@@ -0,0 +1,258 @@
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 { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Исполнителей ведёт поддержка; ставки и деньги — только суперадмин */
const providersProcedure = rolesProcedure('SUPPORT');
const invite = () => `PRO-${randomBytes(5).toString('hex').toUpperCase()}`;
const inviteUrl = (code: string) => `https://t.me/${env.PRO_BOT_USERNAME}?start=${code}`;
const DONE = { completedAt: { not: null }, status: { not: 'CANCELLED' as const } } satisfies Prisma.OrderWhereInput;
const weekSchedule = 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);
const profileInput = z.object({
name: z.string().trim().min(1).max(100),
phone: z.string().trim().max(30).nullable().optional(),
role: z.enum(PROVIDER_ROLES),
companyId: z.string().nullable().optional(),
languages: z.array(z.enum(LOCALES)).min(1),
districtIds: z.array(z.string()).max(30),
schedule: weekSchedule,
adminNote: z.string().max(4000).nullable().optional(),
/** ставка — меняет только суперадмин */
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;
export const boProvidersRouter = router({
list: providersProcedure
.input(z.object({ role: z.enum(PROVIDER_ROLES).optional(), status: z.enum(['active', 'blocked', 'all']).default('active'), search: z.string().trim().max(100).optional() }))
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const from30 = new Date(now.getTime() - 30 * DAY);
const today = startOfYerevanDay(now);
const providers = await ctx.db.provider.findMany({
where: {
...(input.role ? { role: input.role } : {}),
...(input.status === 'active' ? { active: true } : input.status === 'blocked' ? { active: false } : {}),
...(input.search ? { OR: [{ name: { contains: input.search, mode: 'insensitive' } }, { phone: { contains: input.search } }] } : {}),
},
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { company: { select: { id: true, name: true } }, districts: { include: { district: { select: { code: true } } } }, _count: { select: { docs: true } } },
});
const ids = providers.map((p) => p.id);
const [done30, refused30, low30, upcoming] = await Promise.all([
ctx.db.order.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, ...DONE, completedAt: { gte: from30 } }, _count: true }),
ctx.db.orderOffer.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, status: { in: ['DECLINED', 'EXPIRED'] }, createdAt: { gte: from30 } }, _count: true }),
ctx.db.rating.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, score: { lte: 3 }, createdAt: { gte: from30 } }, _count: true }),
ctx.db.order.findMany({
where: { providerId: { in: ids }, scheduledAt: { gte: today }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'LESSON_DONE'] } },
orderBy: { scheduledAt: 'asc' },
select: { providerId: true, scheduledAt: true, number: true, id: true },
}),
]);
const by = <T extends { providerId: string | null; _count: number }>(rows: T[]) => new Map(rows.map((r) => [r.providerId, r._count]));
const done = by(done30);
const refused = by(refused30);
const low = by(low30);
const tomorrow = new Date(today.getTime() + DAY);
return providers.map((p) => {
const mine = upcoming.filter((o) => o.providerId === p.id);
return {
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
company: p.company,
active: p.active,
blockedReason: p.blockedReason,
onShift: p.onShift,
linked: !!p.userId,
languages: p.languages,
districts: p.districts.map((d) => d.district.code),
rating: averageRating(p.ratingSum, p.ratingCount),
ratingCount: p.ratingCount,
done30: done.get(p.id) ?? 0,
refused30: refused.get(p.id) ?? 0,
low30: low.get(p.id) ?? 0,
today: mine.filter((o) => o.scheduledAt < tomorrow).length,
next: mine[0] ? { id: mine[0].id, number: mine[0].number, scheduledAt: mine[0].scheduledAt } : null,
docs: p._count.docs,
};
});
}),
get: providersProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const from30 = new Date(now.getTime() - 30 * DAY);
const p = await ctx.db.provider.findUnique({
where: { id: input.id },
include: {
...payoutInclude,
company: { select: { id: true, name: true, rateType: true, rateValue: true } },
user: { select: { username: true, firstName: true } },
districts: true,
schedule: { orderBy: [{ weekday: 'asc' }, { startMinute: 'asc' }] },
docs: { orderBy: { createdAt: 'desc' } },
},
});
if (!p) throw new TRPCError({ code: 'NOT_FOUND' });
const [ratings, offers30, cancelled30, upcoming, recent] = await Promise.all([
ctx.db.rating.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' }, take: 20, include: { order: { select: { id: true, number: true } }, client: { include: { user: { select: { firstName: true } } } } } }),
ctx.db.orderOffer.groupBy({ by: ['status'], where: { providerId: p.id, createdAt: { gte: from30 } }, _count: true }),
ctx.db.orderStatusHistory.count({ where: { event: 'unassigned', createdAt: { gte: from30 }, order: { offers: { some: { providerId: p.id, status: 'ACCEPTED' } } } } }),
ctx.db.order.findMany({
where: { providerId: p.id, scheduledAt: { gte: startOfYerevanDay(now) }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'LESSON_DONE'] } },
orderBy: { scheduledAt: 'asc' },
take: 10,
include: { service: { select: { code: true, flow: true } }, district: { select: { code: true } } },
}),
ctx.db.order.findMany({
where: { providerId: p.id, ...DONE },
orderBy: { completedAt: 'desc' },
take: 15,
include: { service: { select: { code: true, pricing: true, paramsSchema: true } }, rating: { select: { score: true } } },
}),
]);
const offerCount = (s: string) => offers30.find((o) => o.status === s)?._count ?? 0;
// деньги: заработано (по ставке исполнителя) минус выплачено = к выплате
let money = null;
if (ctx.isSuper) {
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 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),
thisMonth: sum((x) => x.at >= monthStart),
prevMonth: sum((x) => x.at >= prevMonthStart && x.at < monthStart),
earnedTotal,
paidTotal,
balance: earnedTotal - paidTotal,
payouts: payouts.map((x) => ({ id: x.id, amount: x.amount, status: x.status, paidAt: x.paidAt, note: x.note, by: x.createdById ? (admins.get(x.createdById) ?? null) : null, periodStart: x.periodStart, periodEnd: x.periodEnd })),
presets: (await ctx.db.payoutPreset.findMany({ where: { active: true, role: p.role }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true } })),
rateType: p.rateType,
rateValue: p.rateValue,
payoutPresetId: p.payoutPresetId,
};
}
return {
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
company: p.company ? { id: p.company.id, name: p.company.name } : null,
languages: p.languages,
districtIds: p.districts.map((d) => d.districtId),
schedule: p.schedule.map((s) => ({ weekday: s.weekday, startMinute: s.startMinute, endMinute: s.endMinute })),
active: p.active,
blockedReason: p.blockedReason,
blockedAt: p.blockedAt,
adminNote: p.adminNote,
onShift: p.onShift,
linked: !!p.userId,
username: p.user?.username ?? null,
inviteUrl: p.userId ? null : inviteUrl(p.inviteCode),
createdAt: p.createdAt,
docs: p.docs.map((d) => ({ id: d.id, title: d.title, fileName: d.fileName, mime: d.mime, size: d.size, createdAt: d.createdAt })),
quality: {
rating: averageRating(p.ratingSum, p.ratingCount),
ratingCount: p.ratingCount,
accepted30: offerCount('ACCEPTED'),
declined30: offerCount('DECLINED'),
expired30: offerCount('EXPIRED'),
unassigned30: cancelled30,
low30: ratings.filter((r) => r.score <= 3 && r.createdAt >= from30).length,
},
ratings: ratings.map((r) => ({ id: r.id, score: r.score, comment: r.comment, orderId: r.order.id, orderNumber: r.order.number, client: r.client.user.firstName, at: r.createdAt })),
upcoming: upcoming.map((o) => ({ id: o.id, number: o.number, service: o.service.code, flow: o.service.flow, scheduledAt: o.scheduledAt, status: o.status, district: o.district?.code ?? null })),
recent: recent.map((o) => ({ id: o.id, number: o.number, completedAt: o.completedAt, rating: o.rating?.score ?? null })),
money,
};
}),
/** Справочники для формы */
dictionaries: providersProcedure.query(async ({ ctx }) => ({
districts: (await ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })).map((d) => ({ id: d.id, code: d.code })),
companies: await ctx.db.providerCompany.findMany({ where: { active: true }, orderBy: { name: 'asc' }, select: { id: true, name: true, roles: true } }),
})),
create: providersProcedure.input(profileInput).mutation(async ({ ctx, input }) => {
const { districtIds, schedule, rate, ...data } = input;
const p = await ctx.db.provider.create({
data: {
...data,
...(ctx.isSuper && rate ? rate : {}),
inviteCode: invite(),
districts: { create: districtIds.map((districtId) => ({ districtId })) },
schedule: { create: schedule.filter((s) => s.endMinute > s.startMinute) },
},
});
return { id: p.id, inviteUrl: inviteUrl(p.inviteCode) };
}),
update: providersProcedure.input(profileInput.extend({ id: z.string() })).mutation(async ({ ctx, input }) => {
const { id, districtIds, schedule, rate, ...data } = input;
await ctx.db.provider.update({
where: { id },
data: {
...data,
...(ctx.isSuper && rate ? rate : {}),
districts: { deleteMany: {}, create: districtIds.map((districtId) => ({ districtId })) },
schedule: { deleteMany: {}, create: schedule.filter((s) => s.endMinute > s.startMinute) },
},
});
}),
/** Допуск: заблокировать (с причиной) или вернуть к работе. Заблокированному заказы не предлагаются */
setActive: providersProcedure
.input(z.object({ id: z.string(), active: z.boolean(), reason: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
if (!input.active && !input.reason) throw new TRPCError({ code: 'BAD_REQUEST', message: 'REASON_REQUIRED' });
await ctx.db.provider.update({
where: { id: input.id },
data: input.active ? { active: true, blockedReason: null, blockedAt: null } : { active: false, onShift: false, blockedReason: input.reason, blockedAt: ctx.svc.now() },
});
// заблокирован — действующие предложения ему отзываем
if (!input.active) await ctx.db.orderOffer.updateMany({ where: { providerId: input.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: ctx.svc.now() } });
}),
/** Новая ссылка для входа в рабочий бот — старая привязка снимается */
resetInvite: providersProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const p = await ctx.db.provider.update({ where: { id: input.id }, data: { inviteCode: invite(), userId: null, onShift: false } });
return { inviteUrl: inviteUrl(p.inviteCode) };
}),
removeDoc: providersProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.providerDoc.delete({ where: { id: input.id } });
}),
/** Отметить выплату (наличные / перевод). По умолчанию — весь долг */
payout: superProcedure
.input(z.object({ id: z.string(), amount: z.number().int().min(1), note: z.string().trim().max(300).optional() }))
.mutation(async ({ ctx, input }) => {
const now = ctx.svc.now();
const last = await ctx.db.payout.findFirst({ where: { providerId: input.id }, orderBy: { periodEnd: 'desc' } });
const first = await ctx.db.order.findFirst({ where: { providerId: input.id, ...DONE }, orderBy: { completedAt: 'asc' }, select: { completedAt: true } });
await ctx.db.payout.create({
data: { providerId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? first?.completedAt ?? now, periodEnd: now },
});
}),
});
+42
View File
@@ -163,6 +163,48 @@ app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', asy
return sendChatFile(reply, m);
});
// ───── исполнители и заказы (этап 3) ─────
/** Заказы и исполнителей ведёт поддержка; суперадмину — всё */
const canDispatch = (staff: NonNullable<Awaited<ReturnType<typeof staffFromCookie>>>) => staff.admin.roles.includes('SUPERADMIN') || staff.admin.roles.includes('SUPPORT');
const DOC_TYPES = /^(image\/(jpeg|png|webp|heic)|application\/pdf)$/;
/** Документ для допуска исполнителя: паспорт, договор… (фото или PDF) */
app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId', 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 provider = await db.provider.findUnique({ where: { id: req.params.providerId } });
if (!provider) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!DOC_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`providers/${provider.id}`, body, part.mimetype, fileName);
const title = (multipartField(part, 'title') ?? '').trim().slice(0, 120) || fileName;
const doc = await db.providerDoc.create({ data: { providerId: provider.id, title, fileKey, fileName, mime: part.mimetype, size: body.length, uploadedById: staff.admin.id } });
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.providers.addDoc', entity: 'providers', entityId: provider.id, data: { title } } });
return { id: doc.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' });
const d = await db.providerDoc.findUnique({ where: { id: req.params.docId } });
if (!d) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, { fileKey: d.fileKey, tgFileId: null, tgBot: null, mime: d.mime, fileName: d.fileName });
});
/** Фотоотчёт по заказу; каждый просмотр — в журнал доступа */
app.get<{ Params: { fileId: string } }>('/files/bo/order-file/:fileId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
const f = await db.orderFile.findUnique({ where: { id: req.params.fileId } });
if (!f || f.deletedAt) return reply.code(404).send({ error: 'NOT_FOUND' });
await db.fileAccessLog.create({ data: { fileId: f.id, actorType: 'ADMIN', actorId: staff.admin.id } });
return sendChatFile(reply, { fileKey: f.storageKey, tgFileId: null, tgBot: null, mime: f.mime, fileName: `order-photo.${f.mime.split('/')[1] ?? 'jpg'}` });
});
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
function clientFromInit(req: FastifyRequest) {
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;