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:
1 parent
2772837e29
commit
e7de4f5893
33 files changed
+4521
-45
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user