Backoffice stage 3: orders and providers, auto-assignment

- Auto-assignment: a new order is offered to the best free provider (same ranking as
  the dispatcher hint); a decline or timeout passes it to the next one; nobody is
  offered the same order twice; after N tries it waits for a dispatcher and the team
  is notified once. The worker retries waiting orders every minute. Settings
  assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
  done, cancelled), periods and search; order card with progress, provider or ranked
  candidates ("offer" / "pick automatically"), details, checklist and photos,
  history in plain words, client, money (superadmin), payment, status change,
  reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
  address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
  quality, ratings, upcoming orders, schedule and districts, admission documents,
  block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
  and documents (ProviderDoc).
- Tests for auto-assignment.

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:25:41 +04:00
1 parent 2772837e29
commit e7de4f5893
33 files changed
+4521 -45

No files matched your search

+27 -3
View File
@@ -4,7 +4,7 @@
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
import { autoAssign, chatRecipients, dueReminders, 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';
@@ -31,6 +31,21 @@ async function toAdmins(text: string, buttons: InlineButton[][] = []) {
const adminT = createT('ru');
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null);
/** Карточка заказа: в новой админке, если она уже на сервере, иначе — канбан старой */
const orderButton = (orderId: string): InlineButton | null =>
BACKOFFICE_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/orders/${orderId}` } : adminButton('/');
/** Автораспределение не нашло исполнителя (впервые для заказа) — команде в рабочий бот */
async function reportAuto(orderId: string, res: AutoAssignResult) {
if ((res.result !== 'none' && res.result !== 'gaveUp') || !res.first) return;
const o = await loadOrder(orderId);
if (!o) return;
await toAdmins(
`⚠️ ${escapeHtml(adminT('bot.admin.autoFailed', { number: o.number }))}\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}`,
compactRows([[orderButton(o.id)]]),
);
}
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
@@ -104,8 +119,10 @@ async function handle(event: DomainEvent) {
await toClient(o, 'notify.created', { total: formatAmd(locale, o.totalPrice) });
await toAdmins(
`📦 <b>${adminT('bot.admin.newOrder', { number: o.number })}</b>\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}\n${who(o.client.user)} · ${formatAmd('ru', o.totalPrice)}`,
compactRows([[adminButton('/')]]),
compactRows([[orderButton(o.id)]]),
);
// автораспределение: сразу предлагаем лучшему свободному исполнителю
await reportAuto(o.id, await autoAssign(ctx, o.id));
return;
}
case 'order.status': {
@@ -173,7 +190,12 @@ async function handle(event: DomainEvent) {
const offer = await db.orderOffer.findUnique({ where: { id: event.offerId }, include: { provider: true } });
const companyId = offer?.provider?.companyId;
const o = offer && (await loadOrder(offer.orderId));
if (!o || !companyId) return;
if (!o) return;
// частный исполнитель отказался или промолчал — система предлагает следующему
if (!companyId) {
await reportAuto(o.id, await autoAssign(ctx, o.id));
return;
}
for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned');
return;
}
@@ -343,6 +365,8 @@ async function tick() {
try {
const [reminders, expired] = await Promise.all([dueReminders(ctx), expireOffers(ctx)]);
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
} catch (e) {
console.error('[worker] tick failed', e);
} finally {