Bariki cashback, order edit, and current work in progress
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -7,6 +7,8 @@ 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;
|
||||
|
||||
@@ -20,6 +22,7 @@ function interval(o: { scheduledAt: Date; durationMinutes: number }) {
|
||||
*/
|
||||
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);
|
||||
@@ -27,7 +30,7 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
|
||||
|
||||
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.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]));
|
||||
@@ -36,26 +39,37 @@ export async function assignmentCandidates(ctx: Ctx, orderId: 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) => ({
|
||||
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,
|
||||
}));
|
||||
.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 free = staff.filter((p) => freeOf(p.id));
|
||||
// партнёр «доступен», если у него есть сотрудник, свободный по графику, с перерывом на дорогу и в лимите нагрузки
|
||||
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,
|
||||
@@ -74,13 +88,16 @@ export async function assignmentCandidates(ctx: Ctx, orderId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const busy = candidates.filter((c) => !c.free);
|
||||
return { ranked, busy };
|
||||
}).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) {
|
||||
@@ -136,9 +153,9 @@ export async function offerOrder(
|
||||
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 },
|
||||
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 },
|
||||
@@ -147,6 +164,7 @@ export async function offerOrder(
|
||||
return offer;
|
||||
});
|
||||
for (const e of events) await ctx.notify.emit(e);
|
||||
if (input.target.kind === 'company') await companyAutoAssign(ctx, input.orderId);
|
||||
return offer;
|
||||
}
|
||||
|
||||
@@ -182,6 +200,11 @@ export async function respondToOffer(ctx: Ctx, input: { offerId: string; provide
|
||||
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> {
|
||||
@@ -190,8 +213,9 @@ async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED
|
||||
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' } });
|
||||
// сотрудник отказался — заказ снова у менеджера партнёра (срок на назначение — заново)
|
||||
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 } });
|
||||
}
|
||||
@@ -211,18 +235,21 @@ async function declineOffer(ctx: Ctx, tx: Tx, offerId: string, status: 'DECLINED
|
||||
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 },
|
||||
select: { id: true, orderId: true, companyId: true },
|
||||
});
|
||||
let n = 0;
|
||||
for (const { id } of expired) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -249,7 +276,9 @@ export async function dispatcherAssign(ctx: Ctx, input: { orderId: string; compa
|
||||
(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() } });
|
||||
// предложение партнёру принято; ожидающее предложение другому сотруднику (автораспределение) — отозвано
|
||||
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,
|
||||
@@ -334,9 +363,9 @@ export async function autoAssign(ctx: Ctx, orderId: string, opts: { force?: bool
|
||||
}
|
||||
|
||||
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)));
|
||||
// партнёрам заказы назначает оператор (видит, что выгоднее); система предлагает только частным исполнителям
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user