Bariki cashback, order edit, and current work in progress
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
+163
-4
@@ -2,10 +2,10 @@
|
||||
* Воркер: превращает доменные события в сообщения Telegram на языке получателя (MSG-2, MSG-3)
|
||||
* и раз в минуту запускает фоновые задачи — напоминания и истечение предложений.
|
||||
*/
|
||||
import { getFlow, type ServiceFlow } from '@barev/core';
|
||||
import { getFlow, toYerevan, type ServiceFlow } from '@barev/core';
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { autoAssign, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { autoAssign, retryCompanyAutoAssign, activatePendingPoints, burnPoints, buildPointSuggestions, expirePointRequests, reconcilePoints, warnExpiringPoints, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { getFile } from '@barev/services/storage';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
|
||||
@@ -119,6 +119,7 @@ async function loadOrder(id: string) {
|
||||
district: true,
|
||||
client: { include: { user: true } },
|
||||
provider: { include: { user: true } },
|
||||
company: { select: { autoAssign: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -166,7 +167,108 @@ async function dispatchers(companyId: string) {
|
||||
return db.companyDispatcher.findMany({ where: { companyId }, include: { user: true } });
|
||||
}
|
||||
|
||||
// ───── барики ─────
|
||||
|
||||
const pts = (locale: Locale, n: number) => formatAmd(locale, n).replace(/\s?֏$/, '');
|
||||
const pickName = (v: unknown, locale: Locale) => {
|
||||
const m = (v ?? {}) as Record<string, string>;
|
||||
return m[locale] || m.ru || m.en || '';
|
||||
};
|
||||
const pointsButton = (locale: Locale) => compactRows([[webAppButton(createT(locale)('notify.points.open'), `${CLIENT_APP}/?r=/points`)]]);
|
||||
async function toClientUser(clientId: string, build: (t: ReturnType<typeof createT>, locale: Locale, c: { pointsActive: number }) => string | null) {
|
||||
const c = await db.client.findUnique({ where: { id: clientId }, include: { user: true } });
|
||||
if (!c) return;
|
||||
const locale = c.user.locale as Locale;
|
||||
const text = build(createT(locale), locale, c);
|
||||
if (text) await clientBot.send({ chatId: c.user.telegramId, text, buttons: pointsButton(locale) });
|
||||
}
|
||||
const dateL = (locale: Locale, d: Date) => formatDateTime(locale, d, { day: 'numeric', month: 'long' });
|
||||
|
||||
async function handlePoints(event: DomainEvent): Promise<boolean> {
|
||||
switch (event.type) {
|
||||
case 'points.accrued':
|
||||
case 'points.activated': {
|
||||
const a = await db.pointsAccrual.findUnique({ where: { id: event.accrualId }, include: { category: true, bonusType: true } });
|
||||
if (!a) return true;
|
||||
await toClientUser(a.clientId, (t, l, c) => {
|
||||
const comment = a.commentForClient && a.bonusType?.code !== 'housewarming' ? `\n💬 ${escapeHtml(a.commentForClient)}` : '';
|
||||
if (event.type === 'points.activated') return t('notify.points.activated', { amount: pts(l, a.amount), balance: pts(l, c.pointsActive) });
|
||||
if (a.kind === 'BONUS') {
|
||||
const what = a.bonusType?.code === 'housewarming' && a.commentForClient ? t('notify.points.housewarming', { agency: a.commentForClient }) : pickName(a.bonusType?.name, l);
|
||||
return (a.expiresAt ? t('notify.points.bonus', { amount: pts(l, a.amount), what, until: dateL(l, a.expiresAt) }) : t('notify.points.bonusNoTtl', { amount: pts(l, a.amount), what })) + comment;
|
||||
}
|
||||
const what = pickName(a.category?.name, l);
|
||||
return (a.status === 'PENDING'
|
||||
? t('notify.points.pending', { amount: pts(l, a.amount), what, date: dateL(l, a.serviceDate), available: dateL(l, a.availableAt) })
|
||||
: t('notify.points.active', { amount: pts(l, a.amount), what, balance: pts(l, c.pointsActive) })) + comment;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'points.redeemed': {
|
||||
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
|
||||
if (!r) return true;
|
||||
await toClientUser(r.clientId, (t, l, c) =>
|
||||
r.orderAmount != null
|
||||
? t('notify.points.redeemed', { amount: pts(l, r.amount), due: formatAmd(l, Math.max(0, r.orderAmount - r.amount)), balance: pts(l, c.pointsActive) })
|
||||
: t('notify.points.redeemedNoOrder', { amount: pts(l, r.amount), balance: pts(l, c.pointsActive) }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'points.rejected': {
|
||||
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId } });
|
||||
if (!r) return true;
|
||||
await toClientUser(r.clientId, (t) => (r.reason === 'timeout' ? t('notify.points.requestExpired') : t('notify.points.rejected', { reason: escapeHtml(r.reason === 'declined' ? t('notify.points.declinedGeneric') : (r.reason ?? '')) })));
|
||||
return true;
|
||||
}
|
||||
case 'points.reversed': {
|
||||
const rv = await db.pointsReversal.findUnique({ where: { id: event.reversalId } });
|
||||
if (!rv) return true;
|
||||
const reasonText = (t: ReturnType<typeof createT>) => escapeHtml(rv.reason === 'order_cancelled' ? t('notify.points.orderCancelled') : rv.reason === 'order_edited' ? t('notify.points.orderEdited') : rv.reason);
|
||||
if (rv.targetType === 'REDEMPTION') {
|
||||
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.returned', { amount: pts(l, rv.amount), balance: pts(l, c.pointsActive) }));
|
||||
} else {
|
||||
const a = await db.pointsAccrual.findUnique({ where: { id: rv.targetId }, include: { bonusType: true } });
|
||||
await toClientUser(rv.clientId, (t, l, c) => t('notify.points.reversed', { what: a?.orderRef ?? pickName(a?.bonusType?.name, l), reason: reasonText(t), balance: pts(l, c.pointsActive) }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'points.expiring':
|
||||
await toClientUser(event.clientId, (t, l) => t('notify.points.expiring', { days: event.days, amount: pts(l, event.amount) }));
|
||||
return true;
|
||||
case 'points.expired':
|
||||
await toClientUser(event.clientId, (t, l) => t('notify.points.expired', { amount: pts(l, event.amount) }));
|
||||
return true;
|
||||
case 'points.requested': {
|
||||
const r = await db.pointsRedemption.findUnique({ where: { id: event.redemptionId }, include: { client: { include: { user: true } }, order: true } });
|
||||
if (!r) return true;
|
||||
const order = r.order ? `\n${adminT('bot.admin.points.forOrder', { number: r.order.number, sum: formatAmd('ru', r.order.totalPrice) })}` : r.note ? `\n💬 ${escapeHtml(r.note)}` : '';
|
||||
const buttons: InlineButton[][] = [[...(r.order ? [{ text: adminT('bot.admin.points.confirm'), callback_data: `pr:ok:${r.id}` }] : []), { text: adminT('bot.admin.points.reject'), callback_data: `pr:no:${r.id}` }]];
|
||||
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=requests` }]] : [];
|
||||
await toAdmins(`🪙 <b>${adminT('bot.admin.points.request', { amount: pts('ru', r.amount) })}</b>\n${who(r.client.user)} · ${adminT('bot.admin.points.balance', { balance: pts('ru', r.client.pointsActive) })}${order}`, compactRows([...buttons, ...link]));
|
||||
return true;
|
||||
}
|
||||
case 'points.suggestions': {
|
||||
const open = await db.pointsSuggestion.count({ where: { status: 'OPEN' } });
|
||||
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
|
||||
await toAdmins(`💡 ${adminT('bot.admin.points.suggestions', { count: event.count, open })}`, compactRows(link));
|
||||
return true;
|
||||
}
|
||||
case 'points.overLimit': {
|
||||
const s = await db.pointsSuggestion.findUnique({ where: { id: event.suggestionId }, include: { client: { include: { user: true } } } });
|
||||
if (!s) return true;
|
||||
const by = typeof (s.payload as Record<string, unknown>).adminId === 'string' ? await db.adminUser.findUnique({ where: { id: (s.payload as Record<string, string>).adminId } }) : null;
|
||||
const owners = await db.adminUser.findMany({ where: { active: true, telegramId: { not: null }, roles: { has: 'SUPERADMIN' } } });
|
||||
const link = BACKOFFICE_URL.startsWith('https://') ? [[{ text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/points?tab=suggestions` }]] : [];
|
||||
for (const o of owners) await proBot.send({ chatId: o.telegramId!, text: `⚠️ ${adminT('bot.admin.points.overLimit', { by: escapeHtml(by?.name ?? '—'), amount: pts('ru', s.suggestedAmount) })}\n${who(s.client.user)}`, buttons: compactRows(link) });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(event: DomainEvent) {
|
||||
if (event.type.startsWith('points.') && (await handlePoints(event))) return;
|
||||
switch (event.type) {
|
||||
case 'order.created': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
@@ -207,6 +309,14 @@ async function handle(event: DomainEvent) {
|
||||
if (o) await toClient(o, `notify.event.${event.code}`);
|
||||
return;
|
||||
}
|
||||
case 'order.edited': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
const locale = o.client.user.locale as Locale;
|
||||
await toClient(o, 'notify.orderEdited', { total: formatAmd(locale, o.totalPrice - o.pointsUsed) });
|
||||
if (o.provider?.user) await toProvider(o, o.provider.user, 'notify.pro.orderEdited');
|
||||
return;
|
||||
}
|
||||
case 'order.rescheduled': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
@@ -261,9 +371,28 @@ async function handle(event: DomainEvent) {
|
||||
await reportAuto(o.id, await autoAssign(ctx, o.id));
|
||||
return;
|
||||
}
|
||||
// при автораспределении партнёра заказ сам уйдёт следующему; менеджеру пишем, если сотрудников не осталось (company.noStaff)
|
||||
if (o.company?.autoAssign) return;
|
||||
for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned');
|
||||
return;
|
||||
}
|
||||
case 'company.noStaff': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.noStaff', proDetails(o, d.user.locale as Locale));
|
||||
return;
|
||||
}
|
||||
case 'partner.returned': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
const company = await db.providerCompany.findUnique({ where: { id: event.companyId } });
|
||||
if (!o || !company) return;
|
||||
await toAdmins(
|
||||
`↩️ ${escapeHtml(adminT(event.reason === 'declined' ? 'bot.admin.partnerDeclined' : 'bot.admin.partnerExpired', { number: o.number, partner: company.name }))}\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}`,
|
||||
compactRows([[orderButton(o.id)]]),
|
||||
);
|
||||
if (event.reason === 'expired') for (const d of await dispatchers(event.companyId)) await toProvider(o, d.user, 'notify.dispatcher.expired');
|
||||
return;
|
||||
}
|
||||
case 'company.order': {
|
||||
const o = await loadOrder(event.orderId);
|
||||
if (!o) return;
|
||||
@@ -413,9 +542,10 @@ async function handle(event: DomainEvent) {
|
||||
case 'webRequest.created': {
|
||||
const r = await db.webRequest.findUnique({ where: { id: event.requestId }, include: { service: true, district: true } });
|
||||
if (!r) return;
|
||||
const date = formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const date = r.preferredAt ? formatDateTime('ru', r.preferredAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }) : adminT('bot.admin.noTime');
|
||||
const place = r.district ? adminT(`districts.${r.district.code}`) : adminT('bot.admin.noAddress');
|
||||
await toAdmins(
|
||||
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(adminT(`districts.${r.district.code}`))} · ${formatAmd('ru', r.totalPrice)}`,
|
||||
`🧹 <b>${adminT('bot.admin.newWebRequest', { number: r.number })}</b>\n${escapeHtml(adminT(`services.${r.service.code}.name`))} · ${date}\n${escapeHtml(r.name ?? '')} ${escapeHtml(r.phone)} · ${escapeHtml(place)} · ${formatAmd('ru', r.totalPrice)}`,
|
||||
compactRows([[adminButton('/requests')]]),
|
||||
);
|
||||
return;
|
||||
@@ -448,6 +578,9 @@ async function tick() {
|
||||
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
|
||||
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
|
||||
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
|
||||
// заказы партнёров с автораспределением, которые ждут сотрудника
|
||||
await retryCompanyAutoAssign(ctx);
|
||||
await pointsJobs();
|
||||
// задачи продажников, у которых наступил срок
|
||||
await dueSalesTasks(ctx);
|
||||
// рассылки, прерванные перезапуском, — продолжить
|
||||
@@ -458,6 +591,32 @@ async function tick() {
|
||||
ticking = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Барики: ожидающие → доступные и просроченные запросы — каждую минуту;
|
||||
* раз в час — предложения системы; раз в сутки — сгорание (03:00), сверка (04:00), предупреждения (11:00).
|
||||
* Задачи идемпотентны: повторный запуск после рестарта ничего не задваивает.
|
||||
*/
|
||||
const lastRun = new Map<string, string>();
|
||||
async function once(key: string, period: string, fn: () => Promise<unknown>) {
|
||||
if (lastRun.get(key) === period) return;
|
||||
lastRun.set(key, period);
|
||||
await fn();
|
||||
}
|
||||
async function pointsJobs() {
|
||||
await activatePendingPoints(ctx);
|
||||
await expirePointRequests(ctx);
|
||||
const y = toYerevan(new Date());
|
||||
const day = `${y.year}-${y.month}-${y.day}`;
|
||||
await once('suggestions', `${day} ${y.hour}`, () => buildPointSuggestions(ctx));
|
||||
if (y.hour >= 3) await once('burn', day, () => burnPoints(ctx));
|
||||
if (y.hour >= 4)
|
||||
await once('reconcile', day, async () => {
|
||||
const diff = await reconcilePoints(db);
|
||||
if (diff.length) await toAdmins(`⚠️ ${adminT('bot.admin.points.mismatch', { count: diff.length })}`);
|
||||
});
|
||||
if (y.hour >= 11) await once('warn', day, () => warnExpiringPoints(ctx));
|
||||
}
|
||||
|
||||
setInterval(tick, 60_000);
|
||||
void tick();
|
||||
|
||||
|
||||
Reference in new issue
Block a user