Bariki cashback, order edit, and current work in progress
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -0,0 +1,193 @@
|
||||
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<ReturnType<typeof fixtures>>;
|
||||
beforeEach(async () => {
|
||||
fx = await fixtures();
|
||||
});
|
||||
afterAll(() => db.$disconnect());
|
||||
|
||||
type C = ReturnType<typeof makeCtx>['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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user