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

+96 -4
View File
@@ -1,4 +1,7 @@
import { averageRating, getFlow, isProviderFree, rankCandidates, startOfYerevanDay, type Candidate, type Locale } from '@barev/core';
import { averageRating, FLOWS, getFlow, isProviderFree, rankCandidates, startOfYerevanDay, type Candidate, type Locale } from '@barev/core';
/** статусы «только создан, ждёт исполнителя» во всех сценариях */
const INITIAL_STATUSES = new Set(Object.values(FLOWS).map((f) => f.initial));
import type { Tx } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
@@ -84,10 +87,13 @@ async function revokeActiveOffers(tx: Tx, orderId: string) {
await tx.orderOffer.updateMany({ where: { orderId, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } });
}
/** Назначение из админки: частному исполнителю (с подтверждением) или компании (PRO-5, PRO-7) */
/**
* Назначение: частному исполнителю (с подтверждением) или компании (PRO-5, PRO-7).
* adminId = null — предложение сделала система (автораспределение).
*/
export async function offerOrder(
ctx: Ctx,
input: { orderId: string; adminId: string; target: { kind: 'provider' | 'company'; id: string } },
input: { orderId: string; adminId: string | null; target: { kind: 'provider' | 'company'; id: string } },
) {
const settings = await getSettings(ctx.db);
const timeout = settings['offer.acceptTimeoutMinutes'];
@@ -98,6 +104,8 @@ export async function offerOrder(
const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.providerId) throw new DomainError('ALREADY_ASSIGNED');
// система не перебивает действующее предложение (его может отозвать только человек)
if (!input.adminId && (await tx.orderOffer.count({ where: { orderId: order.id, status: 'OFFERED' } }))) throw new DomainError('ALREADY_ASSIGNED');
if (input.target.kind === 'provider') {
const provider = await tx.provider.findUnique({ where: { id: input.target.id } });
@@ -117,6 +125,9 @@ export async function offerOrder(
createdById: input.adminId,
},
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.adminId ? 'offered' : 'autoOffered', actorType: input.adminId ? 'ADMIN' : 'SYSTEM', actorId: input.adminId, comment: provider.name },
});
events.push({ type: 'offer.created', offerId: offer.id });
return offer;
}
@@ -130,7 +141,7 @@ export async function offerOrder(
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId },
});
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: 'ADMIN', actorId: input.adminId, comment: company.name },
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: input.adminId ? 'ADMIN' : 'SYSTEM', actorId: input.adminId, comment: company.name },
});
events.push({ type: 'company.order', orderId: order.id, companyId: company.id });
return offer;
@@ -277,3 +288,84 @@ export async function unassignOrder(ctx: Ctx, input: { orderId: string; adminId:
});
for (const e of events) await ctx.notify.emit(e);
}
// ───── автораспределение ─────
export type AutoAssignResult =
/** предложили исполнителю или компании */
| { result: 'offered'; offerId: string; target: string }
/** выключено в настройках */
| { result: 'off' }
/** уже назначен, есть активное предложение, заказ в прошлом или начат */
| { result: 'skipped' }
/** свободных подходящих нет; first — впервые для этого заказа (сообщить команде) */
| { result: 'none'; first: boolean }
/** исчерпан лимит попыток — дальше вручную; first — впервые */
| { result: 'gaveUp'; first: boolean };
/**
* Автораспределение: новый заказ сам предлагается лучшему свободному исполнителю (те же правила, что у подсказки:
* язык клиента, район, смена, загрузка, рейтинг). Отказ или молчание — следующему.
* Кому заказ уже предлагали (в т.ч. снятым с заказа), второй раз не предлагаем.
* После N попыток подряд система останавливается — заказ ждёт диспетчера.
*/
export async function autoAssign(ctx: Ctx, orderId: string, opts: { force?: boolean } = {}): Promise<AutoAssignResult> {
const settings = await getSettings(ctx.db);
// force — кнопка «Подобрать автоматически» у диспетчера: работает и при выключенном автораспределении, без лимита попыток
if (!settings['assign.auto'] && !opts.force) return { result: 'off' };
const order = await ctx.db.order.findUnique({
where: { id: orderId },
include: { service: true, offers: true, history: { where: { event: { in: ['autoNoCandidate', 'autoGaveUp', 'autoOffered'] } }, orderBy: { createdAt: 'desc' }, take: 1 } },
});
if (!order) return { result: 'skipped' };
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.providerId || order.offers.some((o) => o.status === 'OFFERED') || order.scheduledAt <= ctx.now()) {
return { result: 'skipped' };
}
const lastAuto = order.history[0]?.event ?? null;
const mark = (event: 'autoNoCandidate' | 'autoGaveUp') =>
ctx.db.orderStatusHistory.create({ data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event, actorType: 'SYSTEM' } });
const autoTries = order.offers.filter((o) => !o.createdById).length;
if (!opts.force && autoTries >= settings['assign.maxAutoOffers']) {
if (lastAuto === 'autoGaveUp') return { result: 'gaveUp', first: false };
await mark('autoGaveUp');
return { result: 'gaveUp', first: true };
}
const triedProviders = new Set(order.offers.map((o) => o.providerId).filter((x): x is string => !!x));
const triedCompanies = new Set(order.offers.filter((o) => !o.providerId).map((o) => o.companyId).filter((x): x is string => !!x));
const { ranked } = await assignmentCandidates(ctx, order.id);
const pick = ranked.find((c) => (c.kind === 'company' ? !triedCompanies.has(c.id) : !triedProviders.has(c.id)));
if (!pick) {
if (lastAuto === 'autoNoCandidate') return { result: 'none', first: false };
await mark('autoNoCandidate');
return { result: 'none', first: true };
}
try {
const offer = await offerOrder(ctx, { orderId: order.id, adminId: null, target: { kind: pick.kind, id: pick.id } });
return { result: 'offered', offerId: offer.id, target: pick.name };
} catch (e) {
// кто-то успел назначить вручную или исполнитель только что занялся — попробуем позже
if (e instanceof DomainError && (e.code === 'ALREADY_ASSIGNED' || e.code === 'PROVIDER_BUSY')) return { result: 'skipped' };
throw e;
}
}
/**
* Воркер раз в минуту: заказы, которые всё ещё ждут исполнителя (никому не предложены),
* пробуем распределить снова — вдруг кто-то вышел на смену или освободился.
*/
export async function retryAutoAssign(ctx: Ctx) {
const settings = await getSettings(ctx.db);
if (!settings['assign.auto']) return [];
const waiting = await ctx.db.order.findMany({
where: { providerId: null, companyId: null, scheduledAt: { gt: ctx.now() }, status: { in: [...INITIAL_STATUSES] }, offers: { none: { status: 'OFFERED' } } },
select: { id: true },
orderBy: { scheduledAt: 'asc' },
take: 50,
});
const out: { orderId: string; res: AutoAssignResult }[] = [];
for (const o of waiting) out.push({ orderId: o.id, res: await autoAssign(ctx, o.id) });
return out;
}