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:
commit
45d7512d2b
64 files changed
+9483
No files matched your search
@@ -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 })),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user