import { afterAll, beforeEach, describe, expect, it } from 'vitest'; import { addMonths } from '@barev/core'; import { applyAgencyCode, assignmentCandidates, companyAutoAssign, completeWebRequest, createOrder, createWebRequest, dispatcherAssign, expireOffers, giveConsent, linkWebRequest, markPaid, offerOrder, partnerAddStaff, partnerCabinetData, partnerDeclineOrder, partnerUnassign, respondToOffer, startClient, } from '../src/index.ts'; import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts'; let fx: Awaited>; beforeEach(async () => { fx = await fixtures(); }); afterAll(() => db.$disconnect()); type C = ReturnType['ctx']; async function order(ctx: C, tg: number, at = slotAt(2, 11), allowBusySlot = false) { const s = await startClient(ctx, tgUser(tg)); await giveConsent(ctx, s.clientId); return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: at, addressText: 'Pushkin 10', districtId: fx.kentron.id, allowBusySlot }); } const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } }); const events = async (orderId: string) => (await db.orderStatusHistory.findMany({ where: { orderId }, orderBy: { createdAt: 'asc' } })).map((h) => h.event); describe('партнёры услуг: оператор назначает', () => { it('у каждого варианта видно, что остаётся нам; самый выгодный подсвечен', async () => { const { ctx } = makeCtx(NOW); await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'COMMISSION', commissionPct: 40 } }); const o = await order(ctx, 901); const r = await assignmentCandidates(ctx, o.id); const chisto = r.ranked.find((c) => c.kind === 'company')!; expect(chisto.margin).toBe(Math.floor(o.totalPrice * 0.4)); const maria = r.ranked.find((c) => c.name === 'Maria')!; expect(maria.margin).toBe(o.totalPrice - maria.cost); const best = [...r.ranked].sort((a, b) => b.margin - a.margin)[0]!; expect(r.bestId).toBe(`${best.kind}:${best.id}`); }); it('партнёр отказался — заказ снова у оператора', async () => { const { ctx, events: ev } = makeCtx(NOW); const o = await order(ctx, 902); const op = await admin(); await offerOrder(ctx, { orderId: o.id, adminId: op.id, target: { kind: 'company', id: fx.company.id } }); const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, companyId: fx.company.id, status: 'OFFERED' } }); expect(offer.expiresAt).not.toBeNull(); await partnerDeclineOrder(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id }); expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull(); expect(ev).toContainEqual({ type: 'partner.returned', orderId: o.id, companyId: fx.company.id, reason: 'declined' }); }); it('партнёр не назначил сотрудника вовремя — заказ возвращается оператору', async () => { const { ctx, clock } = makeCtx(NOW); const o = await order(ctx, 903); await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } }); clock.now = new Date(NOW.getTime() + 61 * 60_000); await expireOffers(ctx); expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull(); expect(await events(o.id)).toContain('partnerExpired'); }); }); describe('партнёры услуг: автораспределение по сотрудникам', () => { it('заказ сам уходит сотруднику; отказ — следующему; никого нет — менеджеру', async () => { const { ctx, events: ev } = makeCtx(NOW); await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true } }); const added = await partnerAddStaff(ctx, { companyId: fx.company.id, userId: fx.dispatcher.id, staff: { name: 'Gayane', languages: ['hy'], districtIds: [fx.kentron.id], schedule: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 540, endMinute: 1200 })) }, }); // сотрудник ещё не вошёл в бот — автоматически ему не предлагаем expect(added.inviteCode).toMatch(/^PRO-/); const o = await order(ctx, 904); await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } }); const first = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED', providerId: { not: null } } }); expect(first.providerId).toBe(fx.lusine.id); await respondToOffer(ctx, { offerId: first.id, providerId: fx.lusine.id, accept: false }); // больше подходящих нет — заказ ждёт менеджера expect(await db.orderOffer.count({ where: { orderId: o.id, status: 'OFFERED', providerId: null, companyId: fx.company.id } })).toBe(1); expect(ev).toContainEqual({ type: 'company.noStaff', orderId: o.id, companyId: fx.company.id }); expect(await companyAutoAssign(ctx, o.id)).toEqual({ result: 'none' }); }); it('лимит заказов в день и перерыв на дорогу', async () => { const { ctx } = makeCtx(NOW); await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true, travelGapMinutes: 60 } }); // первый заказ Лусине: 11:00, уборка 2 комнат ~3 часа const a = await order(ctx, 905, slotAt(2, 11)); await offerOrder(ctx, { orderId: a.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } }); const offerA = await db.orderOffer.findFirstOrThrow({ where: { orderId: a.id, status: 'OFFERED', providerId: { not: null } } }); await respondToOffer(ctx, { offerId: offerA.id, providerId: fx.lusine.id, accept: true }); const end = new Date(a.scheduledAt.getTime() + a.durationMinutes * 60_000); // сразу после окончания — нет времени доехать const b = await order(ctx, 906, new Date(end.getTime() + 30 * 60_000), true); const cab = await import('../src/servicePartners.ts'); const settings = await (await import('../src/settings.ts')).getSettings(db); const tight = await cab.companyStaffCandidates(db, fx.company.id, b.id, settings); expect(tight.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('gap'); // лимит 1 заказ в день await db.provider.update({ where: { id: fx.lusine.id }, data: { maxOrdersPerDay: 1 } }); const c = await order(ctx, 907, new Date(end.getTime() + 2 * 3600_000), true); const limited = await cab.companyStaffCandidates(db, fx.company.id, c.id, settings); expect(limited.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('limit'); }); it('менеджер назначает вручную и снимает сотрудника', async () => { const { ctx } = makeCtx(NOW); const o = await order(ctx, 908); await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } }); 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 } })).providerId).toBe(fx.lusine.id); await partnerUnassign(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id }); const after = await db.order.findUniqueOrThrow({ where: { id: o.id } }); expect(after).toMatchObject({ providerId: null, companyId: fx.company.id, status: 'NEW' }); const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id); expect(cab.pending.map((x) => x.id)).toContain(o.id); }); }); describe('партнёры услуг: доход', () => { it('«расходы наши»: при оплате фиксируется доля партнёра от прибыли', async () => { const { ctx } = makeCtx(NOW); await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 30, fotPct: 60, taxPct: 5 } }); const o = await order(ctx, 910); await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } }); await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id }); await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.lusine.id } }); const e = await db.partnerEarning.findUniqueOrThrow({ where: { orderId: o.id } }); const fot = Math.floor(o.totalPrice * 0.6); const tax = Math.floor(o.totalPrice * 0.05); expect(e).toMatchObject({ deal: 'PROFIT_SHARE', paid: o.totalPrice, fot, tax, amount: Math.floor(((o.totalPrice - fot - tax) * 30) / 100) }); const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id); expect(cab.money.balance).toBe(e.amount); }); it('чужой менеджер не видит кабинет', async () => { const { ctx } = makeCtx(NOW); await expect(partnerCabinetData(ctx, fx.company.id, (await db.user.create({ data: { telegramId: 99999n } })).id)).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); }); describe('подарок агентства', () => { it('уровень и срок задаются у агентства', async () => { const { ctx } = makeCtx(NOW); await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: 'ELITE', giftMonths: 3 } }); const s = await startClient(ctx, tgUser(920)); await giveConsent(ctx, s.clientId); const r = await applyAgencyCode(ctx, s.clientId, 'DOMIK'); expect(r.gift).toEqual({ plan: 'ELITE', endsAt: addMonths(NOW, 3) }); const sub = await db.subscription.findFirstOrThrow({ where: { clientId: s.clientId } }); expect(sub).toMatchObject({ plan: 'ELITE', agencyId: fx.agency.id, source: 'AGENCY_GIFT' }); }); it('агентство без подарка', async () => { const { ctx } = makeCtx(NOW); await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: null } }); const s = await startClient(ctx, tgUser(921)); await giveConsent(ctx, s.clientId); expect((await applyAgencyCode(ctx, s.clientId, 'DOMIK')).gift).toBeNull(); }); }); describe('заявка с сайта без времени и адреса', () => { it('бот доспрашивает недостающее и оформляет заказ', async () => { const { ctx } = makeCtx(NOW); const req = await createWebRequest(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, phone: '+37491123456', locale: 'ru' }); expect(req).toMatchObject({ preferredAt: null, districtId: null, addressText: null }); const s = await startClient(ctx, tgUser(930)); const link = await linkWebRequest(ctx, req.token, s.clientId); expect(link).toMatchObject({ kind: 'incomplete', missing: ['district', 'address', 'time'] }); if (link.kind !== 'incomplete') return; expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, districtCode: 'kentron' })).toMatchObject({ kind: 'incomplete', missing: ['address', 'time'] }); expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, addressText: 'Abovyan 5' })).toMatchObject({ kind: 'incomplete', missing: ['time'] }); const done = await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, preferredAt: slotAt(2, 12) }); expect(done).toMatchObject({ kind: 'ordered' }); expect((await db.webRequest.findUniqueOrThrow({ where: { id: req.id } })).status).toBe('ORDERED'); }); });