Chats: show which order a provider writes about, or that there is no order

- Provider messages store the order that was current when they wrote: a started
  order first, otherwise the nearest assigned one within -4h..+12h; none — "no order".
- Admin chat: order tag above each provider message, "now / next" order strip in
  the conversation header, order number (or "no order") in the thread list.
- Telegram notification to the team gets an order line or "no current order".
- Test for the order-picking rule; demo shows both cases.

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:02:31 +04:00
1 parent 23ffb2bf1d
commit 2772837e29
14 files changed
+321 -13

No files matched your search

+71 -2
View File
@@ -61,6 +61,64 @@ async function ensureThread(ctx: Ctx, peer: Peer, channel: ChatChannel) {
});
}
// ───── заказ исполнителя: о чём он пишет ─────
const HOUR = 3600_000;
const FINAL: ('DONE' | 'COMPLETED' | 'CANCELLED' | 'LESSON_DONE')[] = ['DONE', 'COMPLETED', 'CANCELLED', 'LESSON_DONE'];
/** ещё не начат: назначен, но исполнитель не выехал / не приступил */
const NOT_STARTED = new Set(['NEW', 'ASSIGNED', 'ENROLLED', 'LESSON_SCHEDULED', 'CONSULTANT_ASSIGNED']);
const orderInclude = {
service: { select: { code: true, flow: true } },
district: { select: { code: true } },
client: { include: { user: { select: { firstName: true, lastName: true } } } },
} satisfies Prisma.OrderInclude;
type OrderRow = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
/** Коротко о заказе для команды: номер, услуга, время, район/адрес, клиент, статус */
export function orderBrief(o: OrderRow) {
return {
id: o.id,
number: o.number,
service: o.service.code,
flow: o.service.flow,
status: o.status,
scheduledAt: o.scheduledAt,
district: o.district?.code ?? null,
address: o.addressText,
isOnline: o.isOnline,
clientId: o.clientId,
clientName: `${o.client.user.firstName ?? ''} ${o.client.user.lastName ?? ''}`.trim(),
};
}
export type OrderBrief = ReturnType<typeof orderBrief>;
/**
* Текущий заказ исполнителя: уже начатый (в пути, уборка, на месте…), иначе ближайший назначенный
* в окне «4 часа назад — 12 часов вперёд». Нет такого — исполнитель сейчас без заказа.
*/
export async function providerCurrentOrder(ctx: Ctx, providerId: string, at = ctx.now()) {
const orders = await ctx.db.order.findMany({
where: { providerId, status: { notIn: FINAL }, scheduledAt: { gte: new Date(at.getTime() - 24 * HOUR), lte: new Date(at.getTime() + 12 * HOUR) } },
include: orderInclude,
});
const started = orders.filter((o) => !NOT_STARTED.has(o.status)).sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
if (started[0]) return started[0];
const near = orders
.filter((o) => o.scheduledAt.getTime() >= at.getTime() - 4 * HOUR)
.sort((a, b) => Math.abs(a.scheduledAt.getTime() - at.getTime()) - Math.abs(b.scheduledAt.getTime() - at.getTime()));
return near[0] ?? null;
}
/** Следующий заказ исполнителя после текущего момента — подсказка, когда текущего нет */
export function providerNextOrder(ctx: Ctx, providerId: string, at = ctx.now()) {
return ctx.db.order.findFirst({
where: { providerId, status: { notIn: FINAL }, scheduledAt: { gt: at } },
orderBy: { scheduledAt: 'asc' },
include: orderInclude,
});
}
/**
* Входящее сообщение клиента или исполнителя (бот / мини-апп).
* opened = диалог был закрыт или новый — бот подтверждает «передали, ответим здесь».
@@ -71,9 +129,12 @@ export async function receiveMessage(ctx: Ctx, peer: Peer, input: { text?: strin
const channel: ChatChannel = peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT';
const before = await ensureThread(ctx, peer, channel);
const now = ctx.now();
// исполнитель пишет — запоминаем, по какому заказу (или что заказа не было)
const order = peer.providerId ? await providerCurrentOrder(ctx, peer.providerId, now) : null;
const message = await ctx.db.conciergeMessage.create({
data: {
...peerWhere(peer),
orderId: order?.id ?? null,
threadId: before.id,
author: peer.clientId ? 'CLIENT' : 'PROVIDER',
text,
@@ -293,7 +354,7 @@ export async function listThreads(
};
const rows = await ctx.db.chatThread.findMany({
where,
include: { ...peerInclude, messages: { where: { author: { not: 'SYSTEM' } }, orderBy: { createdAt: 'desc' }, take: 1 } },
include: { ...peerInclude, messages: { where: { author: { not: 'SYSTEM' } }, orderBy: { createdAt: 'desc' }, take: 1, include: { order: { select: { number: true } } } } },
// ждущие ответа — сверху, дольше всех ждущий — первым; остальные — по свежести
orderBy: [{ lastMessageAt: 'desc' }],
take: input.limit ?? 100,
@@ -319,6 +380,8 @@ export async function listThreads(
waitingSince: t.status === 'OPEN' ? t.waitingSince : null,
lastMessageAt: t.lastMessageAt,
last: preview(t.messages[0]),
// исполнитель: по какому заказу последнее сообщение; null — писал без заказа
lastOrder: t.providerId && t.messages[0]?.author === 'PROVIDER' ? (t.messages[0].order?.number ?? null) : undefined,
unread: unreadBy.get(t.id) ?? 0,
}))
.sort((a, b) => {
@@ -346,7 +409,9 @@ export async function getThreadForStaff(ctx: Ctx, id: string, opts: { markRead?:
const t = await ctx.db.chatThread.findUnique({ where: { id }, include: peerInclude });
if (!t) throw new DomainError('NOT_FOUND');
if (opts.markRead !== false) await markRead(ctx, id);
const messages = await ctx.db.conciergeMessage.findMany({ where: { threadId: id }, orderBy: { createdAt: 'asc' }, take: 500 });
const messages = await ctx.db.conciergeMessage.findMany({ where: { threadId: id }, orderBy: { createdAt: 'asc' }, take: 500, include: { order: { include: orderInclude } } });
// исполнитель: что у него сейчас и что дальше — в шапке диалога
const [currentOrder, nextOrder] = t.providerId ? await Promise.all([providerCurrentOrder(ctx, t.providerId), providerNextOrder(ctx, t.providerId)]) : [null, null];
const authorIds = [...new Set(messages.map((m) => m.adminUserId).filter((x): x is string => !!x))];
const authors = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: authorIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
// второй канал этого же клиента — чтобы показать «есть переписка в поддержке/консьерже»
@@ -368,7 +433,11 @@ export async function getThreadForStaff(ctx: Ctx, id: string, opts: { markRead?:
botActive: t.botActive,
waitingSince: t.waitingSince,
other,
currentOrder: currentOrder ? orderBrief(currentOrder) : null,
nextOrder: nextOrder && nextOrder.id !== currentOrder?.id ? orderBrief(nextOrder) : null,
messages: messages.map((m) => ({
// только у сообщений исполнителя: заказ, по которому он писал; null — заказа тогда не было
order: m.author === 'PROVIDER' ? (m.order ? orderBrief(m.order) : null) : undefined,
id: m.id,
author: m.author,
authorName: m.adminUserId ? (authors.get(m.adminUserId) ?? null) : null,