From 2772837e298382d33b966211205560e07476d526 Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Wed, 21 Oct 2026 02:02:31 +0400 Subject: [PATCH] Chats: show which order a provider writes about, or that there is no order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/api/scripts/dev-chats-demo.ts | 29 ++++++- apps/backoffice/src/chats.css | 40 ++++++++++ apps/backoffice/src/i18n.tsx | 24 ++++++ apps/backoffice/src/pages/Chats.tsx | 80 +++++++++++++++++++ apps/worker/src/index.ts | 22 ++++- .../migration.sql | 6 ++ packages/db/prisma/schema.prisma | 4 + packages/i18n/locales/en.json | 4 +- packages/i18n/locales/es.json | 4 +- packages/i18n/locales/hy.json | 4 +- packages/i18n/locales/ru.json | 4 +- packages/i18n/locales/zh.json | 4 +- packages/services/src/chats.ts | 73 ++++++++++++++++- packages/services/test/chats.test.ts | 36 ++++++++- 14 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 packages/db/prisma/migrations/20261021140000_chat_message_order/migration.sql diff --git a/apps/api/scripts/dev-chats-demo.ts b/apps/api/scripts/dev-chats-demo.ts index 9e1065d..c573c6e 100644 --- a/apps/api/scripts/dev-chats-demo.ts +++ b/apps/api/scripts/dev-chats-demo.ts @@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { getDb } from '@barev/db'; +import { providerCurrentOrder, silentNotifier, type Ctx } from '@barev/services'; import { putFile } from '@barev/services/storage'; import { env } from '../src/env.ts'; @@ -44,10 +45,23 @@ const katya = await clientByTg(100001004n); // Barev+ const natalia = await clientByTg(100001007n); // без подписки const lilit = await clientByTg(100001025n); // Barev+ const provider = await db.provider.findFirst({ where: { userId: { not: null }, active: true }, orderBy: { createdAt: 'asc' } }); +// второй исполнитель — с заказом в работе, чтобы было видно «пишет по заказу №…» +const busy = await db.provider.findFirst({ where: { userId: { not: null }, active: true, orders: { some: { status: { in: ['EN_ROUTE', 'CLEANING', 'ON_SITE', 'REPAIRING'] } } } } }); +const providerIds = [provider?.id, busy?.id].filter((x): x is string => !!x); +const svc: Ctx = { db, notify: silentNotifier, now: () => new Date() }; +// демо-даты: у «занятого» исполнителя заказ идёт сейчас, у первого — следующий заказ завтра +if (busy) { + const o = await db.order.findFirst({ where: { providerId: busy.id, status: { in: ['EN_ROUTE', 'CLEANING', 'ON_SITE', 'REPAIRING'] } } }); + if (o) await db.order.update({ where: { id: o.id }, data: { scheduledAt: ago(90) } }); +} +if (provider) { + const o = await db.order.findFirst({ where: { providerId: provider.id, status: 'ASSIGNED' } }); + if (o) await db.order.update({ where: { id: o.id }, data: { scheduledAt: new Date(now + 20 * 60 * MIN) } }); +} const clientIds = [arsen, dmitry, maria, sarah, alex, katya, natalia, lilit].map((c) => c.id); -await db.chatThread.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, ...(provider ? [{ providerId: provider.id }] : [])] } }); -await db.conciergeMessage.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, ...(provider ? [{ providerId: provider.id }] : [])] } }); +await db.chatThread.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, { providerId: { in: providerIds } }] } }); +await db.conciergeMessage.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, { providerId: { in: providerIds } }] } }); await db.client.updateMany({ where: { id: { in: clientIds } }, data: { chatChannel: null } }); await db.client.update({ where: { id: sarah.id }, data: { chatChannel: 'SUPPORT' } }); @@ -93,10 +107,13 @@ async function thread( }); for (const m of messages) { const incoming = m.author === 'CLIENT' || m.author === 'PROVIDER'; + // исполнитель: заказ, который был текущим в момент сообщения + const order = 'providerId' in peer && m.author === 'PROVIDER' ? await providerCurrentOrder(svc, peer.providerId, ago(m.at)) : null; await db.conciergeMessage.create({ data: { threadId: t.id, ...peer, + orderId: order?.id ?? null, author: m.author, adminUserId: m.by, text: m.text, @@ -175,6 +192,12 @@ await thread({ clientId: alex.id }, 'SUPPORT', { assigneeId: gor.id, status: 'CL { author: 'SYSTEM', text: 'closed', at: 60 * 29 - 12, by: gor.id }, ]); +if (busy && busy.id !== provider?.id) { + await thread({ providerId: busy.id }, 'SUPPORT', { waiting: 3 }, [ + { author: 'PROVIDER', text: 'Клиентка просит дополнительно помыть балкон — это входит в заказ или считать отдельно?', at: 3 }, + ]); +} + if (provider) { await thread({ providerId: provider.id }, 'SUPPORT', { waiting: 7 }, [ { author: 'PROVIDER', text: 'Здравствуйте, по заказу на Абовяна нет кода от подъезда, клиент не отвечает. Что делать?', at: 7 }, @@ -191,5 +214,5 @@ const TEMPLATES: { channel: 'SUPPORT' | 'CONCIERGE' | null; title: string; text: await db.chatTemplate.deleteMany({ where: { title: { in: TEMPLATES.map((t) => t.title) } } }); for (const t of TEMPLATES) await db.chatTemplate.create({ data: { ...t, createdById: owner.id } }); -console.log(`Демо-чаты готовы: ${provider ? 9 : 8} диалогов, ${TEMPLATES.length} шаблона. Сотрудники: ${anna.name} (консьерж), ${gor.name} (поддержка).`); +console.log(`Демо-чаты готовы: ${8 + providerIds.length} диалогов, ${TEMPLATES.length} шаблона. Сотрудники: ${anna.name} (консьерж), ${gor.name} (поддержка).`); process.exit(0); diff --git a/apps/backoffice/src/chats.css b/apps/backoffice/src/chats.css index f659524..906abad 100644 --- a/apps/backoffice/src/chats.css +++ b/apps/backoffice/src/chats.css @@ -379,6 +379,46 @@ background: var(--bc-learn); color: var(--bc-learn-ink); } +/* исполнитель: текущий заказ в шапке и заказ над каждым его сообщением */ +.bo-conv__banner--order { + background: var(--bc-docs); + color: var(--bc-docs-ink); + align-items: flex-start; +} +.bo-conv__banner--order.is-free { + background: var(--bo-soft); + color: var(--bc-ink); +} +.bo-conv__banner--order svg { + flex: none; + margin-top: 2px; +} +.bo-conv__next { + display: block; + font-size: 13px; + opacity: 0.75; +} +.bo-ordertag { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + margin: -2px 0 6px; + padding: 3px 9px; + border-radius: var(--bc-pill); + background: var(--bc-docs); + color: var(--bc-docs-ink); + font-size: 12px; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bo-ordertag--none { + background: var(--bo-soft); + color: var(--bc-ink-2); +} .bo-conv__closed { padding: 8px 18px; font-size: 13px; diff --git a/apps/backoffice/src/i18n.tsx b/apps/backoffice/src/i18n.tsx index e4eac12..4fc2c6d 100644 --- a/apps/backoffice/src/i18n.tsx +++ b/apps/backoffice/src/i18n.tsx @@ -386,6 +386,14 @@ const ru = { 'chat.notLinked': 'Рабочий бот не открыт', 'chat.details': 'Подробнее', 'chat.more': 'Ещё действия', + 'chat.order': 'Заказ №{n}', + 'chat.noOrder': 'Без заказа', + 'chat.noOrderNow': 'Сейчас без заказа', + 'chat.orderNow': 'Сейчас', + 'chat.orderNext': 'Ближайший', + 'chat.orderNoNext': 'Ближайших заказов нет', + 'chat.orderClient': 'клиент {name}', + 'chat.wroteNoOrder': 'писал(а) без заказа', 'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Консьерж — только для подписчиков. Сначала выдайте клиенту подписку.', 'err.ASSIGNEE_NO_ACCESS': 'У этого сотрудника нет доступа к этому каналу.', 'err.PROVIDER_NOT_LINKED': 'Исполнитель ещё не открыл рабочий бот — написать ему пока некуда.', @@ -784,6 +792,14 @@ const en: Dict = { 'chat.notLinked': 'Work bot not opened', 'chat.details': 'Details', 'chat.more': 'More actions', + 'chat.order': 'Order #{n}', + 'chat.noOrder': 'No order', + 'chat.noOrderNow': 'No current order', + 'chat.orderNow': 'Now', + 'chat.orderNext': 'Next', + 'chat.orderNoNext': 'No upcoming orders', + 'chat.orderClient': 'client {name}', + 'chat.wroteNoOrder': 'wrote without an order', 'err.CONCIERGE_SUBSCRIBERS_ONLY': 'The concierge is for members only. Give the client a subscription first.', 'err.ASSIGNEE_NO_ACCESS': 'This team member has no access to this channel.', 'err.PROVIDER_NOT_LINKED': 'The provider hasn’t opened the work bot yet.', @@ -1179,6 +1195,14 @@ const hy: Dict = { 'chat.notLinked': 'Աշխատանքային բոտը բացված չէ', 'chat.details': 'Մանրամասներ', 'chat.more': 'Այլ գործողություններ', + 'chat.order': 'Պատվեր №{n}', + 'chat.noOrder': 'Առանց պատվերի', + 'chat.noOrderNow': 'Այժմ պատվեր չկա', + 'chat.orderNow': 'Այժմ', + 'chat.orderNext': 'Հաջորդը', + 'chat.orderNoNext': 'Մոտակա պատվերներ չկան', + 'chat.orderClient': 'հաճախորդ {name}', + 'chat.wroteNoOrder': 'գրել է առանց պատվերի', 'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Կոնսիերժը միայն բաժանորդների համար է։ Նախ տրամադրեք բաժանորդագրություն։', 'err.ASSIGNEE_NO_ACCESS': 'Այս աշխատակիցը մուտք չունի այս ալիքին։', 'err.PROVIDER_NOT_LINKED': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։', diff --git a/apps/backoffice/src/pages/Chats.tsx b/apps/backoffice/src/pages/Chats.tsx index d1d6236..9035006 100644 --- a/apps/backoffice/src/pages/Chats.tsx +++ b/apps/backoffice/src/pages/Chats.tsx @@ -169,6 +169,8 @@ type ListItem = { waitingSince: Date | null; lastMessageAt: Date; last: { author: string; text: string; file: string | null } | null; + /** исполнитель: номер заказа последнего сообщения, null — без заказа */ + lastOrder?: number | null; unread: number; }; @@ -204,6 +206,7 @@ function ThreadList({ items, loading, activeId, showChannel, meId, filtered }: { {showChannel && {t(`channel.${it.channel}`)}} + {it.lastOrder !== undefined && {it.lastOrder ? t('chat.order', { n: it.lastOrder }) : t('chat.noOrder')}} {it.waitingSince && {t('chat.waiting', { t: ago(t, it.waitingSince) })}} {it.botActive && ( @@ -334,6 +337,7 @@ function Conversation({ id, me, channels, side, onToggleSide }: { id: string; me + {d.providerId && } {d.botActive && (
@@ -379,6 +383,7 @@ type Message = { param: string | null; deliveryError: string | null; createdAt: Date; + order?: OrderBrief | null; }; function Messages({ messages, locale, peerName }: { messages: Message[]; locale: Locale; peerName: string }) { @@ -430,6 +435,7 @@ function Messages({ messages, locale, peerName }: { messages: Message[]; locale: {sep}
+ {m.author === 'PROVIDER' && m.order !== undefined && } {kind === 'note' && (
@@ -478,6 +484,80 @@ function FileView({ id, file }: { id: string; file: { name: string | null; mime: ); } +// ───── заказ исполнителя ───── + +type OrderBrief = { + id: string; + number: number; + service: string; + flow: string; + status: string; + scheduledAt: Date; + district: string | null; + address: string | null; + isOnline: boolean; + clientId: string; + clientName: string; +}; + +/** «Заказ №19 · Клининг · ср, 21 окт., 14:00 · Кентрон» */ +function useOrderText() { + const { t, shared, locale } = useT(); + return (o: OrderBrief, full = false) => + [ + t('chat.order', { n: o.number }), + shared(`services.${o.service}.name`), + formatDateTime(locale as Locale, o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }), + o.isOnline ? null : full && o.address ? o.address : o.district ? shared(`districts.${o.district}`) : null, + full ? shared(`status.${o.flow}.${o.status}`) : null, + full && o.clientName ? t('chat.orderClient', { name: o.clientName }) : null, + ] + .filter(Boolean) + .join(' · '); +} + +/** Над сообщением исполнителя: по какому заказу он писал, или что заказа не было */ +function OrderTag({ order }: { order: OrderBrief | null }) { + const { t } = useT(); + const text = useOrderText(); + if (!order) + return ( +
+ + {t('chat.wroteNoOrder')} +
+ ); + return ( + + + {text(order)} + + ); +} + +/** Шапка диалога с исполнителем: что у него сейчас и что дальше */ +function ProviderOrders({ current, next }: { current: OrderBrief | null; next: OrderBrief | null }) { + const { t } = useT(); + const text = useOrderText(); + return ( +
+ + + {current ? ( + <> + {t('chat.orderNow')}: {text(current, true)} + + ) : ( + {t('chat.noOrderNow')} + )} + + {next ? `${t('chat.orderNext')}: ${text(next)}` : t('chat.orderNoNext')} + + +
+ ); +} + const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`); // ───── поле ответа ───── diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index d7ff605..88a893a 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -233,9 +233,27 @@ async function handle(event: DomainEvent) { return; } case 'concierge.message': { - const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: true } }); + const msg = await db.conciergeMessage.findUnique({ + where: { id: event.messageId }, + include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: true, order: { include: { service: true, district: true } } }, + }); if (!msg) return; - const from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`; + let from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`; + // исполнитель: по какому заказу пишет, или что сейчас без заказа + if (msg.providerId) { + const o = msg.order; + const orderLine = o + ? [ + adminT('bot.admin.order', { number: o.number }), + adminT(`services.${o.service.code}.name`), + formatDateTime('ru', o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }), + o.district ? adminT(`districts.${o.district.code}`) : null, + ] + .filter(Boolean) + .join(' · ') + : adminT('bot.admin.noOrder'); + from += `\n📦 ${escapeHtml(orderLine)}`; + } const peer = msg.clientId ? `c:${msg.clientId}` : `p:${msg.providerId}`; const channel = msg.thread ? `${adminT(msg.thread.channel === 'CONCIERGE' ? 'bot.admin.channelConcierge' : 'bot.admin.channelSupport')} · ` : ''; const body = msg.text ? escapeHtml(msg.text) : ''; diff --git a/packages/db/prisma/migrations/20261021140000_chat_message_order/migration.sql b/packages/db/prisma/migrations/20261021140000_chat_message_order/migration.sql new file mode 100644 index 0000000..fc8a673 --- /dev/null +++ b/packages/db/prisma/migrations/20261021140000_chat_message_order/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "ConciergeMessage" ADD COLUMN "orderId" TEXT; + +-- AddForeignKey +ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 90b1be4..919ef34 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -465,6 +465,7 @@ model Order { offers OrderOffer[] files OrderFile[] messages Message[] + chatMessages ConciergeMessage[] estimates Estimate[] rating Rating? lessons LessonPackage? @@ -1152,6 +1153,9 @@ model ConciergeMessage { size Int? /// не удалось доставить в Telegram (клиент заблокировал бота и т.п.) deliveryError String? + /// исполнитель писал по этому заказу (текущий заказ в момент сообщения); null — заказа тогда не было + orderId String? + order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) /// оператор прочитал сообщение клиента readAt DateTime? createdAt DateTime @default(now()) diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 968d79e..5b6bb14 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -869,7 +869,9 @@ "channelSupport": "Support", "channelConcierge": "Concierge", "file": "file", - "chatAssigned": "{{by}} passed you a conversation: {{name}}" + "chatAssigned": "{{by}} passed you a conversation: {{name}}", + "order": "Order #{{number}}", + "noOrder": "No current order" }, "partner": { "valid": "{{name}} — {{level}}, card is valid", diff --git a/packages/i18n/locales/es.json b/packages/i18n/locales/es.json index c8e387a..3bdac21 100644 --- a/packages/i18n/locales/es.json +++ b/packages/i18n/locales/es.json @@ -869,7 +869,9 @@ "channelSupport": "Soporte", "channelConcierge": "Conserje", "file": "archivo", - "chatAssigned": "{{by}} te ha pasado una conversación: {{name}}" + "chatAssigned": "{{by}} te ha pasado una conversación: {{name}}", + "order": "Pedido n.º {{number}}", + "noOrder": "Sin pedido en curso" }, "partner": { "valid": "{{name}} — {{level}}, tarjeta válida", diff --git a/packages/i18n/locales/hy.json b/packages/i18n/locales/hy.json index 1db5a7f..d587485 100644 --- a/packages/i18n/locales/hy.json +++ b/packages/i18n/locales/hy.json @@ -869,7 +869,9 @@ "channelSupport": "Աջակցություն", "channelConcierge": "Կոնսիերժ", "file": "ֆայլ", - "chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}" + "chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}", + "order": "Պատվեր №{{number}}", + "noOrder": "Այժմ պատվեր չկա" }, "partner": { "valid": "{{name}} — {{level}}, քարտը գործում է", diff --git a/packages/i18n/locales/ru.json b/packages/i18n/locales/ru.json index 1ae6947..11cb627 100644 --- a/packages/i18n/locales/ru.json +++ b/packages/i18n/locales/ru.json @@ -869,7 +869,9 @@ "channelSupport": "Поддержка", "channelConcierge": "Консьерж", "file": "файл", - "chatAssigned": "{{by}} передал(а) вам диалог: {{name}}" + "chatAssigned": "{{by}} передал(а) вам диалог: {{name}}", + "order": "Заказ №{{number}}", + "noOrder": "Сейчас без заказа" }, "partner": { "valid": "{{name}} — {{level}}, карта действует", diff --git a/packages/i18n/locales/zh.json b/packages/i18n/locales/zh.json index 20bfebd..8736d9d 100644 --- a/packages/i18n/locales/zh.json +++ b/packages/i18n/locales/zh.json @@ -869,7 +869,9 @@ "channelSupport": "客服", "channelConcierge": "礼宾", "file": "文件", - "chatAssigned": "{{by}} 将对话转交给你:{{name}}" + "chatAssigned": "{{by}} 将对话转交给你:{{name}}", + "order": "订单 #{{number}}", + "noOrder": "当前没有订单" }, "partner": { "valid": "{{name}} — {{level}},会员卡有效", diff --git a/packages/services/src/chats.ts b/packages/services/src/chats.ts index d157590..b7628d5 100644 --- a/packages/services/src/chats.ts +++ b/packages/services/src/chats.ts @@ -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; + +/** + * Текущий заказ исполнителя: уже начатый (в пути, уборка, на месте…), иначе ближайший назначенный + * в окне «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, diff --git a/packages/services/test/chats.test.ts b/packages/services/test/chats.test.ts index 3b2bc68..91fb666 100644 --- a/packages/services/test/chats.test.ts +++ b/packages/services/test/chats.test.ts @@ -5,6 +5,7 @@ import { chatRecipients, clientConversation, closeThread, + createOrder, conversation, DomainError, getThreadForStaff, @@ -20,7 +21,7 @@ import { startClient, threadCounts, } from '../src/index.ts'; -import { db, fixtures, makeCtx, NOW, tgUser } from './helpers.ts'; +import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts'; beforeEach(async () => { await fixtures(); @@ -174,6 +175,39 @@ describe('чаты: поддержка и консьерж', () => { void boss; }); + it('исполнитель: сообщение помечается текущим заказом, без заказа — пометка «нет заказа»', async () => { + const { ctx, clock } = makeCtx(NOW); + const fx = await db.provider.findFirstOrThrow({ where: { name: 'Maria' } }); + const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } }); + const clientId = await client(ctx, 771); + const order = await createOrder(ctx, { clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Абовяна 10', districtId: kentron.id }); + + // заказ через двое суток — сейчас исполнитель без заказа, но он «ближайший» + await db.order.update({ where: { id: order.id }, data: { providerId: fx.id, status: 'ASSIGNED' } }); + const free = (await receiveMessage(ctx, { providerId: fx.id }, { text: 'Есть работа на завтра?' }))!; + expect(free.message.orderId).toBeNull(); + const view = await getThreadForStaff(ctx, free.thread.id); + expect(view.currentOrder).toBeNull(); + expect(view.nextOrder).toMatchObject({ number: order.number, address: 'Абовяна 10' }); + expect(view.messages[0]!.order).toBeNull(); + + // за 2 часа до начала — уже текущий + clock.now = new Date(order.scheduledAt.getTime() - 2 * 3600_000); + const soon = (await receiveMessage(ctx, { providerId: fx.id }, { text: 'Код от подъезда?' }))!; + expect(soon.message.orderId).toBe(order.id); + + // начатый заказ — текущий даже после планового времени + await db.order.update({ where: { id: order.id }, data: { status: 'CLEANING' } }); + clock.now = new Date(order.scheduledAt.getTime() + 5 * 3600_000); + expect((await receiveMessage(ctx, { providerId: fx.id }, { text: 'Задерживаюсь' }))!.message.orderId).toBe(order.id); + + // завершён — снова без заказа; клиентские сообщения заказ не помечает + await db.order.update({ where: { id: order.id }, data: { status: 'DONE' } }); + expect((await receiveMessage(ctx, { providerId: fx.id }, { text: 'Готово' }))!.message.orderId).toBeNull(); + expect((await addClientMessage(ctx, clientId, 'Спасибо'))!.message.orderId).toBeNull(); + expect((await listThreads(ctx, { channels: ['SUPPORT'], filter: 'open', adminUserId: 'x' })).find((x) => x.providerId === fx.id)!.lastOrder).toBeNull(); + }); + it('ответ из старой админки / из Telegram попадает в последний диалог клиента', async () => { const { ctx } = makeCtx(NOW); const clientId = await client(ctx, 761, true);