Monorepo skeleton, Prisma schema, core domain and services with tests

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-17 19:45:51 +04:00
commit 45d7512d2b
64 files changed
+9483

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
import { averageRating, getFlow, isProviderFree, rankCandidates, startOfYerevanDay, type Candidate, type Locale } from '@barev/core';
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) */
export async function offerOrder(
ctx: Ctx,
input: { orderId: string; adminId: string; 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.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,
},
});
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: 'ADMIN', 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<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);
}
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) {
// сотрудник отказался — заказ снова у диспетчера компании
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<number> {
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);
}