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 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);
+40
View File
@@ -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;
+24
View File
@@ -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': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։',
+80
View File
@@ -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 }: {
</span>
<span className="bo-thread__meta">
{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.botActive && (
<span className="bo-chip">
@@ -334,6 +337,7 @@ function Conversation({ id, me, channels, side, onToggleSide }: { id: string; me
</div>
</header>
{d.providerId && <ProviderOrders current={d.currentOrder} next={d.nextOrder} />}
{d.botActive && (
<div className="bo-conv__banner bo-conv__banner--bot">
<I name="bot" size={18} />
@@ -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:
<Fragment key={m.id}>
{sep}
<div className={`bo-msg bo-msg--${kind}`}>
{m.author === 'PROVIDER' && m.order !== undefined && <OrderTag order={m.order} />}
{kind === 'note' && (
<div className="bo-msg__label">
<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`);
// ───── поле ответа ─────
+20 -2
View File
@@ -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) : '';