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

+23
View File
@@ -0,0 +1,23 @@
{
"name": "@barev/services",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/db": "workspace:*"
},
"devDependencies": {
"dotenv": "^17.0.0",
"vitest": "^4.1.0",
"pg": "^8.16.0",
"@types/pg": "^8.15.0"
}
}
+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);
}
+69
View File
@@ -0,0 +1,69 @@
import { daySlots, fromYerevan, parseDateKey, type Interval, type ProviderAvailability, type ProviderRole, type Slot, type SlotConfig } from '@barev/core';
import type { Db, Tx } from '@barev/db';
const ACTIVE_STATUSES_EXCLUDED = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
/** Исполнители роли с их графиком и занятостью на интервал [from, to) */
export async function loadAvailability(
db: Db | Tx,
role: ProviderRole,
from: Date,
to: Date,
opts: { excludeOrderId?: string; companyId?: string } = {},
): Promise<ProviderAvailability[]> {
const providers = await db.provider.findMany({
where: { role, active: true, ...(opts.companyId ? { companyId: opts.companyId } : {}) },
include: {
schedule: true,
orders: {
where: {
status: { notIn: [...ACTIVE_STATUSES_EXCLUDED] },
scheduledAt: { lt: to },
...(opts.excludeOrderId ? { id: { not: opts.excludeOrderId } } : {}),
},
select: { scheduledAt: true, durationMinutes: true },
},
},
});
return providers.map((p) => ({
id: p.id,
schedule: p.schedule,
busy: p.orders
.map((o) => ({ start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) }))
.filter((i) => i.end > from),
}));
}
/**
* Неназначенные заказы роли (без исполнителя), занимающие мощность.
* Заказ, переданный компании, тоже ещё без исполнителя — он учитывается здесь.
*/
export async function loadUnassigned(db: Db | Tx, role: ProviderRole, from: Date, to: Date, excludeOrderId?: string): Promise<Interval[]> {
const orders = await db.order.findMany({
where: {
providerId: null,
status: { notIn: [...ACTIVE_STATUSES_EXCLUDED] },
service: { providerRole: role },
scheduledAt: { lt: to, gte: new Date(from.getTime() - 24 * 3600_000) },
...(excludeOrderId ? { id: { not: excludeOrderId } } : {}),
},
select: { scheduledAt: true, durationMinutes: true },
});
return orders
.map((o) => ({ start: o.scheduledAt, end: new Date(o.scheduledAt.getTime() + o.durationMinutes * 60_000) }))
.filter((i) => i.end > from);
}
export async function slotsForDay(
db: Db | Tx,
input: { role: ProviderRole; config: SlotConfig; durationMinutes: number; dateKey: string; now: Date; excludeOrderId?: string },
): Promise<Slot[]> {
const { year, month, day } = parseDateKey(input.dateKey);
const from = fromYerevan(year, month, day);
const to = fromYerevan(year, month, day + 1);
const [providers, unassigned] = await Promise.all([
loadAvailability(db, input.role, from, to, { excludeOrderId: input.excludeOrderId }),
loadUnassigned(db, input.role, from, to, input.excludeOrderId),
]);
return daySlots({ ...input, providers, unassigned });
}
+72
View File
@@ -0,0 +1,72 @@
import {
fromPrice,
paramsSchemaSchema,
pricingSchema,
slotConfigSchema,
type CategoryCode,
type ParamsSchema,
type Pricing,
type SlotConfig,
} from '@barev/core';
import type { Db, Service, Tx } from '@barev/db';
import { DomainError } from './context.ts';
export interface ParsedService {
id: string;
code: string;
category: CategoryCode;
categoryDiscountPct: number;
flow: Service['flow'];
providerRole: Service['providerRole'];
pricing: Pricing;
paramsSchema: ParamsSchema;
slotConfig: SlotConfig;
lateCancelFeePct: number;
isOnline: boolean;
active: boolean;
fromPrice: number;
}
type ServiceWithCategory = Service & { category: { code: CategoryCode; barevPlusDiscountPct: number } };
export function parseService(s: ServiceWithCategory): ParsedService {
const pricing = pricingSchema.parse(s.pricing);
return {
id: s.id,
code: s.code,
category: s.category.code,
categoryDiscountPct: s.category.barevPlusDiscountPct,
flow: s.flow,
providerRole: s.providerRole,
pricing,
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
slotConfig: slotConfigSchema.parse(s.slotConfig),
lateCancelFeePct: s.lateCancelFeePct,
isOnline: s.isOnline,
active: s.active,
fromPrice: fromPrice(pricing),
};
}
/** Каталог для лендинга и Mini App: все направления, у услуг — «от N ֏» и признак доступности */
export async function listCatalog(db: Db) {
const categories = await db.category.findMany({
orderBy: { sortOrder: 'asc' },
include: { services: { orderBy: { sortOrder: 'asc' }, include: { category: true } } },
});
return categories.map((c) => ({
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
services: c.services.map((s) => {
const p = parseService(s);
return { code: p.code, flow: p.flow, fromPrice: p.fromPrice, active: p.active, isOnline: p.isOnline };
}),
}));
}
export async function getServiceByCode(db: Db | Tx, code: string, opts: { activeOnly?: boolean } = {}): Promise<ParsedService> {
const s = await db.service.findUnique({ where: { code }, include: { category: true } });
if (!s) throw new DomainError('NOT_FOUND');
if (opts.activeOnly && !s.active) throw new DomainError('SERVICE_UNAVAILABLE');
return parseService(s);
}
+205
View File
@@ -0,0 +1,205 @@
import { randomBytes } from 'node:crypto';
import {
activeSubscription,
decideGift,
decodeStartPayload,
isLocale,
membershipLevel,
normalizeAgencyCode,
type Locale,
type MembershipLevel,
} from '@barev/core';
import type { Client, Db, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
export interface TelegramUserInput {
id: number | bigint;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
}
/** Код клубной карты: 8 символов без похожих букв */
const CARD_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export function generateCode(length = 8): string {
const bytes = randomBytes(length);
return Array.from(bytes, (b) => CARD_ALPHABET[b % CARD_ALPHABET.length]).join('');
}
/** Язык из Telegram, если он один из наших */
export function localeFromTelegram(code?: string): Locale | null {
const base = code?.split('-')[0]?.toLowerCase();
return isLocale(base) ? base : null;
}
export async function upsertUser(db: Db | Tx, tg: TelegramUserInput, locale?: Locale) {
const telegramId = BigInt(tg.id);
return db.user.upsert({
where: { telegramId },
create: {
telegramId,
firstName: tg.first_name,
lastName: tg.last_name,
username: tg.username,
locale: locale ?? localeFromTelegram(tg.language_code) ?? 'ru',
},
update: { firstName: tg.first_name, lastName: tg.last_name, username: tg.username },
});
}
export interface StartResult {
clientId: string;
isNew: boolean;
/** язык не пришёл в payload и не был выбран раньше — показать выбор (ONB-2) */
needsLocale: boolean;
needsConsent: boolean;
locale: Locale;
agencyName: string | null;
/** подарок активирован в этом /start */
gift: { endsAt: Date } | null;
}
/**
* ONB-1: /start с payload. Создаёт клиента, сохраняет источник (агентство, UTM),
* язык. Агентство закрепляется только при первом контакте — дальше не перезаписывается.
*/
export async function startClient(ctx: Ctx, tg: TelegramUserInput, rawPayload?: string): Promise<StartResult> {
const { db } = ctx;
const payload = decodeStartPayload(rawPayload);
let locale = payload.locale;
let agencyCode = payload.agency;
let utm: unknown = undefined;
if (payload.token) {
const token = await db.startToken.findUnique({ where: { id: payload.token } });
if (token) {
locale ??= token.locale ?? undefined;
agencyCode ??= token.agency ?? undefined;
utm = token.utm ?? undefined;
}
}
const agency = agencyCode ? await db.agency.findUnique({ where: { code: agencyCode } }) : null;
const usableAgency = agency?.active ? agency : null;
const existing = await db.user.findUnique({ where: { telegramId: BigInt(tg.id) }, include: { client: true } });
const user = await upsertUser(db, tg, locale);
if (locale && user.locale !== locale) await db.user.update({ where: { id: user.id }, data: { locale } });
let client = existing?.client ?? null;
const isNew = !client;
if (!client) {
client = await db.client.create({
data: {
userId: user.id,
cardCode: generateCode(),
source: usableAgency ? 'deeplink' : 'organic',
agencyId: usableAgency?.id,
agencyAttachedAt: usableAgency ? ctx.now() : undefined,
utm: utm ?? undefined,
},
});
} else if (!client.agencyId && usableAgency) {
client = await db.client.update({
where: { id: client.id },
data: { agencyId: usableAgency.id, agencyAttachedAt: ctx.now(), source: 'deeplink' },
});
}
// подарок выдаём сразу, если согласие уже есть; иначе — после согласия (giveConsent)
const gift = client.consentAt ? await activateAgencyGift(ctx, client.id) : null;
const finalAgency = client.agencyId ? await db.agency.findUnique({ where: { id: client.agencyId } }) : null;
return {
clientId: client.id,
isNew,
needsLocale: !locale && isNew,
needsConsent: !client.consentAt,
locale: locale ?? user.locale,
agencyName: finalAgency?.name ?? null,
gift,
};
}
export async function setLocale(db: Db, telegramId: bigint, locale: Locale) {
await db.user.update({ where: { telegramId }, data: { locale } });
}
/** ONB-3: согласие на обработку ПДн. После него — подарок агентства, если положен. */
export async function giveConsent(ctx: Ctx, clientId: string) {
const settings = await getSettings(ctx.db);
await ctx.db.client.update({
where: { id: clientId },
data: { consentAt: ctx.now(), consentVersion: settings['consent.version'] },
});
return activateAgencyGift(ctx, clientId);
}
/**
* ONB-4 / SUB-3: подарочный месяц Barev+ клиенту агентства, один раз.
* Возвращает дату окончания или null, если не положен.
*/
export async function activateAgencyGift(ctx: Ctx, clientId: string): Promise<{ endsAt: Date } | null> {
const { db } = ctx;
const settings = await getSettings(db);
const result = await db.$transaction(async (tx) => {
// блокируем строку клиента, чтобы параллельные /start не выдали подарок дважды
await tx.$queryRaw`SELECT id FROM "Client" WHERE id = ${clientId} FOR UPDATE`;
const client = await tx.client.findUniqueOrThrow({
where: { id: clientId },
include: { agency: true, subscriptions: true },
});
if (!client.agency || !client.consentAt) return null;
const now = ctx.now();
const current = activeSubscription(client.subscriptions, now);
const decision = decideGift({
now,
giftUsedAt: client.giftUsedAt,
agencyActive: client.agency.active,
giftDays: settings['barevPlus.giftDays'],
currentEndsAt: current?.endsAt ?? null,
});
if (!decision.ok) return null;
await tx.subscription.create({
data: { clientId, source: 'AGENCY_GIFT', startsAt: decision.startsAt, endsAt: decision.endsAt },
});
await tx.client.update({ where: { id: clientId }, data: { giftUsedAt: now } });
return { endsAt: decision.endsAt };
});
if (result) await ctx.notify.emit({ type: 'gift.activated', clientId, endsAt: result.endsAt.toISOString() });
return result;
}
/** ONB-6: ручной ввод кода агентства, если клиент пришёл без deep-link */
export async function applyAgencyCode(ctx: Ctx, clientId: string, rawCode: string) {
const code = normalizeAgencyCode(rawCode);
const agency = code ? await ctx.db.agency.findUnique({ where: { code } }) : null;
if (!agency || !agency.active) throw new DomainError('AGENCY_CODE_INVALID');
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId } });
if (client.agencyId && client.agencyId !== agency.id) throw new DomainError('AGENCY_ALREADY_SET');
if (!client.agencyId) {
await ctx.db.client.update({
where: { id: clientId },
data: { agencyId: agency.id, agencyAttachedAt: ctx.now(), source: 'manual_code' },
});
}
const gift = await activateAgencyGift(ctx, clientId);
return { agencyName: agency.name, gift };
}
export interface Membership {
level: MembershipLevel;
endsAt: Date | null;
}
export async function getMembership(db: Db | Tx, clientId: string, now: Date): Promise<Membership> {
const subs = await db.subscription.findMany({ where: { clientId, status: 'ACTIVE', endsAt: { gt: now } } });
const active = activeSubscription(subs, now);
return { level: membershipLevel(subs, now), endsAt: active?.endsAt ?? null };
}
export async function getClientByTelegramId(db: Db, telegramId: bigint): Promise<(Client & { user: { locale: Locale; firstName: string | null } }) | null> {
const user = await db.user.findUnique({ where: { telegramId }, include: { client: true } });
if (!user?.client) return null;
return { ...user.client, user: { locale: user.locale, firstName: user.firstName } };
}
+62
View File
@@ -0,0 +1,62 @@
import type { Db } from '@barev/db';
import type { OrderStatus } from '@barev/core';
/** Доменные события. Воркер превращает их в уведомления на языке получателя. */
export type DomainEvent =
| { type: 'order.created'; orderId: string }
| { type: 'order.status'; orderId: string; from: OrderStatus; to: OrderStatus }
/** событие без смены статуса, например «Я на месте» */
| { type: 'order.event'; orderId: string; code: string }
| { type: 'order.rescheduled'; orderId: string }
| { type: 'order.unassigned'; orderId: string; providerId: string }
| { type: 'offer.created'; offerId: string }
| { type: 'offer.declined'; offerId: string }
| { type: 'offer.expired'; offerId: string }
| { type: 'company.order'; orderId: string; companyId: string }
| { type: 'order.reminder'; orderId: string; hours: 24 | 1 }
| { type: 'gift.activated'; clientId: string; endsAt: string };
export interface Notifier {
emit(event: DomainEvent): Promise<void> | void;
}
export interface Ctx {
db: Db;
notify: Notifier;
now: () => Date;
}
export const silentNotifier: Notifier = { emit: () => {} };
/** Ошибка бизнес-логики. code переводится на фронте как errors.<code>. */
export class DomainError extends Error {
constructor(
public code: DomainErrorCode,
public details?: Record<string, unknown>,
) {
super(code);
this.name = 'DomainError';
}
}
export type DomainErrorCode =
| 'NOT_FOUND'
| 'FORBIDDEN'
| 'CONSENT_REQUIRED'
| 'SERVICE_UNAVAILABLE'
| 'INVALID_PARAMS'
| 'SLOT_UNAVAILABLE'
| 'ADDRESS_REQUIRED'
| 'TRANSITION_NOT_ALLOWED'
| 'CHECKLIST_INCOMPLETE'
| 'PHOTO_REQUIRED'
| 'ESTIMATE_NOT_ACCEPTED'
| 'CANCEL_VIA_OPERATOR'
| 'RESCHEDULE_VIA_OPERATOR'
| 'AGENCY_CODE_INVALID'
| 'AGENCY_ALREADY_SET'
| 'OFFER_NOT_ACTIVE'
| 'PROVIDER_BUSY'
| 'ALREADY_ASSIGNED'
| 'INVITE_INVALID'
| 'NOT_PAID_STATE';
+10
View File
@@ -0,0 +1,10 @@
export * from './context.ts';
export * from './settings.ts';
export * from './catalog.ts';
export * from './clients.ts';
export * from './availability.ts';
export * from './orders.ts';
export * from './assignment.ts';
export * from './providers.ts';
export * from './queries.ts';
export * from './reminders.ts';
+410
View File
@@ -0,0 +1,410 @@
import {
agencyCommission,
cancelPolicy,
canRescheduleSelf,
checkTransition,
discountFor,
findActionTransition,
findEvent,
getFlow,
isAgencyAttributionActive,
isProviderFree,
isTerminal,
ManualPaymentProvider,
quote,
validateParams,
yerevanDateKey,
yerevanMonthKey,
type ActorType,
type GuardContext,
type OrderStatus,
} from '@barev/core';
import type { Db, Prisma, Tx } from '@barev/db';
import { loadAvailability, slotsForDay } from './availability.ts';
import { getServiceByCode, type ParsedService } from './catalog.ts';
import { getMembership } from './clients.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, rawParams);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const membership = await getMembership(ctx.db, clientId, ctx.now());
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct));
return { service, params: params.values, quote: q, membership };
}
export async function availableSlots(ctx: Ctx, input: { serviceCode: string; params: Record<string, unknown>; dateKey: string; excludeOrderId?: string }) {
const service = await getServiceByCode(ctx.db, input.serviceCode, { activeOnly: true });
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const { durationMinutes } = quote(service.pricing, params.values);
return slotsForDay(ctx.db, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: input.dateKey,
now: ctx.now(),
excludeOrderId: input.excludeOrderId,
});
}
async function assertSlotFree(tx: Tx, service: ParsedService, scheduledAt: Date, durationMinutes: number, now: Date, excludeOrderId?: string) {
const slots = await slotsForDay(tx, {
role: service.providerRole,
config: service.slotConfig,
durationMinutes,
dateKey: yerevanDateKey(scheduledAt),
now,
excludeOrderId,
});
if (!slots.some((s) => s.start.getTime() === scheduledAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
}
export interface CreateOrderInput {
clientId: string;
serviceCode: string;
params: Record<string, unknown>;
scheduledAt: Date;
addressText?: string;
districtId?: string;
lat?: number;
lng?: number;
note?: string;
paymentMethodId?: string;
repeatOfId?: string;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
const { service, params, quote: q } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
const order = await ctx.db.$transaction(
async (tx) => {
// сериализуем создание заказов одной роли, чтобы не продать слот дважды
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({
data: {
clientId: input.clientId,
serviceId: service.id,
params,
scheduledAt: input.scheduledAt,
durationMinutes: q.durationMinutes,
isOnline: service.isOnline,
addressText: input.addressText?.trim(),
districtId: input.districtId,
lat: input.lat,
lng: input.lng,
note: input.note?.trim() || undefined,
locale: client.user.locale,
basePrice: q.basePrice,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
status: flow.initial,
paymentMethodId: input.paymentMethodId,
repeatOfId: input.repeatOfId,
checklist: {
create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })),
},
history: { create: { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
},
{ isolationLevel: 'ReadCommitted' },
);
await ctx.notify.emit({ type: 'order.created', orderId: order.id });
return order;
}
/** Контекст guard'ов для перехода — из чек-листа, сметы, пакета уроков */
export async function guardContext(db: Db | Tx, orderId: string): Promise<GuardContext> {
const [items, estimate, lessons] = await Promise.all([
db.orderChecklistItem.findMany({ where: { orderId } }),
db.estimate.findFirst({ where: { orderId }, orderBy: { createdAt: 'desc' } }),
db.lessonPackage.findUnique({ where: { orderId } }),
]);
return {
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId)),
estimateAccepted: estimate?.status === 'ACCEPTED',
lessonsTotal: lessons?.total,
lessonsCompleted: lessons?.completed,
};
}
function transitionError(res: Exclude<ReturnType<typeof checkTransition>, { ok: true }>): DomainError {
if (res.code === 'GUARD_FAILED') {
if (res.guard === 'checklistComplete') return new DomainError('CHECKLIST_INCOMPLETE');
if (res.guard === 'estimateAccepted') return new DomainError('ESTIMATE_NOT_ACCEPTED');
}
return new DomainError('TRANSITION_NOT_ALLOWED', { reason: res.code, guard: 'guard' in res ? res.guard : undefined });
}
export interface TransitionInput {
orderId: string;
to: OrderStatus;
actor: ActorType;
actorId?: string;
comment?: string;
event?: string;
/** дополнительные поля заказа, которые меняются вместе со статусом */
data?: Prisma.OrderUncheckedUpdateInput;
/** ожидаемый текущий статус — защита от гонок двойного нажатия */
expectFrom?: OrderStatus;
}
/**
* Единственная точка смены статуса. Проверяет state machine на бэкенде,
* пишет историю (кто, когда, комментарий) и порождает событие для уведомления.
*/
export async function transitionOrder(ctx: Ctx, input: TransitionInput, txArg?: Tx) {
const run = async (tx: Tx) => {
await tx.$queryRaw`SELECT id FROM "Order" WHERE id = ${input.orderId} FOR UPDATE`;
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.expectFrom && order.status !== input.expectFrom) throw new DomainError('TRANSITION_NOT_ALLOWED', { reason: 'STALE' });
const res = checkTransition(order.service.flow, order.status, input.to, input.actor, await guardContext(tx, order.id));
if (!res.ok) throw transitionError(res);
const flow = getFlow(order.service.flow);
const updated = await tx.order.update({
where: { id: order.id },
data: {
...input.data,
status: input.to,
completedAt: flow.done.includes(input.to) ? ctx.now() : undefined,
cancelledAt: input.to === 'CANCELLED' ? ctx.now() : undefined,
},
});
await tx.orderStatusHistory.create({
data: {
orderId: order.id,
fromStatus: order.status,
toStatus: input.to,
event: input.event,
actorType: input.actor,
actorId: input.actorId,
comment: input.comment,
},
});
return { order: updated, from: order.status };
};
const result = txArg ? await run(txArg) : await ctx.db.$transaction(run);
const event: DomainEvent = { type: 'order.status', orderId: input.orderId, from: result.from, to: input.to };
if (txArg) return { ...result, event };
await ctx.notify.emit(event);
return { ...result, event };
}
/** Событие без смены статуса («Я на месте») — пишется в историю, клиент получает уведомление */
export async function recordEvent(ctx: Ctx, input: { orderId: string; code: string; actor: ActorType; actorId?: string }) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const ev = findEvent(order.service.flow, order.status, input.code);
if (!ev || !ev.by.includes(input.actor)) throw new DomainError('TRANSITION_NOT_ALLOWED');
await ctx.db.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.code, actorType: input.actor, actorId: input.actorId },
});
await ctx.notify.emit({ type: 'order.event', orderId: order.id, code: input.code });
}
/** PRO-3: кнопка исполнителя — переход или событие */
export async function providerAction(ctx: Ctx, input: { orderId: string; providerId: string; action: string; comment?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
const t = findActionTransition(order.service.flow, order.status, input.action);
if (t) {
return transitionOrder(ctx, {
orderId: order.id,
to: t.to,
actor: 'PROVIDER',
actorId: input.providerId,
event: input.action,
comment: input.comment,
expectFrom: order.status,
});
}
await recordEvent(ctx, { orderId: order.id, code: input.action, actor: 'PROVIDER', actorId: input.providerId });
return null;
}
/** PRO-4: отметка пункта чек-листа. Пункт с обязательным фото не отмечается без файла. */
export async function setChecklistItem(
ctx: Ctx,
input: { orderId: string; providerId: string; code: string; checked: boolean; fileId?: string },
) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.providerId !== input.providerId) throw new DomainError('NOT_FOUND');
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) throw new DomainError('TRANSITION_NOT_ALLOWED');
const item = await ctx.db.orderChecklistItem.findUnique({ where: { orderId_code: { orderId: order.id, code: input.code } } });
if (!item) throw new DomainError('NOT_FOUND');
const fileId = input.fileId ?? item.fileId;
if (input.checked && item.requiresPhoto && !fileId) throw new DomainError('PHOTO_REQUIRED');
if (input.fileId) {
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } });
if (!file || file.orderId !== order.id) throw new DomainError('NOT_FOUND');
}
return ctx.db.orderChecklistItem.update({
where: { id: item.id },
data: { checkedAt: input.checked ? ctx.now() : null, fileId },
});
}
/** ORD-6: отмена клиентом. Внутри порога — с удержанием; после начала — только оператор. */
export async function cancelByClient(ctx: Ctx, input: { orderId: string; clientId: string; reason?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const policy = cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
});
if (policy.kind === 'operator_only') throw new DomainError('CANCEL_VIA_OPERATOR');
const result = await ctx.db.$transaction(async (tx) => {
const r = await transitionOrder(
ctx,
{
orderId: order.id,
to: 'CANCELLED',
actor: 'CLIENT',
actorId: input.clientId,
comment: input.reason,
expectFrom: order.status,
data: { cancelFee: policy.fee, cancelReason: input.reason },
},
tx,
);
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED' }, data: { status: 'REVOKED' } });
return r;
});
await ctx.notify.emit(result.event);
return { order: result.order, fee: policy.fee };
}
/** Что увидит клиент, нажав «Отменить»: бесплатно / с удержанием / через оператора */
export async function cancelPreview(ctx: Ctx, orderId: string, clientId: string) {
const order = await ctx.db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
const cancellable = flow.clientCancellable.includes(order.status);
return {
cancellable,
policy: cancelPolicy({
scheduledAt: order.scheduledAt,
now: ctx.now(),
freeHours: settings['cancel.freeHours'],
lateFeePct: order.service.lateCancelFeePct,
totalPrice: order.totalPrice,
}),
canReschedule: cancellable && canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours']),
freeHours: settings['cancel.freeHours'],
};
}
/**
* ORD-6: перенос клиентом — только вне порога. Если назначенный исполнитель занят в новое время,
* заказ возвращается в «Новые» и исполнитель получает уведомление.
*/
export async function rescheduleByClient(ctx: Ctx, input: { orderId: string; clientId: string; scheduledAt: Date }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: { include: { category: true } } } });
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
const settings = await getSettings(ctx.db);
const flow = getFlow(order.service.flow);
if (!flow.clientCancellable.includes(order.status) || !canRescheduleSelf(order.scheduledAt, ctx.now(), settings['cancel.freeHours'])) {
throw new DomainError('RESCHEDULE_VIA_OPERATOR');
}
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}))`;
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.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: 'CLIENT', actorId: input.clientId },
});
if (order.providerId && !keepProvider) {
const r = await transitionOrder(
ctx,
{ orderId: order.id, to: flow.initial, actor: 'SYSTEM', event: 'rescheduled', data: { providerId: 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);
}
/** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */
export async function repeatDraft(db: Db, orderId: string, clientId: string) {
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
if (!order || order.clientId !== clientId) throw new DomainError('NOT_FOUND');
return {
serviceCode: order.service.code,
params: order.params as Record<string, unknown>,
addressText: order.addressText,
districtId: order.districtId,
note: order.note,
repeatOfId: order.id,
};
}
/** PAY-1: ручная отметка «оплачено». PAY-3: начисление агентству — только с оплаченных. */
export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: 'ADMIN' | 'PROVIDER'; id: string }; paymentMethodId?: string }) {
const provider = new ManualPaymentProvider();
const settings = await getSettings(ctx.db);
return ctx.db.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { client: { include: { agency: true } } } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.by.type === 'PROVIDER' && order.providerId !== input.by.id) throw new DomainError('NOT_FOUND');
if (order.paymentStatus === 'PAID' || order.status === 'CANCELLED') throw new DomainError('NOT_PAID_STATE');
const res = await provider.confirmPayment(
{ orderId: order.id, amount: order.totalPrice, currency: 'AMD', description: `#${order.number}` },
{ markedBy: input.by.id },
);
const now = ctx.now();
await tx.payment.create({
data: { orderId: order.id, provider: res.provider, amount: order.totalPrice, status: 'PAID', markedById: input.by.id },
});
const updated = await tx.order.update({
where: { id: order.id },
data: { paymentStatus: 'PAID', paidAt: now, paymentMethodId: input.paymentMethodId ?? order.paymentMethodId },
});
const agency = order.client.agency;
if (agency && isAgencyAttributionActive(order.client.agencyAttachedAt, order.createdAt, settings['agency.attributionMonths'])) {
await tx.agencyCommission.create({
data: {
agencyId: agency.id,
orderId: order.id,
pct: agency.commissionPct,
amount: agencyCommission(order.totalPrice, agency.commissionPct),
period: yerevanMonthKey(now),
},
});
}
return updated;
});
}
export function isOrderActive(flowCode: Parameters<typeof isTerminal>[0], status: OrderStatus) {
return !isTerminal(flowCode, status);
}
+197
View File
@@ -0,0 +1,197 @@
import { averageRating, payoutFor, providerActions, startOfYerevanDay, getFlow, type ServiceFlow } from '@barev/core';
import type { Db } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { upsertUser, type TelegramUserInput } from './clients.ts';
const DAY_MS = 24 * 3600_000;
/** Исполнитель или диспетчер открывает бота по инвайт-коду из админки */
export async function linkByInvite(ctx: Ctx, tg: TelegramUserInput, code: string) {
const user = await upsertUser(ctx.db, tg);
const provider = await ctx.db.provider.findUnique({ where: { inviteCode: code } });
if (provider) {
if (provider.userId && provider.userId !== user.id) throw new DomainError('INVITE_INVALID');
await ctx.db.provider.update({ where: { id: provider.id }, data: { userId: user.id } });
return { kind: 'provider' as const, name: provider.name };
}
const company = await ctx.db.providerCompany.findUnique({ where: { inviteCode: code } });
if (company) {
await ctx.db.companyDispatcher.upsert({
where: { companyId_userId: { companyId: company.id, userId: user.id } },
create: { companyId: company.id, userId: user.id },
update: {},
});
return { kind: 'dispatcher' as const, name: company.name };
}
throw new DomainError('INVITE_INVALID');
}
/** Кто этот пользователь в боте исполнителей */
export async function proIdentity(db: Db, telegramId: bigint) {
const user = await db.user.findUnique({
where: { telegramId },
include: {
provider: { include: { company: true } },
dispatcher: { include: { company: true } },
},
});
if (!user) return null;
const provider = user.provider?.active ? user.provider : null;
return {
user,
provider,
companies: user.dispatcher.filter((d) => d.company.active).map((d) => d.company),
};
}
export async function setShift(db: Db, providerId: string, onShift: boolean) {
return db.provider.update({ where: { id: providerId }, data: { onShift } });
}
/** Ставка, по которой считается заработок: у сотрудника компании — ставка компании */
function rateOf(p: { rateType: 'FIXED' | 'PERCENT'; rateValue: number; company: { rateType: 'FIXED' | 'PERCENT'; rateValue: number } | null }) {
return p.company ? { type: p.company.rateType, value: p.company.rateValue } : { type: p.rateType, value: p.rateValue };
}
/** PRO-1/PRO-2: шапка и заказы на сегодня */
export async function proToday(ctx: Ctx, providerId: string) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [orders, offers] = await Promise.all([
ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: dayStart, lt: dayEnd }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, checklist: { orderBy: { sortOrder: 'asc' } }, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.orderOffer.findMany({
where: { providerId, status: 'OFFERED', OR: [{ expiresAt: null }, { expiresAt: { gt: ctx.now() } }] },
include: { order: { include: { service: true, district: true, client: { include: { user: true } } } } },
orderBy: { createdAt: 'asc' },
}),
]);
const upcoming = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: dayEnd }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
take: 20,
});
const rate = rateOf(provider);
const doneToday = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
const current =
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status) && o.status !== getFlow(o.service.flow as ServiceFlow).assigned) ??
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status)) ??
null;
return {
provider: {
id: provider.id,
name: provider.name,
role: provider.role,
onShift: provider.onShift,
rating: averageRating(provider.ratingSum, provider.ratingCount),
companyName: provider.company?.name ?? null,
},
earningsToday: doneToday.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
currentOrderId: current?.id ?? null,
orders: orders.map((o) => ({ ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status) })),
upcoming: upcoming.map(proOrderView),
offers: offers.map((f) => ({ offerId: f.id, expiresAt: f.expiresAt, order: proOrderView(f.order) })),
};
}
type ProOrderRow = {
id: string;
number: number;
status: import('@barev/db').OrderStatus;
scheduledAt: Date;
durationMinutes: number;
isOnline: boolean;
addressText: string | null;
note: string | null;
locale: import('@barev/db').Locale;
params: unknown;
totalPrice: number;
paymentStatus: import('@barev/db').PaymentStatus;
service: { code: string; flow: import('@barev/db').ServiceFlow };
district: { code: string } | null;
client: { user: { firstName: string | null } };
checklist?: { code: string; requiresPhoto: boolean; checkedAt: Date | null; fileId: string | null }[];
};
/** Исполнитель видит имя, язык, адрес и заметку клиента — но не телефон и не Telegram */
export function proOrderView(o: ProOrderRow) {
return {
id: o.id,
number: o.number,
status: o.status,
flow: o.service.flow,
serviceCode: o.service.code,
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
addressText: o.addressText,
districtCode: o.district?.code ?? null,
note: o.note,
clientLocale: o.locale,
clientName: o.client.user.firstName,
params: o.params as Record<string, unknown>,
totalPrice: o.totalPrice,
paymentStatus: o.paymentStatus,
checklist: o.checklist?.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, checked: !!c.checkedAt, hasPhoto: !!c.fileId })) ?? [],
};
}
export async function proOrder(ctx: Ctx, providerId: string, orderId: string) {
const o = await ctx.db.order.findUnique({
where: { id: orderId },
include: { service: true, district: true, checklist: { orderBy: { sortOrder: 'asc' } }, client: { include: { user: true } } },
});
if (!o || o.providerId !== providerId) throw new DomainError('NOT_FOUND');
const flow = getFlow(o.service.flow as ServiceFlow);
return { ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status), checklistActive: flow.checklistAt.includes(o.status) };
}
/** PRO-6: история и заработок за период */
export async function proHistory(ctx: Ctx, providerId: string, from: Date, to: Date) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const orders = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: from, lt: to } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'desc' },
});
const rate = rateOf(provider);
const done = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
return {
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? payoutFor(o.totalPrice, rate) : 0 })),
totalEarned: done.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
doneCount: done.length,
rate,
};
}
/** Диспетчер: заказы компании без исполнителя и сотрудники */
export async function companyBoard(ctx: Ctx, companyId: string, userId: string) {
const link = await ctx.db.companyDispatcher.findUnique({ where: { companyId_userId: { companyId, userId } } });
if (!link) throw new DomainError('FORBIDDEN');
const [company, pending, assigned, staff] = await Promise.all([
ctx.db.providerCompany.findUniqueOrThrow({ where: { id: companyId } }),
ctx.db.order.findMany({
where: { companyId, providerId: null, status: { not: 'CANCELLED' }, offers: { some: { companyId, status: 'OFFERED' } } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'asc' },
}),
ctx.db.order.findMany({
where: { companyId, providerId: { not: null }, scheduledAt: { gte: startOfYerevanDay(ctx.now()) }, status: { not: 'CANCELLED' } },
include: { service: true, district: true, client: { include: { user: true } }, provider: true },
orderBy: { scheduledAt: 'asc' },
take: 50,
}),
ctx.db.provider.findMany({ where: { companyId, active: true }, orderBy: { name: 'asc' } }),
]);
return {
company: { id: company.id, name: company.name },
pending: pending.map(proOrderView),
assigned: assigned.map((o) => ({ ...proOrderView(o), providerName: o.provider?.name ?? null })),
staff: staff.map((p) => ({ id: p.id, name: p.name, role: p.role, onShift: p.onShift, languages: p.languages, linked: !!p.userId })),
};
}
+216
View File
@@ -0,0 +1,216 @@
import { getFlow, isProviderFree, isTerminal, kanbanColumn, startOfYerevanDay, type KanbanColumn, type ServiceFlow } from '@barev/core';
import type { Db, Prisma } from '@barev/db';
import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx } from './context.ts';
const DAY_MS = 24 * 3600_000;
const clientOrderInclude = {
service: { include: { category: true } },
district: true,
provider: { select: { name: true, ratingSum: true, ratingCount: true } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
paymentMethod: true,
} satisfies Prisma.OrderInclude;
type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>;
/** Клиент видит имя исполнителя, но не его контакты (MSG-1) */
function clientOrderView(o: ClientOrderRow) {
const flow = o.service.flow as ServiceFlow;
return {
id: o.id,
number: o.number,
serviceCode: o.service.code,
category: o.service.category.code,
flow,
status: o.status,
active: !isTerminal(flow, o.status),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
addressText: o.addressText,
districtCode: o.district?.code ?? null,
note: o.note,
params: o.params as Record<string, unknown>,
basePrice: o.basePrice,
discountPct: o.discountPct,
discountAmount: o.discountAmount,
totalPrice: o.totalPrice,
cancelFee: o.cancelFee,
paymentStatus: o.paymentStatus,
paymentMethod: o.paymentMethod ? { code: o.paymentMethod.code, name: o.paymentMethod.name as Record<string, string> } : null,
providerName: o.provider?.name ?? null,
history: o.history.map((h) => ({ from: h.fromStatus, to: h.toStatus, event: h.event, at: h.createdAt })),
photos: o.checklist.filter((c) => c.fileId).map((c) => ({ code: c.code, fileId: c.fileId! })),
/** основной путь сценария — для шкалы прогресса */
steps: getFlow(flow).steps,
};
}
export async function listClientOrders(db: Db, clientId: string) {
const orders = await db.order.findMany({
where: { clientId },
include: clientOrderInclude,
orderBy: { scheduledAt: 'desc' },
take: 100,
});
const views = orders.map(clientOrderView);
return {
active: views.filter((o) => o.active).sort((a, b) => +a.scheduledAt - +b.scheduledAt),
history: views.filter((o) => !o.active),
};
}
export async function clientOrder(db: Db, clientId: string, orderId: string) {
const o = await db.order.findUnique({ where: { id: orderId }, include: clientOrderInclude });
if (!o || o.clientId !== clientId) throw new DomainError('NOT_FOUND');
return clientOrderView(o);
}
// ───────────── Админка ─────────────
export interface KanbanFilter {
category?: string;
serviceCode?: string;
dateFrom?: Date;
dateTo?: Date;
search?: string;
includeCancelled?: boolean;
}
/** ADM-1: канбан. Готовые и отменённые — только за последние 7 дней, если не задан период. */
export async function kanban(ctx: Ctx, f: KanbanFilter) {
const where: Prisma.OrderWhereInput = {
...(f.serviceCode ? { service: { code: f.serviceCode } } : f.category ? { service: { category: { code: f.category as never } } } : {}),
...(f.dateFrom || f.dateTo ? { scheduledAt: { gte: f.dateFrom, lt: f.dateTo } } : {}),
};
if (f.search?.trim()) {
const q = f.search.trim();
const n = Number(q.replace(/^#/, ''));
where.OR = [
...(Number.isInteger(n) && n > 0 ? [{ number: n }] : []),
{ addressText: { contains: q, mode: 'insensitive' as const } },
{ client: { user: { OR: [{ firstName: { contains: q, mode: 'insensitive' as const } }, { username: { contains: q, mode: 'insensitive' as const } }, { phone: { contains: q } }] } } },
{ provider: { name: { contains: q, mode: 'insensitive' as const } } },
];
}
if (!f.dateFrom && !f.dateTo) {
const weekAgo = new Date(ctx.now().getTime() - 7 * DAY_MS);
where.AND = [{ OR: [{ status: { notIn: ['DONE', 'COMPLETED', 'PLAN_SENT', 'CANCELLED'] } }, { updatedAt: { gte: weekAgo } }] }];
}
const orders = await ctx.db.order.findMany({
where,
include: {
service: { include: { category: true } },
district: true,
provider: { select: { id: true, name: true } },
company: { select: { id: true, name: true } },
client: { include: { user: true } },
offers: { where: { status: 'OFFERED' }, include: { provider: { select: { name: true } } } },
},
orderBy: { scheduledAt: 'asc' },
take: 500,
});
const columns: Record<KanbanColumn, ReturnType<typeof card>[]> = { new: [], assigned: [], in_progress: [], done: [], cancelled: [] };
function card(o: (typeof orders)[number]) {
const offer = o.offers[0];
return {
id: o.id,
number: o.number,
serviceCode: o.service.code,
category: o.service.category.code,
status: o.status,
scheduledAt: o.scheduledAt,
districtCode: o.district?.code ?? null,
clientName: o.client.user.firstName,
clientLocale: o.locale,
totalPrice: o.totalPrice,
paymentStatus: o.paymentStatus,
providerName: o.provider?.name ?? null,
companyName: o.company?.name ?? null,
/** «Ждёт подтверждения: Мария» / «Назначен на компанию» */
pending: offer ? (offer.providerId ? { kind: 'provider' as const, name: offer.provider?.name ?? '' } : { kind: 'company' as const, name: o.company?.name ?? '' }) : null,
};
}
for (const o of orders) {
const col = kanbanColumn(o.service.flow as ServiceFlow, o.status, {
hasProvider: !!o.providerId,
hasPendingOffer: o.offers.length > 0,
hasCompany: !!o.companyId,
});
if (col === 'cancelled' && !f.includeCancelled) continue;
columns[col].push(card(o));
}
return columns;
}
/** ADM-2: сводка за сегодня */
export async function summary(ctx: Ctx) {
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [today, unassigned, paidToday, providers, openTasks] = await Promise.all([
ctx.db.order.count({ where: { scheduledAt: { gte: dayStart, lt: dayEnd }, status: { not: 'CANCELLED' } } }),
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
ctx.db.order.aggregate({ _sum: { totalPrice: true }, where: { paidAt: { gte: dayStart, lt: dayEnd }, paymentStatus: 'PAID' } }),
ctx.db.provider.findMany({ where: { active: true }, include: { schedule: true } }),
ctx.db.operatorTask.count({ where: { status: 'OPEN' } }),
]);
// свободен сейчас: на смене и нет заказа, который идёт прямо сейчас
const now = ctx.now();
const busyNow = await ctx.db.order.findMany({
where: { providerId: { not: null }, scheduledAt: { lte: now, gte: new Date(now.getTime() - DAY_MS) }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] } },
select: { providerId: true, scheduledAt: true, durationMinutes: true },
});
const busyIds = new Set(busyNow.filter((o) => o.scheduledAt.getTime() + o.durationMinutes * 60_000 > now.getTime()).map((o) => o.providerId));
const freeProviders = providers.filter((p) => p.onShift && !busyIds.has(p.id)).length;
return { ordersToday: today, unassigned, revenueToday: paidToday._sum.totalPrice ?? 0, freeProviders, providersOnShift: providers.filter((p) => p.onShift).length, openTasks };
}
/** ADM-4: карточка заказа */
export async function adminOrder(db: Db, orderId: string) {
const o = await db.order.findUnique({
where: { id: orderId },
include: {
service: { include: { category: true } },
district: true,
provider: true,
company: true,
client: { include: { user: true, agency: true } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
offers: { orderBy: { createdAt: 'desc' }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
files: { where: { deletedAt: null } },
messages: { orderBy: { createdAt: 'asc' } },
paymentMethod: true,
payments: { orderBy: { createdAt: 'desc' } },
},
});
if (!o) throw new DomainError('NOT_FOUND');
return o;
}
/** Сотрудники компании и свободны ли они на время заказа — для диспетчера */
export async function companyStaffForOrder(ctx: Ctx, companyId: string, orderId: string) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: orderId }, include: { service: true } });
if (order.companyId !== companyId) throw new DomainError('NOT_FOUND');
const end = new Date(order.scheduledAt.getTime() + order.durationMinutes * 60_000);
const avail = await loadAvailability(ctx.db, order.service.providerRole, startOfYerevanDay(order.scheduledAt), new Date(startOfYerevanDay(order.scheduledAt).getTime() + DAY_MS), {
companyId,
excludeOrderId: order.id,
});
const staff = await ctx.db.provider.findMany({ where: { companyId, active: true, role: order.service.providerRole }, orderBy: { name: 'asc' } });
return staff.map((p) => {
const a = avail.find((x) => x.id === p.id);
return {
id: p.id,
name: p.name,
languages: p.languages,
onShift: p.onShift,
free: !!a && isProviderFree(a, { start: order.scheduledAt, end }),
load: a?.busy.length ?? 0,
speaksClientLanguage: p.languages.includes(order.locale),
};
});
}
+34
View File
@@ -0,0 +1,34 @@
import { HOUR } from '@barev/core';
import type { Ctx } from './context.ts';
const FINAL = ['CANCELLED', 'DONE', 'COMPLETED', 'PLAN_SENT'] as const;
/**
* MSG-2: напоминания за 24 часа и за 1 час. Отметка reminded*At делает обработку идемпотентной:
* воркер может запускать её каждую минуту. Заказы, созданные позже чем за 24 ч, получают только часовое.
*/
export async function dueReminders(ctx: Ctx): Promise<number> {
const now = ctx.now();
let sent = 0;
for (const hours of [24, 1] as const) {
const field = hours === 24 ? 'reminded24hAt' : 'reminded1hAt';
const due = await ctx.db.order.findMany({
where: {
status: { notIn: [...FINAL] },
[field]: null,
scheduledAt: { gt: now, lte: new Date(now.getTime() + hours * HOUR) },
// 24-часовое не шлём, если до начала уже меньше часа — придёт часовое
...(hours === 24 ? { NOT: { scheduledAt: { lte: new Date(now.getTime() + HOUR) } } } : {}),
},
select: { id: true },
});
for (const { id } of due) {
const { count } = await ctx.db.order.updateMany({ where: { id, [field]: null }, data: { [field]: now } });
if (count === 1) {
await ctx.notify.emit({ type: 'order.reminder', orderId: id, hours });
sent++;
}
}
}
return sent;
}
+11
View File
@@ -0,0 +1,11 @@
import { resolveSettings, settingsSchema, type SettingKey, type Settings } from '@barev/core';
import type { Db, Tx } from '@barev/db';
export async function getSettings(db: Db | Tx): Promise<Settings> {
return resolveSettings(await db.setting.findMany());
}
export async function setSetting<K extends SettingKey>(db: Db, key: K, value: Settings[K]): Promise<void> {
const parsed = settingsSchema.shape[key].parse(value);
await db.setting.upsert({ where: { key }, create: { key, value: parsed }, update: { value: parsed } });
}
+327
View File
@@ -0,0 +1,327 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { encodeStartPayload } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
availableSlots,
cancelByClient,
createOrder,
dispatcherAssign,
dueReminders,
expireOffers,
giveConsent,
kanban,
markPaid,
offerOrder,
providerAction,
quoteForClient,
respondToOffer,
rescheduleByClient,
setChecklistItem,
startClient,
transitionOrder,
DomainError,
} 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();
});
afterAll(() => db.$disconnect());
async function onboardedClient(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, payload?: string) {
const start = await startClient(ctx, tgUser(id), payload);
await giveConsent(ctx, start.clientId);
return start.clientId;
}
async function newOrder(ctx: ReturnType<typeof makeCtx>['ctx'], clientId: string, when = slotAt(2, 11)) {
return createOrder(ctx, {
clientId,
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2 },
scheduledAt: when,
addressText: 'Pushkin 10',
districtId: fx.kentron.id,
});
}
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
describe('онбординг (ONB-1…6)', () => {
it('/start с кодом агентства: клиент с источником, язык, подарок после согласия', async () => {
const { ctx, events } = makeCtx(NOW);
const start = await startClient(ctx, tgUser(1, 'en'), encodeStartPayload({ locale: 'hy', agency: 'domik' }));
expect(start).toMatchObject({ isNew: true, needsLocale: false, needsConsent: true, locale: 'hy', agencyName: 'Domik', gift: null });
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
expect(client.agencyId).toBe(fx.agency.id);
expect(client.source).toBe('deeplink');
expect(client.user.locale).toBe('hy');
const gift = await giveConsent(ctx, start.clientId);
expect(gift?.endsAt.getTime()).toBe(NOW.getTime() + 30 * 24 * 3600_000);
expect(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
});
it('подарок — один раз; повторный /start с другим агентством не перезаписывает источник', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 2, encodeStartPayload({ agency: 'DOMIK' }));
await db.agency.create({ data: { code: 'OTHER', name: 'Other' } });
const again = await startClient(ctx, tgUser(2), encodeStartPayload({ agency: 'OTHER' }));
expect(again).toMatchObject({ isNew: false, agencyName: 'Domik', gift: null });
expect(await db.subscription.count({ where: { clientId: id } })).toBe(1);
});
it('без языка в payload и у нового клиента — спрашиваем язык', async () => {
const { ctx } = makeCtx(NOW);
expect((await startClient(ctx, tgUser(3, 'de'))).needsLocale).toBe(true);
});
it('UTM через короткий токен', async () => {
const { ctx } = makeCtx(NOW);
await db.startToken.create({ data: { id: 'tok1', locale: 'es', agency: 'DOMIK', utm: { utm_source: 'qr' } } });
const s = await startClient(ctx, tgUser(4), encodeStartPayload({ token: 'tok1' }));
expect(s.locale).toBe('es');
const c = await db.client.findUniqueOrThrow({ where: { id: s.clientId } });
expect(c.utm).toEqual({ utm_source: 'qr' });
expect(c.agencyId).toBe(fx.agency.id);
});
it('ручной ввод кода (ONB-6): неверный/неактивный — ошибка, верный — подарок', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 5);
expect(await code(applyAgencyCode(ctx, id, 'nope'))).toBe('AGENCY_CODE_INVALID');
expect(await code(applyAgencyCode(ctx, id, 'closed'))).toBe('AGENCY_CODE_INVALID');
const r = await applyAgencyCode(ctx, id, ' domik ');
expect(r.gift).not.toBeNull();
expect((await db.client.findUniqueOrThrow({ where: { id } })).source).toBe('manual_code');
});
});
describe('заказ (ORD-3…6)', () => {
it('цена со скидкой Barev+ только у подписчика', async () => {
const { ctx } = makeCtx(NOW);
const plus = await onboardedClient(ctx, 10, encodeStartPayload({ agency: 'DOMIK' }));
const plain = await onboardedClient(ctx, 11);
const p = { cleaningType: 'regular', rooms: 2 };
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toEqual({ basePrice: 19000, discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plain, 'cleaning', p)).quote.totalPrice).toBe(19000);
});
it('без согласия заказ не создаётся; без адреса — тоже', async () => {
const { ctx } = makeCtx(NOW);
const s = await startClient(ctx, tgUser(12));
expect(await code(newOrder(ctx, s.clientId))).toBe('CONSENT_REQUIRED');
await giveConsent(ctx, s.clientId);
expect(
await code(createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 1 }, scheduledAt: slotAt(2, 11) })),
).toBe('ADDRESS_REQUIRED');
});
it('создаёт «Новый» с чек-листом и историей, шлёт событие', async () => {
const { ctx, events } = makeCtx(NOW);
const id = await onboardedClient(ctx, 13);
const order = await newOrder(ctx, id);
expect(order.status).toBe('NEW');
expect(await db.orderChecklistItem.count({ where: { orderId: order.id } })).toBe(5);
expect(await db.orderStatusHistory.count({ where: { orderId: order.id } })).toBe(1);
expect(events).toContainEqual({ type: 'order.created', orderId: order.id });
const cols = await kanban(ctx, {});
expect(cols.new.map((c) => c.id)).toContain(order.id);
});
it('слот не продаётся больше раз, чем есть свободных исполнителей', async () => {
const { ctx } = makeCtx(NOW);
const when = slotAt(2, 11);
// 3 клинера (Maria, Ani, Lusine) → 3 заказа, четвёртый — отказ
for (let i = 0; i < 3; i++) await newOrder(ctx, await onboardedClient(ctx, 20 + i), when);
expect(await code(newOrder(ctx, await onboardedClient(ctx, 30), when))).toBe('SLOT_UNAVAILABLE');
const slots = await availableSlots(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, dateKey: '2026-10-21' });
expect(slots.some((s) => s.start.getTime() === when.getTime())).toBe(false);
});
it('время вне окна или в прошлом — отказ', async () => {
const { ctx } = makeCtx(NOW);
const id = await onboardedClient(ctx, 31);
expect(await code(newOrder(ctx, id, slotAt(0, 9)))).toBe('SLOT_UNAVAILABLE');
expect(await code(newOrder(ctx, id, slotAt(2, 19)))).toBe('SLOT_UNAVAILABLE');
});
it('отмена: бесплатно за 24 ч, позже — удержание 30%, после начала — только оператор', async () => {
const { ctx, clock } = makeCtx(NOW);
const id = await onboardedClient(ctx, 32);
const free = await newOrder(ctx, id, slotAt(2, 11));
expect((await cancelByClient(ctx, { orderId: free.id, clientId: id })).fee).toBe(0);
const late = await newOrder(ctx, id, slotAt(1, 9));
clock.now = new Date(late.scheduledAt.getTime() - 2 * 3600_000);
expect((await cancelByClient(ctx, { orderId: late.id, clientId: id })).fee).toBe(Math.floor(late.totalPrice * 0.3));
clock.now = NOW;
const started = await newOrder(ctx, id, slotAt(1, 12));
clock.now = new Date(started.scheduledAt.getTime() + 60_000);
expect(await code(cancelByClient(ctx, { orderId: started.id, clientId: id }))).toBe('CANCEL_VIA_OPERATOR');
});
it('чужой заказ отменить нельзя', async () => {
const { ctx } = makeCtx(NOW);
const a = await onboardedClient(ctx, 33);
const b = await onboardedClient(ctx, 34);
const o = await newOrder(ctx, a);
expect(await code(cancelByClient(ctx, { orderId: o.id, clientId: b }))).toBe('NOT_FOUND');
});
});
describe('назначение (ADM-3, PRO-5, PRO-7)', () => {
it('подсказка учитывает язык клиента', async () => {
const { ctx } = makeCtx(NOW);
const s = await startClient(ctx, tgUser(40), encodeStartPayload({ locale: 'hy' }));
await giveConsent(ctx, s.clientId);
const o = await newOrder(ctx, s.clientId);
const { ranked } = await assignmentCandidates(ctx, o.id);
expect(ranked[0]).toMatchObject({ kind: 'provider', name: 'Ani' });
expect(ranked.map((r) => r.name)).toContain('Chisto');
});
it('предложение → принятие → «Назначен», клиент получает событие', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 41));
const offer = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'provider', name: 'Maria' });
expect(await code(respondToOffer(ctx, { offerId: offer.id, providerId: fx.ani.id, accept: true }))).toBe('OFFER_NOT_ACTIVE');
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: true });
const updated = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(updated).toMatchObject({ status: 'ASSIGNED', providerId: fx.maria.id });
expect(events).toContainEqual({ type: 'order.status', orderId: o.id, from: 'NEW', to: 'ASSIGNED' });
});
it('отказ и таймаут возвращают заказ в «Новые»', async () => {
const { ctx, clock } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 42));
const f1 = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f1.id, providerId: fx.maria.id, accept: false });
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.ani.id } });
clock.now = new Date(NOW.getTime() + 16 * 60_000);
expect(await expireOffers(ctx)).toBe(1);
expect((await kanban(ctx, {})).new.map((c) => c.id)).toContain(o.id);
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('NEW');
});
it('компания: «Назначен на компанию» → диспетчер выбирает сотрудника', async () => {
const { ctx } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 43));
await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'company', id: fx.company.id } });
expect((await kanban(ctx, {})).assigned.find((c) => c.id === o.id)?.pending).toEqual({ kind: 'company', name: 'Chisto' });
// чужой сотрудник и не-диспетчер — отказ
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.maria.id, dispatcherUserId: fx.dispatcher.id }))).toBe('NOT_FOUND');
expect(await code(dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: 'x' }))).toBe('FORBIDDEN');
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
expect(await db.order.findUniqueOrThrow({ where: { id: o.id } })).toMatchObject({ status: 'ASSIGNED', providerId: fx.lusine.id, companyId: fx.company.id });
const hist = await db.orderStatusHistory.findFirst({ where: { orderId: o.id, toStatus: 'ASSIGNED' } });
expect(hist?.actorType).toBe('DISPATCHER');
});
it('занятому исполнителю предложить нельзя', async () => {
const { ctx } = makeCtx(NOW);
const when = slotAt(2, 11);
const a = await newOrder(ctx, await onboardedClient(ctx, 44), when);
const f = await offerOrder(ctx, { orderId: a.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
const b = await newOrder(ctx, await onboardedClient(ctx, 45), when);
expect(await code(offerOrder(ctx, { orderId: b.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } }))).toBe('PROVIDER_BUSY');
});
});
describe('исполнитель ведёт заказ (PRO-3, PRO-4)', () => {
async function assigned(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const o = await newOrder(ctx, await onboardedClient(ctx, tg));
const f = await offerOrder(ctx, { orderId: o.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
return o;
}
it('шаги клинера; закрыть без чек-листа и фото нельзя', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await assigned(ctx, 50);
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' }))).toBe('TRANSITION_NOT_ALLOWED');
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'depart' });
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'arrived' });
expect(events).toContainEqual({ type: 'order.event', orderId: o.id, code: 'arrived' });
// чек-лист до начала уборки не отмечается
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'kitchen', checked: true }))).toBe('TRANSITION_NOT_ALLOWED');
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'startCleaning' });
for (const c of ['kitchen', 'bathroom', 'rooms', 'floors']) await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: c, checked: true });
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' }))).toBe('CHECKLIST_INCOMPLETE');
expect(await code(setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true }))).toBe('PHOTO_REQUIRED');
const file = await db.orderFile.create({ data: { orderId: o.id, kind: 'PHOTO_REPORT', storageKey: `t/${o.id}.jpg`, mime: 'image/jpeg', size: 1, uploadedBy: 'PROVIDER' } });
await setChecklistItem(ctx, { orderId: o.id, providerId: fx.maria.id, code: 'photoReport', checked: true, fileId: file.id });
await providerAction(ctx, { orderId: o.id, providerId: fx.maria.id, action: 'finishCleaning' });
const done = await db.order.findUniqueOrThrow({ where: { id: o.id } });
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']);
});
it('чужой исполнитель не может двигать заказ', async () => {
const { ctx } = makeCtx(NOW);
const o = await assigned(ctx, 51);
expect(await code(providerAction(ctx, { orderId: o.id, providerId: fx.ani.id, action: 'depart' }))).toBe('NOT_FOUND');
});
it('бэкенд отклоняет недопустимый переход напрямую', async () => {
const { ctx } = makeCtx(NOW);
const o = await assigned(ctx, 52);
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
expect(await code(transitionOrder(ctx, { orderId: o.id, to: 'DRIVER_WAITING', actor: 'ADMIN' }))).toBe('TRANSITION_NOT_ALLOWED');
});
it('перенос: если исполнитель занят в новое время — заказ возвращается в «Новые»', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await assigned(ctx, 53);
// Мария уже занята в 15:00 другим заказом
const other = await newOrder(ctx, await onboardedClient(ctx, 54), slotAt(2, 15));
const f = await offerOrder(ctx, { orderId: other.id, adminId: 'admin', target: { kind: 'provider', id: fx.maria.id } });
await respondToOffer(ctx, { offerId: f.id, providerId: fx.maria.id, accept: true });
const client = (await db.order.findUniqueOrThrow({ where: { id: o.id } })).clientId;
await rescheduleByClient(ctx, { orderId: o.id, clientId: client, scheduledAt: slotAt(2, 15) });
expect(await db.order.findUniqueOrThrow({ where: { id: o.id } })).toMatchObject({ status: 'NEW', providerId: null });
expect(events).toContainEqual(expect.objectContaining({ type: 'order.unassigned', orderId: o.id }));
});
});
describe('оплата и напоминания', () => {
it('оплата вручную: начисление агентству только с оплаченных и в срок закрепления', async () => {
const { ctx } = makeCtx(NOW);
const agencyClient = await onboardedClient(ctx, 60, encodeStartPayload({ agency: 'DOMIK' }));
const o = await newOrder(ctx, agencyClient);
expect(await db.agencyCommission.count()).toBe(0);
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } });
const comm = await db.agencyCommission.findUniqueOrThrow({ where: { orderId: o.id } });
expect(comm).toMatchObject({ pct: 10, amount: Math.floor(o.totalPrice * 0.1), period: '2026-10' });
expect(await code(markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: 'admin' } }))).toBe('NOT_PAID_STATE');
const organic = await onboardedClient(ctx, 61);
const o2 = await newOrder(ctx, organic, slotAt(2, 13));
await markPaid(ctx, { orderId: o2.id, by: { type: 'ADMIN', id: 'admin' } });
expect(await db.agencyCommission.count()).toBe(1);
});
it('напоминания за 24 ч и 1 ч отправляются по одному разу', async () => {
const { ctx, events, clock } = makeCtx(NOW);
const o = await newOrder(ctx, await onboardedClient(ctx, 62), slotAt(2, 11));
clock.now = new Date(o.scheduledAt.getTime() - 23 * 3600_000);
expect(await dueReminders(ctx)).toBe(1);
expect(await dueReminders(ctx)).toBe(0);
clock.now = new Date(o.scheduledAt.getTime() - 50 * 60_000);
expect(await dueReminders(ctx)).toBe(1);
expect(events.filter((e) => e.type === 'order.reminder').map((e) => (e as { hours: number }).hours)).toEqual([24, 1]);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { config } from 'dotenv';
import pg from 'pg';
/** Отдельная БД barev_test рядом с основной; схема накатывается миграциями */
export default async function setup() {
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
const base = new URL(process.env.DATABASE_URL!);
const testUrl = new URL(base);
testUrl.pathname = '/barev_test';
const admin = new pg.Client({ connectionString: base.toString() });
await admin.connect();
const exists = await admin.query(`SELECT 1 FROM pg_database WHERE datname = 'barev_test'`);
if (!exists.rowCount) await admin.query('CREATE DATABASE barev_test');
await admin.end();
execSync('npx prisma migrate deploy', {
cwd: path.resolve(import.meta.dirname, '../../db'),
env: { ...process.env, DATABASE_URL: testUrl.toString() },
stdio: 'pipe',
});
process.env.TEST_DATABASE_URL = testUrl.toString();
}
+87
View File
@@ -0,0 +1,87 @@
import path from 'node:path';
import { config } from 'dotenv';
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, fromYerevan, toYerevan, YEREVAN_DISTRICTS } from '@barev/core';
import { createDb, type Db } from '@barev/db';
import type { Ctx, DomainEvent } from '../src/context.ts';
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
function testUrl() {
const u = new URL(process.env.DATABASE_URL!);
u.pathname = '/barev_test';
return u.toString();
}
export const db: Db = createDb(testUrl());
export function makeCtx(now: Date) {
const events: DomainEvent[] = [];
const clock = { now };
const ctx: Ctx = { db, notify: { emit: (e) => void events.push(e) }, now: () => clock.now };
return { ctx, events, clock };
}
export async function wipe() {
const tables = await db.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`;
await db.$executeRawUnsafe(`TRUNCATE ${tables.map((t) => `"public"."${t.tablename}"`).join(', ')} RESTART IDENTITY CASCADE`);
}
/** Понедельник 2026-10-19, 10:00 по Еревану — «сейчас» в тестах */
export const NOW = fromYerevan(2026, 10, 19, 10 * 60);
/** Слот послезавтра (ср) в hh:00 — вне 24-часового порога */
export const slotAt = (dayOffset: number, hour: number) => {
const p = toYerevan(new Date(NOW.getTime() + dayOffset * 24 * 3600_000));
return fromYerevan(p.year, p.month, p.day, hour * 60);
};
let tgSeq = 1000;
export async function fixtures() {
await wipe();
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
const c = CATALOG.find((s) => s.code === 'cleaning')!;
const cleaning = await db.service.create({
data: {
code: c.code,
categoryId: home.id,
flow: c.flow,
providerRole: c.providerRole,
pricing: c.pricing,
paramsSchema: c.paramsSchema,
slotConfig: c.slotConfig,
lateCancelFeePct: 30,
checklist: { create: c.checklist.map((x, i) => ({ code: x.code, requiresPhoto: !!x.requiresPhoto, sortOrder: i })) },
},
});
const agency = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik', commissionPct: 10 } });
await db.agency.create({ data: { code: 'CLOSED', name: 'Closed', active: false } });
const schedule = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 }));
const mkProvider = async (name: string, languages: ('ru' | 'en' | 'hy')[], companyId?: string) => {
const user = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: name } });
return db.provider.create({
data: {
name,
userId: user.id,
role: 'CLEANER',
languages,
companyId,
inviteCode: `INV-${name}`,
onShift: true,
districts: { create: [{ districtId: kentron.id }] },
schedule: { create: schedule },
},
});
};
const maria = await mkProvider('Maria', ['ru', 'en']);
const ani = await mkProvider('Ani', ['hy']);
const company = await db.providerCompany.create({ data: { name: 'Chisto', roles: ['CLEANER'], inviteCode: 'CO-1', rateValue: 65 } });
const lusine = await mkProvider('Lusine', ['ru'], company.id);
const dispatcher = await db.user.create({ data: { telegramId: BigInt(++tgSeq), firstName: 'Karen' } });
await db.companyDispatcher.create({ data: { companyId: company.id, userId: dispatcher.id } });
return { kentron, cleaning, agency, maria, ani, company, lusine, dispatcher };
}
export const tgUser = (id: number, lang = 'ru') => ({ id, first_name: `User${id}`, language_code: lang });
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src"] }
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['./test/global-setup.ts'],
// тесты делят одну БД — запускаем последовательно
fileParallelism: false,
testTimeout: 20_000,
hookTimeout: 60_000,
},
});