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
@@ -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");
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
// ───────────── Админка ─────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ավելացնել նախադրվածք",
|
||||
|
||||
@@ -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": "Добавить пресет",
|
||||
|
||||
@@ -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": "添加方案",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user