Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
401 lines
23 KiB
TypeScript
401 lines
23 KiB
TypeScript
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';
|
|
import { orderPayout } from './payouts.ts';
|
|
import { companyAutoAssign, companyAvailability, companySplit, expireCompanyOffers } from './servicePartners.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 settings = await getSettings(ctx.db);
|
|
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, payoutPreset: 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 money = new Map<string, { cost: number; margin: number; partnerFree?: number; partnerStaff?: number }>();
|
|
|
|
const candidates: Candidate[] = providers
|
|
.filter((p) => !p.companyId)
|
|
.map((p) => {
|
|
const cost = orderPayout(order, { ...p, company: null }, { fotPct: settings['partner.fotPct'] });
|
|
money.set(`provider:${p.id}`, { cost, margin: order.totalPrice - cost });
|
|
return {
|
|
kind: 'provider' as const,
|
|
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 avail = await companyAvailability(ctx.db, c.id, order.id, settings);
|
|
const freeIds = new Set(avail.rows.filter((r) => r.check === 'ok' || r.check === 'notLinked').map((r) => r.id));
|
|
const free = staff.filter((p) => freeIds.has(p.id));
|
|
const ratings = staff.filter((p) => p.ratingCount > 0);
|
|
const split = companySplit(c, order.totalPrice, settings);
|
|
money.set(`company:${c.id}`, { cost: split.cost, margin: split.margin, partnerFree: free.length, partnerStaff: staff.length });
|
|
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 withMoney = <T extends Candidate>(c: T) => ({ ...c, ...(money.get(`${c.kind}:${c.id}`) ?? { cost: 0, margin: order.totalPrice }) });
|
|
const ranked = rankCandidates(candidates, {
|
|
locale: order.locale as Locale,
|
|
districtId: order.districtId,
|
|
isOnline: order.isOnline,
|
|
}).map(withMoney);
|
|
const busy = candidates.filter((c) => !c.free).map(withMoney);
|
|
// самый выгодный для нас из доступных
|
|
const best = ranked.reduce<(typeof ranked)[number] | null>((b, c) => (!b || c.margin > b.margin ? c : b), null);
|
|
return { ranked, busy, bestId: best ? `${best.kind}:${best.id}` : null, paid: order.totalPrice };
|
|
}
|
|
|
|
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 } });
|
|
// заказ «Назначен на партнёра», пока менеджер не выберет сотрудника; не успел за N минут — заказ вернётся оператору
|
|
const offer = await tx.orderOffer.create({
|
|
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId, expiresAt: new Date(ctx.now().getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) },
|
|
});
|
|
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);
|
|
if (input.target.kind === 'company') await companyAutoAssign(ctx, input.orderId);
|
|
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<DomainEvent | null> => {
|
|
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);
|
|
// сотрудник партнёра отказался — автораспределение партнёра предложит следующему
|
|
if (!input.accept) {
|
|
const offer = await ctx.db.orderOffer.findUnique({ where: { id: input.offerId } });
|
|
if (offer?.companyId) await companyAutoAssign(ctx, offer.orderId);
|
|
}
|
|
}
|
|
|
|
async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED' | 'EXPIRED', providerId?: string): Promise<DomainEvent> {
|
|
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) {
|
|
// сотрудник отказался — заказ снова у менеджера партнёра (срок на назначение — заново)
|
|
const settings = await getSettings(tx);
|
|
await tx.orderOffer.create({ data: { orderId: offer.orderId, companyId, status: 'OFFERED', expiresAt: new Date(ctx.now().getTime() + settings['partner.acceptTimeoutMinutes'] * 60_000) } });
|
|
} 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<number> {
|
|
const expired = await ctx.db.orderOffer.findMany({
|
|
where: { status: 'OFFERED', expiresAt: { lt: ctx.now() }, providerId: { not: null } },
|
|
select: { id: true, orderId: true, companyId: true },
|
|
});
|
|
let n = 0;
|
|
for (const { id, orderId, companyId } of expired) {
|
|
try {
|
|
const event = await ctx.db.$transaction((tx) => declineOffer(ctx, tx, id, 'EXPIRED'));
|
|
await ctx.notify.emit(event);
|
|
n++;
|
|
if (companyId) await companyAutoAssign(ctx, orderId);
|
|
} catch (e) {
|
|
if (!(e instanceof DomainError)) throw e;
|
|
}
|
|
}
|
|
// партнёр не назначил сотрудника вовремя — заказ возвращается оператору
|
|
await expireCompanyOffers(ctx);
|
|
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', providerId: null }, data: { status: 'ACCEPTED', respondedAt: ctx.now() } });
|
|
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } }, data: { status: 'REVOKED', 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<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 { ranked } = await assignmentCandidates(ctx, order.id);
|
|
// партнёрам заказы назначает оператор (видит, что выгоднее); система предлагает только частным исполнителям
|
|
const pick = ranked.find((c) => c.kind === 'provider' && !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;
|
|
}
|