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

+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 })),
};
}