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:
1 parent
2772837e29
commit
e7de4f5893
33 files changed
+4521
-45
No files matched your search
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user