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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+163 -4
View File
@@ -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();