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