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

+26 -3
View File
@@ -7,6 +7,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { getDb } from '@barev/db'; import { getDb } from '@barev/db';
import { providerCurrentOrder, silentNotifier, type Ctx } from '@barev/services';
import { putFile } from '@barev/services/storage'; import { putFile } from '@barev/services/storage';
import { env } from '../src/env.ts'; import { env } from '../src/env.ts';
@@ -44,10 +45,23 @@ const katya = await clientByTg(100001004n); // Barev+
const natalia = await clientByTg(100001007n); // без подписки const natalia = await clientByTg(100001007n); // без подписки
const lilit = await clientByTg(100001025n); // Barev+ const lilit = await clientByTg(100001025n); // Barev+
const provider = await db.provider.findFirst({ where: { userId: { not: null }, active: true }, orderBy: { createdAt: 'asc' } }); 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); 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.chatThread.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, { providerId: { in: providerIds } }] } });
await db.conciergeMessage.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, ...(provider ? [{ providerId: provider.id }] : [])] } }); 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.updateMany({ where: { id: { in: clientIds } }, data: { chatChannel: null } });
await db.client.update({ where: { id: sarah.id }, data: { chatChannel: 'SUPPORT' } }); await db.client.update({ where: { id: sarah.id }, data: { chatChannel: 'SUPPORT' } });
@@ -93,10 +107,13 @@ async function thread(
}); });
for (const m of messages) { for (const m of messages) {
const incoming = m.author === 'CLIENT' || m.author === 'PROVIDER'; 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({ await db.conciergeMessage.create({
data: { data: {
threadId: t.id, threadId: t.id,
...peer, ...peer,
orderId: order?.id ?? null,
author: m.author, author: m.author,
adminUserId: m.by, adminUserId: m.by,
text: m.text, 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 }, { 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) { if (provider) {
await thread({ providerId: provider.id }, 'SUPPORT', { waiting: 7 }, [ await thread({ providerId: provider.id }, 'SUPPORT', { waiting: 7 }, [
{ author: 'PROVIDER', text: 'Здравствуйте, по заказу на Абовяна нет кода от подъезда, клиент не отвечает. Что делать?', at: 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) } } }); 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 } }); 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); process.exit(0);
+40
View File
@@ -379,6 +379,46 @@
background: var(--bc-learn); background: var(--bc-learn);
color: var(--bc-learn-ink); 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 { .bo-conv__closed {
padding: 8px 18px; padding: 8px 18px;
font-size: 13px; font-size: 13px;
+24
View File
@@ -386,6 +386,14 @@ const ru = {
'chat.notLinked': 'Рабочий бот не открыт', 'chat.notLinked': 'Рабочий бот не открыт',
'chat.details': 'Подробнее', 'chat.details': 'Подробнее',
'chat.more': 'Ещё действия', '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.CONCIERGE_SUBSCRIBERS_ONLY': 'Консьерж — только для подписчиков. Сначала выдайте клиенту подписку.',
'err.ASSIGNEE_NO_ACCESS': 'У этого сотрудника нет доступа к этому каналу.', 'err.ASSIGNEE_NO_ACCESS': 'У этого сотрудника нет доступа к этому каналу.',
'err.PROVIDER_NOT_LINKED': 'Исполнитель ещё не открыл рабочий бот — написать ему пока некуда.', 'err.PROVIDER_NOT_LINKED': 'Исполнитель ещё не открыл рабочий бот — написать ему пока некуда.',
@@ -784,6 +792,14 @@ const en: Dict = {
'chat.notLinked': 'Work bot not opened', 'chat.notLinked': 'Work bot not opened',
'chat.details': 'Details', 'chat.details': 'Details',
'chat.more': 'More actions', '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.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.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.', 'err.PROVIDER_NOT_LINKED': 'The provider hasn’t opened the work bot yet.',
@@ -1179,6 +1195,14 @@ const hy: Dict = {
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ', 'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
'chat.details': 'Մանրամասներ', 'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ', '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.CONCIERGE_SUBSCRIBERS_ONLY': 'Կոնսիերժը միայն բաժանորդների համար է։ Նախ տրամադրեք բաժանորդագրություն։',
'err.ASSIGNEE_NO_ACCESS': 'Այս աշխատակիցը մուտք չունի այս ալիքին։', 'err.ASSIGNEE_NO_ACCESS': 'Այս աշխատակիցը մուտք չունի այս ալիքին։',
'err.PROVIDER_NOT_LINKED': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։', 'err.PROVIDER_NOT_LINKED': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։',
+80
View File
@@ -169,6 +169,8 @@ type ListItem = {
waitingSince: Date | null; waitingSince: Date | null;
lastMessageAt: Date; lastMessageAt: Date;
last: { author: string; text: string; file: string | null } | null; last: { author: string; text: string; file: string | null } | null;
/** исполнитель: номер заказа последнего сообщения, null — без заказа */
lastOrder?: number | null;
unread: number; unread: number;
}; };
@@ -204,6 +206,7 @@ function ThreadList({ items, loading, activeId, showChannel, meId, filtered }: {
</span> </span>
<span className="bo-thread__meta"> <span className="bo-thread__meta">
{showChannel && <span className={`bo-chip bo-chip--${it.channel}`}>{t(`channel.${it.channel}`)}</span>} {showChannel && <span className={`bo-chip bo-chip--${it.channel}`}>{t(`channel.${it.channel}`)}</span>}
{it.lastOrder !== undefined && <span className="bo-chip">{it.lastOrder ? t('chat.order', { n: it.lastOrder }) : t('chat.noOrder')}</span>}
{it.waitingSince && <span className={`bo-wait-chip bo-wait-chip--${urgency(it.waitingSince)}`}>{t('chat.waiting', { t: ago(t, it.waitingSince) })}</span>} {it.waitingSince && <span className={`bo-wait-chip bo-wait-chip--${urgency(it.waitingSince)}`}>{t('chat.waiting', { t: ago(t, it.waitingSince) })}</span>}
{it.botActive && ( {it.botActive && (
<span className="bo-chip"> <span className="bo-chip">
@@ -334,6 +337,7 @@ function Conversation({ id, me, channels, side, onToggleSide }: { id: string; me
</div> </div>
</header> </header>
{d.providerId && <ProviderOrders current={d.currentOrder} next={d.nextOrder} />}
{d.botActive && ( {d.botActive && (
<div className="bo-conv__banner bo-conv__banner--bot"> <div className="bo-conv__banner bo-conv__banner--bot">
<I name="bot" size={18} /> <I name="bot" size={18} />
@@ -379,6 +383,7 @@ type Message = {
param: string | null; param: string | null;
deliveryError: string | null; deliveryError: string | null;
createdAt: Date; createdAt: Date;
order?: OrderBrief | null;
}; };
function Messages({ messages, locale, peerName }: { messages: Message[]; locale: Locale; peerName: string }) { function Messages({ messages, locale, peerName }: { messages: Message[]; locale: Locale; peerName: string }) {
@@ -430,6 +435,7 @@ function Messages({ messages, locale, peerName }: { messages: Message[]; locale:
<Fragment key={m.id}> <Fragment key={m.id}>
{sep} {sep}
<div className={`bo-msg bo-msg--${kind}`}> <div className={`bo-msg bo-msg--${kind}`}>
{m.author === 'PROVIDER' && m.order !== undefined && <OrderTag order={m.order} />}
{kind === 'note' && ( {kind === 'note' && (
<div className="bo-msg__label"> <div className="bo-msg__label">
<I name="lock" size={13} /> <I name="lock" size={13} />
@@ -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 (
<div className="bo-ordertag bo-ordertag--none">
<I name="orders" size={13} />
{t('chat.wroteNoOrder')}
</div>
);
return (
<a className="bo-ordertag" href={`#/clients/${order.clientId}`} title={t('chat.orderClient', { name: order.clientName })}>
<I name="orders" size={13} />
{text(order)}
</a>
);
}
/** Шапка диалога с исполнителем: что у него сейчас и что дальше */
function ProviderOrders({ current, next }: { current: OrderBrief | null; next: OrderBrief | null }) {
const { t } = useT();
const text = useOrderText();
return (
<div className={`bo-conv__banner bo-conv__banner--order${current ? '' : ' is-free'}`}>
<I name="orders" size={18} />
<span className="bo-grow" style={{ minWidth: 0 }}>
{current ? (
<>
<b>{t('chat.orderNow')}:</b> {text(current, true)}
</>
) : (
<b>{t('chat.noOrderNow')}</b>
)}
<span className="bo-conv__next">
{next ? `${t('chat.orderNext')}: ${text(next)}` : t('chat.orderNoNext')}
</span>
</span>
</div>
);
}
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`); const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
// ───── поле ответа ───── // ───── поле ответа ─────
+20 -2
View File
@@ -233,9 +233,27 @@ async function handle(event: DomainEvent) {
return; return;
} }
case 'concierge.message': { 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; 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 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 channel = msg.thread ? `${adminT(msg.thread.channel === 'CONCIERGE' ? 'bot.admin.channelConcierge' : 'bot.admin.channelSupport')} · ` : '';
const body = msg.text ? escapeHtml(msg.text) : ''; const body = msg.text ? escapeHtml(msg.text) : '';
@@ -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;
+4
View File
@@ -465,6 +465,7 @@ model Order {
offers OrderOffer[] offers OrderOffer[]
files OrderFile[] files OrderFile[]
messages Message[] messages Message[]
chatMessages ConciergeMessage[]
estimates Estimate[] estimates Estimate[]
rating Rating? rating Rating?
lessons LessonPackage? lessons LessonPackage?
@@ -1152,6 +1153,9 @@ model ConciergeMessage {
size Int? size Int?
/// не удалось доставить в Telegram (клиент заблокировал бота и т.п.) /// не удалось доставить в Telegram (клиент заблокировал бота и т.п.)
deliveryError String? deliveryError String?
/// исполнитель писал по этому заказу (текущий заказ в момент сообщения); null — заказа тогда не было
orderId String?
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
/// оператор прочитал сообщение клиента /// оператор прочитал сообщение клиента
readAt DateTime? readAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
+3 -1
View File
@@ -869,7 +869,9 @@
"channelSupport": "Support", "channelSupport": "Support",
"channelConcierge": "Concierge", "channelConcierge": "Concierge",
"file": "file", "file": "file",
"chatAssigned": "{{by}} passed you a conversation: {{name}}" "chatAssigned": "{{by}} passed you a conversation: {{name}}",
"order": "Order #{{number}}",
"noOrder": "No current order"
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}}, card is valid", "valid": "{{name}} — {{level}}, card is valid",
+3 -1
View File
@@ -869,7 +869,9 @@
"channelSupport": "Soporte", "channelSupport": "Soporte",
"channelConcierge": "Conserje", "channelConcierge": "Conserje",
"file": "archivo", "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": { "partner": {
"valid": "{{name}} — {{level}}, tarjeta válida", "valid": "{{name}} — {{level}}, tarjeta válida",
+3 -1
View File
@@ -869,7 +869,9 @@
"channelSupport": "Աջակցություն", "channelSupport": "Աջակցություն",
"channelConcierge": "Կոնսիերժ", "channelConcierge": "Կոնսիերժ",
"file": "ֆայլ", "file": "ֆայլ",
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}" "chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}",
"order": "Պատվեր №{{number}}",
"noOrder": "Այժմ պատվեր չկա"
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}}, քարտը գործում է", "valid": "{{name}} — {{level}}, քարտը գործում է",
+3 -1
View File
@@ -869,7 +869,9 @@
"channelSupport": "Поддержка", "channelSupport": "Поддержка",
"channelConcierge": "Консьерж", "channelConcierge": "Консьерж",
"file": "файл", "file": "файл",
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}" "chatAssigned": "{{by}} передал(а) вам диалог: {{name}}",
"order": "Заказ №{{number}}",
"noOrder": "Сейчас без заказа"
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}}, карта действует", "valid": "{{name}} — {{level}}, карта действует",
+3 -1
View File
@@ -869,7 +869,9 @@
"channelSupport": "客服", "channelSupport": "客服",
"channelConcierge": "礼宾", "channelConcierge": "礼宾",
"file": "文件", "file": "文件",
"chatAssigned": "{{by}} 将对话转交给你:{{name}}" "chatAssigned": "{{by}} 将对话转交给你:{{name}}",
"order": "订单 #{{number}}",
"noOrder": "当前没有订单"
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}},会员卡有效", "valid": "{{name}} — {{level}},会员卡有效",
+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 = диалог был закрыт или новый — бот подтверждает «передали, ответим здесь». * 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 channel: ChatChannel = peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT';
const before = await ensureThread(ctx, peer, channel); const before = await ensureThread(ctx, peer, channel);
const now = ctx.now(); const now = ctx.now();
// исполнитель пишет — запоминаем, по какому заказу (или что заказа не было)
const order = peer.providerId ? await providerCurrentOrder(ctx, peer.providerId, now) : null;
const message = await ctx.db.conciergeMessage.create({ const message = await ctx.db.conciergeMessage.create({
data: { data: {
...peerWhere(peer), ...peerWhere(peer),
orderId: order?.id ?? null,
threadId: before.id, threadId: before.id,
author: peer.clientId ? 'CLIENT' : 'PROVIDER', author: peer.clientId ? 'CLIENT' : 'PROVIDER',
text, text,
@@ -293,7 +354,7 @@ export async function listThreads(
}; };
const rows = await ctx.db.chatThread.findMany({ const rows = await ctx.db.chatThread.findMany({
where, 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' }], orderBy: [{ lastMessageAt: 'desc' }],
take: input.limit ?? 100, take: input.limit ?? 100,
@@ -319,6 +380,8 @@ export async function listThreads(
waitingSince: t.status === 'OPEN' ? t.waitingSince : null, waitingSince: t.status === 'OPEN' ? t.waitingSince : null,
lastMessageAt: t.lastMessageAt, lastMessageAt: t.lastMessageAt,
last: preview(t.messages[0]), 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, unread: unreadBy.get(t.id) ?? 0,
})) }))
.sort((a, b) => { .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 }); const t = await ctx.db.chatThread.findUnique({ where: { id }, include: peerInclude });
if (!t) throw new DomainError('NOT_FOUND'); if (!t) throw new DomainError('NOT_FOUND');
if (opts.markRead !== false) await markRead(ctx, id); 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 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])); 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, botActive: t.botActive,
waitingSince: t.waitingSince, waitingSince: t.waitingSince,
other, other,
currentOrder: currentOrder ? orderBrief(currentOrder) : null,
nextOrder: nextOrder && nextOrder.id !== currentOrder?.id ? orderBrief(nextOrder) : null,
messages: messages.map((m) => ({ messages: messages.map((m) => ({
// только у сообщений исполнителя: заказ, по которому он писал; null — заказа тогда не было
order: m.author === 'PROVIDER' ? (m.order ? orderBrief(m.order) : null) : undefined,
id: m.id, id: m.id,
author: m.author, author: m.author,
authorName: m.adminUserId ? (authors.get(m.adminUserId) ?? null) : null, authorName: m.adminUserId ? (authors.get(m.adminUserId) ?? null) : null,
+35 -1
View File
@@ -5,6 +5,7 @@ import {
chatRecipients, chatRecipients,
clientConversation, clientConversation,
closeThread, closeThread,
createOrder,
conversation, conversation,
DomainError, DomainError,
getThreadForStaff, getThreadForStaff,
@@ -20,7 +21,7 @@ import {
startClient, startClient,
threadCounts, threadCounts,
} from '../src/index.ts'; } 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 () => { beforeEach(async () => {
await fixtures(); await fixtures();
@@ -174,6 +175,39 @@ describe('чаты: поддержка и консьерж', () => {
void boss; 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 () => { it('ответ из старой админки / из Telegram попадает в последний диалог клиента', async () => {
const { ctx } = makeCtx(NOW); const { ctx } = makeCtx(NOW);
const clientId = await client(ctx, 761, true); const clientId = await client(ctx, 761, true);