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:
1 parent
23ffb2bf1d
commit
2772837e29
14 files changed
+321
-13
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -869,7 +869,9 @@
|
||||
"channelSupport": "Աջակցություն",
|
||||
"channelConcierge": "Կոնսիերժ",
|
||||
"file": "ֆայլ",
|
||||
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}"
|
||||
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}",
|
||||
"order": "Պատվեր №{{number}}",
|
||||
"noOrder": "Այժմ պատվեր չկա"
|
||||
},
|
||||
"partner": {
|
||||
"valid": "{{name}} — {{level}}, քարտը գործում է",
|
||||
|
||||
@@ -869,7 +869,9 @@
|
||||
"channelSupport": "Поддержка",
|
||||
"channelConcierge": "Консьерж",
|
||||
"file": "файл",
|
||||
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}"
|
||||
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}",
|
||||
"order": "Заказ №{{number}}",
|
||||
"noOrder": "Сейчас без заказа"
|
||||
},
|
||||
"partner": {
|
||||
"valid": "{{name}} — {{level}}, карта действует",
|
||||
|
||||
@@ -869,7 +869,9 @@
|
||||
"channelSupport": "客服",
|
||||
"channelConcierge": "礼宾",
|
||||
"file": "文件",
|
||||
"chatAssigned": "{{by}} 将对话转交给你:{{name}}"
|
||||
"chatAssigned": "{{by}} 将对话转交给你:{{name}}",
|
||||
"order": "订单 #{{number}}",
|
||||
"noOrder": "当前没有订单"
|
||||
},
|
||||
"partner": {
|
||||
"valid": "{{name}} — {{level}},会员卡有效",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user