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'; import { getSettings } from './settings.ts'; import { transitionOrder } from './orders.ts'; const DAY_MS = 24 * 3600_000; function interval(o: { scheduledAt: Date; durationMinutes: number }) { return { start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) }; } /** * ADM-3: подсказка исполнителя. Частные исполнители и компании нужной роли; * компания «свободна», если свободен хотя бы один её сотрудник. */ export async function assignmentCandidates(ctx: Ctx, orderId: string) { const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } }); const slot = interval(order); const dayStart = startOfYerevanDay(order.scheduledAt); const dayEnd = new Date(dayStart.getTime() + DAY_MS); const role = order.service.providerRole; const [availability, providers, companies] = await Promise.all([ loadAvailability(ctx.db, role, dayStart, dayEnd, { excludeOrderId: order.id }), ctx.db.provider.findMany({ where: { role, active: true }, include: { districts: true } }), ctx.db.providerCompany.findMany({ where: { active: true, roles: { has: role } } }), ]); const availById = new Map(availability.map((a) => [a.id, a])); const loadOf = (id: string) => availById.get(id)?.busy.length ?? 0; const freeOf = (id: string) => { const a = availById.get(id); return !!a && isProviderFree(a, slot); }; const candidates: Candidate[] = providers .filter((p) => !p.companyId) .map((p) => ({ kind: 'provider', id: p.id, name: p.name, languages: p.languages as Locale[], districtIds: p.districts.map((d) => d.districtId), load: loadOf(p.id), rating: averageRating(p.ratingSum, p.ratingCount), free: freeOf(p.id), onShift: p.onShift, })); for (const c of companies) { const staff = providers.filter((p) => p.companyId === c.id); if (!staff.length) continue; const free = staff.filter((p) => freeOf(p.id)); const ratings = staff.filter((p) => p.ratingCount > 0); candidates.push({ kind: 'company', id: c.id, name: c.name, languages: [...new Set(free.flatMap((p) => p.languages as Locale[]))], districtIds: [...new Set(free.flatMap((p) => p.districts.map((d) => d.districtId)))], load: Math.min(...staff.map((p) => loadOf(p.id))), rating: ratings.length ? averageRating( ratings.reduce((s, p) => s + p.ratingSum, 0), ratings.reduce((s, p) => s + p.ratingCount, 0), ) : null, free: free.length > 0, onShift: free.some((p) => p.onShift), }); } const ranked = rankCandidates(candidates, { locale: order.locale as Locale, districtId: order.districtId, isOnline: order.isOnline, }); const busy = candidates.filter((c) => !c.free); return { ranked, busy }; } 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). * adminId = null — предложение сделала система (автораспределение). */ export async function offerOrder( ctx: Ctx, input: { orderId: string; adminId: string | null; target: { kind: 'provider' | 'company'; id: string } }, ) { const settings = await getSettings(ctx.db); const timeout = settings['offer.acceptTimeoutMinutes']; const events: DomainEvent[] = []; const offer = await ctx.db.$transaction(async (tx) => { await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`; 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 } }); if (!provider || !provider.active || provider.role !== order.service.providerRole) throw new DomainError('NOT_FOUND'); const [avail] = await loadAvailability(tx, provider.role, order.scheduledAt, interval(order).end, { excludeOrderId: order.id }).then((a) => a.filter((x) => x.id === provider.id), ); if (!avail || !isProviderFree(avail, interval(order))) throw new DomainError('PROVIDER_BUSY'); await revokeActiveOffers(tx, order.id); await tx.order.update({ where: { id: order.id }, data: { companyId: provider.companyId } }); const offer = await tx.orderOffer.create({ data: { orderId: order.id, providerId: provider.id, status: 'OFFERED', expiresAt: new Date(ctx.now().getTime() + timeout * 60_000), 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; } const company = await tx.providerCompany.findUnique({ where: { id: input.target.id } }); if (!company || !company.active || !company.roles.includes(order.service.providerRole)) throw new DomainError('NOT_FOUND'); await revokeActiveOffers(tx, order.id); await tx.order.update({ where: { id: order.id }, data: { companyId: company.id } }); // предложение компании без таймаута — заказ «Назначен на компанию», пока диспетчер не выберет сотрудника const offer = await tx.orderOffer.create({ 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: input.adminId ? 'ADMIN' : 'SYSTEM', actorId: input.adminId, comment: company.name }, }); events.push({ type: 'company.order', orderId: order.id, companyId: company.id }); return offer; }); for (const e of events) await ctx.notify.emit(e); return offer; } /** Принять заказ исполнителем: проставляется исполнитель, статус → «Назначен» */ async function acceptOffer(ctx: Ctx, tx: Tx, offerId: string, providerId: string) { const offer = await tx.orderOffer.findUnique({ where: { id: offerId }, include: { order: { include: { service: true } } } }); if (!offer || offer.providerId !== providerId || offer.status !== 'OFFERED') throw new DomainError('OFFER_NOT_ACTIVE'); if (offer.expiresAt && offer.expiresAt < ctx.now()) throw new DomainError('OFFER_NOT_ACTIVE'); const flow = getFlow(offer.order.service.flow); await tx.orderOffer.update({ where: { id: offer.id }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } }); if (flow.assigned) { const r = await transitionOrder( ctx, { orderId: offer.orderId, to: flow.assigned, actor: 'SYSTEM', actorId: providerId, event: 'accepted', data: { providerId }, expectFrom: flow.initial }, tx, ); return r.event; } await tx.order.update({ where: { id: offer.orderId }, data: { providerId } }); return null; } /** * PRO-5: ответ исполнителя. Отказ (или таймаут) возвращает заказ в «Новые». * Если исполнитель — сотрудник компании, заказ возвращается диспетчеру компании. */ export async function respondToOffer(ctx: Ctx, input: { offerId: string; providerId: string; accept: boolean }) { const event = await ctx.db.$transaction(async (tx): Promise => { const locked = await tx.orderOffer.findUnique({ where: { id: input.offerId } }); if (!locked) throw new DomainError('OFFER_NOT_ACTIVE'); await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${locked.orderId} FOR UPDATE`; if (input.accept) return acceptOffer(ctx, tx, input.offerId, input.providerId); return declineOffer(ctx, tx, input.offerId, 'DECLINED', input.providerId); }); if (event) await ctx.notify.emit(event); } async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED' | 'EXPIRED', providerId?: string): Promise { const offer = await tx.orderOffer.findUnique({ where: { id: offerId }, include: { provider: true } }); if (!offer || offer.status !== 'OFFERED' || (providerId && offer.providerId !== providerId)) throw new DomainError('OFFER_NOT_ACTIVE'); await tx.orderOffer.update({ where: { id: offer.id }, data: { status, respondedAt: ctx.now() } }); const companyId = offer.provider?.companyId ?? null; if (companyId) { // сотрудник отказался — заказ снова у диспетчера компании await tx.orderOffer.create({ data: { orderId: offer.orderId, companyId, status: 'OFFERED' } }); } else { await tx.order.update({ where: { id: offer.orderId }, data: { companyId: null } }); } await tx.orderStatusHistory.create({ data: { orderId: offer.orderId, toStatus: (await tx.order.findUniqueOrThrow({ where: { id: offer.orderId } })).status, event: status === 'DECLINED' ? 'offerDeclined' : 'offerExpired', actorType: status === 'DECLINED' ? 'PROVIDER' : 'SYSTEM', actorId: offer.providerId, }, }); return { type: status === 'DECLINED' ? 'offer.declined' : 'offer.expired', offerId: offer.id }; } /** Воркер: просроченные предложения → как отказ */ export async function expireOffers(ctx: Ctx): Promise { const expired = await ctx.db.orderOffer.findMany({ where: { status: 'OFFERED', expiresAt: { lt: ctx.now() }, providerId: { not: null } }, select: { id: true }, }); let n = 0; for (const { id } of expired) { try { const event = await ctx.db.$transaction((tx) => declineOffer(ctx, tx, id, 'EXPIRED')); await ctx.notify.emit(event); n++; } catch (e) { if (!(e instanceof DomainError)) throw e; } } return n; } /** * PRO-7: диспетчер компании выбирает сотрудника. Компания отвечает за своих людей, * поэтому заказ сразу «Назначен», без отдельного подтверждения сотрудником. */ export async function dispatcherAssign(ctx: Ctx, input: { orderId: string; companyId: string; providerId: string; dispatcherUserId: string }) { const event = await ctx.db.$transaction(async (tx) => { await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`; const dispatcher = await tx.companyDispatcher.findUnique({ where: { companyId_userId: { companyId: input.companyId, userId: input.dispatcherUserId } }, }); if (!dispatcher) throw new DomainError('FORBIDDEN'); const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } }); if (order.companyId !== input.companyId) throw new DomainError('NOT_FOUND'); const flow = getFlow(order.service.flow); if (order.providerId || order.status !== flow.initial) throw new DomainError('ALREADY_ASSIGNED'); const provider = await tx.provider.findUnique({ where: { id: input.providerId } }); if (!provider || provider.companyId !== input.companyId || !provider.active || provider.role !== order.service.providerRole) { throw new DomainError('NOT_FOUND'); } const [avail] = (await loadAvailability(tx, provider.role, order.scheduledAt, interval(order).end, { excludeOrderId: order.id })).filter( (x) => x.id === provider.id, ); if (!avail || !isProviderFree(avail, interval(order))) throw new DomainError('PROVIDER_BUSY'); await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } }); if (flow.assigned) { const r = await transitionOrder( ctx, { orderId: order.id, to: flow.assigned, actor: 'DISPATCHER', actorId: input.dispatcherUserId, event: 'dispatcherAssigned', data: { providerId: provider.id } }, tx, ); return r.event; } await tx.order.update({ where: { id: order.id }, data: { providerId: provider.id } }); return null; }); if (event) await ctx.notify.emit(event); } /** Снять исполнителя (админ): заказ возвращается в «Новые» */ export async function unassignOrder(ctx: Ctx, input: { orderId: string; adminId: string }) { const events: DomainEvent[] = []; await ctx.db.$transaction(async (tx) => { await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`; const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } }); const flow = getFlow(order.service.flow); await revokeActiveOffers(tx, order.id); if (order.status === flow.assigned) { const r = await transitionOrder( ctx, { orderId: order.id, to: flow.initial, actor: 'ADMIN', actorId: input.adminId, event: 'unassigned', data: { providerId: null, companyId: null } }, tx, ); events.push(r.event); } else if (order.status === flow.initial) { await tx.order.update({ where: { id: order.id }, data: { providerId: null, companyId: null } }); } else { throw new DomainError('TRANSITION_NOT_ALLOWED'); } if (order.providerId) events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId }); }); 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 { 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; }