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

+4
View File
@@ -41,6 +41,10 @@ export const settingsSchema = z.object({
'transfer.premiumPrice': z.number().int().min(0).default(20000),
/** PRO-5: сколько минут исполнитель может думать над предложением */
'offer.acceptTimeoutMinutes': z.number().int().min(1).max(1440).default(15),
/** автораспределение: новый заказ сам предлагается лучшему свободному исполнителю; отказ — следующему */
'assign.auto': z.boolean().default(true),
/** сколько исполнителей подряд система пробует сама, дальше заказ ждёт диспетчера */
'assign.maxAutoOffers': z.number().int().min(1).max(20).default(5),
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
'chat.closeAfterHours': z.number().int().min(0).default(24),
/** раздел 6: автоудаление сканов через N дней после завершения */
@@ -0,0 +1,26 @@
-- AlterTable
ALTER TABLE "Provider" ADD COLUMN "adminNote" TEXT,
ADD COLUMN "blockedAt" TIMESTAMP(3),
ADD COLUMN "blockedReason" TEXT;
-- CreateTable
CREATE TABLE "ProviderDoc" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"fileKey" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mime" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"uploadedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProviderDoc_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProviderDoc_providerId_idx" ON "ProviderDoc"("providerId");
-- AddForeignKey
ALTER TABLE "ProviderDoc" ADD CONSTRAINT "ProviderDoc_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Payout" ADD COLUMN "createdById" TEXT,
ADD COLUMN "note" TEXT,
ADD COLUMN "paidAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "Payout_providerId_createdAt_idx" ON "Payout"("providerId", "createdAt");
+28
View File
@@ -290,10 +290,16 @@ model Provider {
/// пресет ставок (джун / мидл / прайват); если задан — заменяет rateType/rateValue
payoutPresetId String?
payoutPreset PayoutPreset? @relation(fields: [payoutPresetId], references: [id])
/// допуск: почему заблокирован и когда (active = false)
blockedReason String?
blockedAt DateTime?
/// заметка команды об исполнителе (он не видит)
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
districts ProviderDistrict[]
docs ProviderDoc[]
schedule ProviderSchedule[]
orders Order[]
offers OrderOffer[]
@@ -305,6 +311,22 @@ model Provider {
chatThreads ChatThread[]
}
/// Документы исполнителя для допуска: паспорт, договор, справка… Файл — в приватном хранилище
model ProviderDoc {
id String @id @default(cuid())
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
title String
fileKey String
fileName String
mime String
size Int
uploadedById String?
createdAt DateTime @default(now())
@@index([providerId])
}
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts)
model PayoutPreset {
id String @id @default(cuid())
@@ -832,7 +854,13 @@ model Payout {
periodEnd DateTime
amount Int
status PayoutStatus @default(DRAFT)
/// когда выплачено и как (наличные, перевод…), кто отметил
paidAt DateTime?
note String?
createdById String?
createdAt DateTime @default(now())
@@index([providerId, createdAt])
}
// ───────────── Админка ─────────────
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "file",
"chatAssigned": "{{by}} passed you a conversation: {{name}}",
"order": "Order #{{number}}",
"noOrder": "No current order"
"noOrder": "No current order",
"autoFailed": "No free provider found for order #{{number}} — please assign manually."
},
"partner": {
"valid": "{{name}} — {{level}}, card is valid",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "Residence permit: support from, AMD",
"residence_stateFee": "Residence permit: state fee (1 year), AMD",
"transfer_standardPrice": "Transfer “Standard”, AMD",
"transfer_premiumPrice": "Transfer “Premium”, AMD"
"transfer_premiumPrice": "Transfer “Premium”, AMD",
"assign_auto": "Assign orders automatically",
"assign_maxAutoOffers": "How many providers to try automatically"
},
"presets": {
"add": "Add preset",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "archivo",
"chatAssigned": "{{by}} te ha pasado una conversación: {{name}}",
"order": "Pedido n.º {{number}}",
"noOrder": "Sin pedido en curso"
"noOrder": "Sin pedido en curso",
"autoFailed": "No hay profesional libre para el pedido n.º {{number}}: asígnalo manualmente."
},
"partner": {
"valid": "{{name}} — {{level}}, tarjeta válida",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "Residencia: acompañamiento desde, AMD",
"residence_stateFee": "Residencia: tasa estatal (1 año), AMD",
"transfer_standardPrice": "Traslado «Estándar», AMD",
"transfer_premiumPrice": "Traslado «Premium», AMD"
"transfer_premiumPrice": "Traslado «Premium», AMD",
"assign_auto": "Asignar pedidos automáticamente",
"assign_maxAutoOffers": "Cuántos profesionales probar automáticamente"
},
"presets": {
"add": "Añadir plantilla",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "ֆայլ",
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}",
"order": "Պատվեր №{{number}}",
"noOrder": "Այժմ պատվեր չկա"
"noOrder": "Այժմ պատվեր չկա",
"autoFailed": "№{{number}} պատվերի համար ազատ կատարող չգտնվեց․ նշանակեք ձեռքով։"
},
"partner": {
"valid": "{{name}} — {{level}}, քարտը գործում է",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "Կացության քարտ՝ ուղեկցում «սկսած», ֏",
"residence_stateFee": "Կացության քարտ՝ պետտուրք 1 տարի, ֏",
"transfer_standardPrice": "Տրանսֆեր «Ստանդարտ», ֏",
"transfer_premiumPrice": "Տրանսֆեր «Պրեմիում», ֏"
"transfer_premiumPrice": "Տրանսֆեր «Պրեմիում», ֏",
"assign_auto": "Պատվերների ավտոմատ բաշխում",
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ"
},
"presets": {
"add": "Ավելացնել նախադրվածք",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "файл",
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}",
"order": "Заказ №{{number}}",
"noOrder": "Сейчас без заказа"
"noOrder": "Сейчас без заказа",
"autoFailed": "Не нашли свободного исполнителя для заказа №{{number}} — назначьте вручную."
},
"partner": {
"valid": "{{name}} — {{level}}, карта действует",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "ВНЖ: сопровождение «от», ֏",
"residence_stateFee": "ВНЖ: госпошлина на 1 год, ֏",
"transfer_standardPrice": "Трансфер «Стандарт», ֏",
"transfer_premiumPrice": "Трансфер «Премиум», ֏"
"transfer_premiumPrice": "Трансфер «Премиум», ֏",
"assign_auto": "Автораспределение заказов",
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически"
},
"presets": {
"add": "Добавить пресет",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "文件",
"chatAssigned": "{{by}} 将对话转交给你:{{name}}",
"order": "订单 #{{number}}",
"noOrder": "当前没有订单"
"noOrder": "当前没有订单",
"autoFailed": "订单 #{{number}} 没有找到空闲的服务人员,请手动分配。"
},
"partner": {
"valid": "{{name}} — {{level}},会员卡有效",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "居留许可:陪同办理起价,֏",
"residence_stateFee": "居留许可:1年国家规费,֏",
"transfer_standardPrice": "接机「标准」,֏",
"transfer_premiumPrice": "接机「高级」,֏"
"transfer_premiumPrice": "接机「高级」,֏",
"assign_auto": "自动分配订单",
"assign_maxAutoOffers": "自动尝试的服务人员数量"
},
"presets": {
"add": "添加方案",
+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;
}
+43 -2
View File
@@ -74,6 +74,10 @@ export interface CreateOrderInput {
note?: string;
paymentMethodId?: string;
repeatOfId?: string;
/** заказ оформила команда за клиента (новая админка) */
createdBy?: { type: 'ADMIN'; id: string };
/** оператор ставит время, даже если по графику все заняты (договорился с исполнителем сам) */
allowBusySlot?: boolean;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
@@ -88,7 +92,8 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
async (tx) => {
// сериализуем создание заказов одной роли, чтобы не продать слот дважды
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
else if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({
data: {
@@ -115,7 +120,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
checklist: {
create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })),
},
history: { create: { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
history: { create: input.createdBy ? { toStatus: flow.initial, actorType: 'ADMIN', actorId: input.createdBy.id, event: 'createdByStaff' } : { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
},
@@ -355,6 +360,42 @@ export async function rescheduleByClient(ctx: Ctx, input: { orderId: string; cli
for (const e of events) await ctx.notify.emit(e);
}
/**
* Перенос командой: в любой момент до начала работы. Если назначенный исполнитель занят в новое время —
* заказ возвращается в «Новые» (и уйдёт в автораспределение). allowBusySlot — время вне свободных слотов.
*/
export async function rescheduleByAdmin(ctx: Ctx, input: { orderId: string; adminId: string; scheduledAt: Date; allowBusySlot?: boolean; comment?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial && order.status !== flow.assigned) throw new DomainError('TRANSITION_NOT_ALLOWED');
if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
const service = await getServiceByCode(ctx.db, order.service.code);
const events: DomainEvent[] = [{ type: 'order.rescheduled', orderId: order.id }];
await ctx.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, order.durationMinutes, ctx.now(), order.id);
let keepProvider = false;
if (order.providerId) {
const end = new Date(input.scheduledAt.getTime() + order.durationMinutes * 60_000);
const [p] = (await loadAvailability(tx, service.providerRole, input.scheduledAt, end, { excludeOrderId: order.id })).filter((x) => x.id === order.providerId);
keepProvider = !!p && isProviderFree(p, { start: input.scheduledAt, end });
}
// действующее предложение было на старое время — отзываем
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
await tx.order.update({ where: { id: order.id }, data: { scheduledAt: input.scheduledAt, reminded24hAt: null, reminded1hAt: null } });
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'rescheduled', actorType: 'ADMIN', actorId: input.adminId, comment: input.comment },
});
if (order.providerId && !keepProvider) {
const r = await transitionOrder(ctx, { orderId: order.id, to: flow.initial, actor: 'ADMIN', actorId: input.adminId, event: 'rescheduled', data: { providerId: null, companyId: null } }, tx);
events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId }, r.event);
}
});
for (const e of events) await ctx.notify.emit(e);
return { keptProvider: events.length === 1 };
}
/** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */
export async function repeatDraft(db: Db, orderId: string, clientId: string) {
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
+96
View File
@@ -0,0 +1,96 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { autoAssign, createOrder, giveConsent, respondToOffer, retryAutoAssign, startClient, unassignOrder } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
// при равном счёте компания идёт раньше по алфавиту — в большинстве тестов проверяем частных исполнителей
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: false } });
});
afterAll(() => db.$disconnect());
async function order(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number, lang = 'ru') {
const s = await startClient(ctx, tgUser(tg, lang));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
}
const setting = (key: string, value: unknown) => db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
const history = async (orderId: string) => (await db.orderStatusHistory.findMany({ where: { orderId }, orderBy: { createdAt: 'asc' } })).map((h) => h.event).filter(Boolean);
describe('автораспределение заказов', () => {
it('новый заказ сразу предлагается лучшему: язык клиента и район; система — автор предложения', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await order(ctx, 801);
const res = await autoAssign(ctx, o.id);
expect(res).toMatchObject({ result: 'offered', target: 'Maria' });
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
expect(offer).toMatchObject({ providerId: fx.maria.id, status: 'OFFERED', createdById: null });
expect(events).toContainEqual({ type: 'offer.created', offerId: offer.id });
expect(await history(o.id)).toContain('autoOffered');
// пока предложение действует — не трогаем
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'skipped' });
});
it('отказ — следующему; кому уже предлагали, повторно не предлагаем', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 802);
await autoAssign(ctx, o.id);
const first = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
await respondToOffer(ctx, { offerId: first.id, providerId: fx.maria.id, accept: false });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Ani' });
const second = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED' } });
await respondToOffer(ctx, { offerId: second.id, providerId: fx.ani.id, accept: false });
// больше свободных частных нет — компания включается и получает заказ
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: true } });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Chisto' });
const offers = await db.orderOffer.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id, fx.company.id]);
});
it('снятый с заказа исполнитель не получает его снова автоматически', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 803);
await autoAssign(ctx, o.id);
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: true });
const admin = await db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
await unassignOrder(ctx, { orderId: o.id, adminId: admin.id });
const again = await autoAssign(ctx, o.id);
expect(again.result).toBe('offered');
expect(again).not.toMatchObject({ target: 'Maria' });
});
it('лимит попыток: дальше вручную, команде сообщаем один раз', async () => {
const { ctx } = makeCtx(NOW);
await setting('assign.maxAutoOffers', 1);
const o = await order(ctx, 804);
await autoAssign(ctx, o.id);
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: false });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'gaveUp', first: true });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'gaveUp', first: false });
expect((await history(o.id)).filter((e) => e === 'autoGaveUp')).toHaveLength(1);
});
it('свободных нет — пометка один раз; повторная попытка воркера находит освободившегося', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 805);
// все недоступны: исполнители отключены
await db.provider.updateMany({ data: { active: false } });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'none', first: true });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'none', first: false });
await db.provider.update({ where: { id: fx.ani.id }, data: { active: true } });
const retried = await retryAutoAssign(ctx);
expect(retried).toEqual([{ orderId: o.id, res: expect.objectContaining({ result: 'offered', target: 'Ani' }) }]);
});
it('выключено в настройках — заказы ждут диспетчера', async () => {
const { ctx } = makeCtx(NOW);
await setting('assign.auto', false);
const o = await order(ctx, 806);
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'off' });
expect(await retryAutoAssign(ctx)).toEqual([]);
expect(await db.orderOffer.count({ where: { orderId: o.id } })).toBe(0);
});
});
+1 -1
View File
@@ -302,7 +302,7 @@ describe('исполнитель ведёт заказ (PRO-3, PRO-4)', () => {
expect(done.status).toBe('DONE');
expect(done.completedAt).not.toBeNull();
const hist = await db.orderStatusHistory.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'offered', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
});
it('чужой исполнитель не может двигать заказ', async () => {