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
@@ -41,11 +41,11 @@ describe('автораспределение заказов', () => {
|
||||
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Ani' });
|
||||
const second = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED' } });
|
||||
await respondToOffer(ctx, { offerId: second.id, providerId: fx.ani.id, accept: false });
|
||||
// больше свободных частных нет — компания включается и получает заказ
|
||||
// больше свободных частных нет; партнёрам система сама не назначает — это делает оператор
|
||||
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: true } });
|
||||
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Chisto' });
|
||||
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'none' });
|
||||
const offers = await db.orderOffer.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
|
||||
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id, fx.company.id]);
|
||||
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id]);
|
||||
});
|
||||
|
||||
it('снятый с заказа исполнитель не получает его снова автоматически', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { encodeStartPayload } from '@barev/core';
|
||||
import { addMonths, encodeStartPayload } from '@barev/core';
|
||||
import {
|
||||
applyAgencyCode,
|
||||
assignmentCandidates,
|
||||
@@ -95,7 +95,7 @@ describe('онбординг (ONB-1…6)', () => {
|
||||
expect(client.user.locale).toBe('hy');
|
||||
|
||||
const gift = await giveConsent(ctx, start.clientId);
|
||||
expect(gift?.endsAt.getTime()).toBe(NOW.getTime() + 30 * 24 * 3600_000);
|
||||
expect(gift?.endsAt.getTime()).toBe(addMonths(NOW, 1).getTime());
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
|
||||
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
|
||||
});
|
||||
@@ -388,9 +388,11 @@ describe('выплаты клинеру по пресету', () => {
|
||||
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
|
||||
// без пресета — индивидуальная ставка 70% от итога
|
||||
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
|
||||
// сотрудник компании — ставка компании
|
||||
// сотрудник партнёра «как Uber» — ему платит партнёр, от нас 0
|
||||
const lusine = await db.provider.findUniqueOrThrow({ where: { id: fx.lusine.id }, include: { company: true, payoutPreset: true } });
|
||||
expect(orderPayout(full, lusine)).toBe(Math.floor(full.totalPrice * 0.65));
|
||||
expect(orderPayout(full, lusine)).toBe(0);
|
||||
// «расходы наши» — ФОТ клинера, % от оплаченного
|
||||
expect(orderPayout(full, { ...lusine, company: { deal: 'PROFIT_SHARE', fotPct: 40 } })).toBe(Math.floor(full.totalPrice * 0.4));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,20 @@ export async function fixtures() {
|
||||
],
|
||||
});
|
||||
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
|
||||
// справочники бариков (в базе их создаёт миграция)
|
||||
await db.pointsCategory.createMany({
|
||||
data: [
|
||||
{ code: 'cleaning', name: { ru: 'Клининг' }, rateBarev: 5, ratePlus: 8, rateElite: 10 },
|
||||
{ code: 'documents', name: { ru: 'Документы' }, rateBarev: 5, ratePlus: 10, rateElite: 12 },
|
||||
],
|
||||
});
|
||||
await db.pointsBonusType.createMany({
|
||||
data: [
|
||||
{ code: 'housewarming', name: { ru: 'Новоселье' }, defaultAmount: 5000, ttlDays: 60 },
|
||||
{ code: 'promo', name: { ru: 'Промо' }, defaultAmount: 1000, ttlDays: 30 },
|
||||
{ code: 'first_month_route', name: { ru: 'Маршрут' }, defaultAmount: 3000 },
|
||||
],
|
||||
});
|
||||
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
|
||||
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
|
||||
const c = CATALOG.find((s) => s.code === 'cleaning')!;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createOrder, editOrderComposition, giveConsent, markPaid, previewOrderEdit, staffChecklistItem, startClient, transitionOrder } 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());
|
||||
|
||||
async function order(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
|
||||
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, bathrooms: 1 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
|
||||
}
|
||||
const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
|
||||
|
||||
describe('команда меняет заказ', () => {
|
||||
it('состав: пересчёт цены и длительности, история, уведомление по галочке', async () => {
|
||||
const { ctx, events } = makeCtx(NOW);
|
||||
const o = await order(ctx, 1501);
|
||||
const op = await admin();
|
||||
const p = await previewOrderEdit(ctx, { orderId: o.id, params: { cleaningType: 'regular', rooms: 3, bathrooms: 1, extras: { windows: 2 } } });
|
||||
expect(p.totalPrice).toBeGreaterThan(o.totalPrice);
|
||||
expect(p.durationMinutes).toBeGreaterThan(o.durationMinutes);
|
||||
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: p.params, notify: true });
|
||||
const after = await db.order.findUniqueOrThrow({ where: { id: o.id } });
|
||||
expect(after).toMatchObject({ totalPrice: p.totalPrice, durationMinutes: p.durationMinutes });
|
||||
expect((after.params as Record<string, unknown>).rooms).toBe(3);
|
||||
expect(events).toContainEqual({ type: 'order.edited', orderId: o.id });
|
||||
expect(await db.orderStatusHistory.count({ where: { orderId: o.id, event: 'editedByStaff' } })).toBe(1);
|
||||
});
|
||||
|
||||
it('своя цена — только с причиной', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const o = await order(ctx, 1502);
|
||||
const op = await admin();
|
||||
await expect(editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345 })).rejects.toMatchObject({ code: 'REASON_REQUIRED' });
|
||||
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345, reason: 'договорились' });
|
||||
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).totalPrice).toBe(12_345);
|
||||
});
|
||||
|
||||
it('после оплаты: кэшбек пересчитывается по новой цене', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const o = await order(ctx, 1503);
|
||||
const op = await admin();
|
||||
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
|
||||
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
|
||||
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
|
||||
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: op.id } });
|
||||
const before = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
|
||||
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 4, bathrooms: 2 } });
|
||||
const now = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
|
||||
expect(now.id).not.toBe(before.id);
|
||||
expect(now.amount).toBeGreaterThan(before.amount);
|
||||
});
|
||||
|
||||
it('чек-лист за клинера: пункт с фото — без фото только с причиной; потом заказ можно закрыть', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const o = await order(ctx, 1504);
|
||||
const op = await admin();
|
||||
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
|
||||
const items = await db.orderChecklistItem.findMany({ where: { orderId: o.id } });
|
||||
const photo = items.find((i) => i.requiresPhoto)!;
|
||||
await expect(staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: photo.code, checked: true })).rejects.toMatchObject({ code: 'PHOTO_REQUIRED' });
|
||||
for (const i of items) await staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: i.code, checked: true, reason: i.requiresPhoto ? 'фото в чате' : null });
|
||||
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
|
||||
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('DONE');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
accruePoints,
|
||||
activatePendingPoints,
|
||||
burnPoints,
|
||||
cancelByClient,
|
||||
confirmRedemption,
|
||||
createOrder,
|
||||
giveConsent,
|
||||
markPaid,
|
||||
reconcilePoints,
|
||||
redeemPoints,
|
||||
requestRedemption,
|
||||
reverseAccrual,
|
||||
reversePreview,
|
||||
reverseRedemption,
|
||||
startClient,
|
||||
transitionOrder,
|
||||
warnExpiringPoints,
|
||||
applyAgencyCode,
|
||||
} 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'];
|
||||
const OWNER = { adminId: null, owner: true };
|
||||
const H = 3600_000;
|
||||
const D = 24 * H;
|
||||
async function client(ctx: C, tg: number) {
|
||||
const s = await startClient(ctx, tgUser(tg));
|
||||
await giveConsent(ctx, s.clientId);
|
||||
return s.clientId;
|
||||
}
|
||||
const cat = (code = 'cleaning') => db.pointsCategory.findUniqueOrThrow({ where: { code } });
|
||||
const bal = async (id: string) => db.client.findUniqueOrThrow({ where: { id }, select: { pointsActive: true, pointsPending: true, pointsDebt: true } });
|
||||
async function give(ctx: C, clientId: string, amount: number, extra: Record<string, unknown> = {}) {
|
||||
return accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 100_000, amount, reason: 'тест', serviceDate: NOW, noWait: true, channel: 'WEB', ...extra }, OWNER);
|
||||
}
|
||||
|
||||
describe('барики: начисление', () => {
|
||||
it('по заказу: подсказка по ставке Barev, ожидание 48 ч, потом доступны', async () => {
|
||||
const { ctx, clock, events } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1001);
|
||||
const r = await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB', orderRef: '1042' }, OWNER);
|
||||
expect(r).toMatchObject({ status: 'created', amount: 900 });
|
||||
expect(await bal(id)).toMatchObject({ pointsActive: 0, pointsPending: 900 });
|
||||
expect(events.some((e) => e.type === 'points.accrued')).toBe(true);
|
||||
clock.now = new Date(NOW.getTime() + 49 * H);
|
||||
expect(await activatePendingPoints(ctx)).toBe(1);
|
||||
expect(await bal(id)).toMatchObject({ pointsActive: 900, pointsPending: 0 });
|
||||
});
|
||||
|
||||
it('тот же номер заказа второй раз — нельзя; изменённая сумма — только с причиной', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1002);
|
||||
const base = { clientId: id, kind: 'ORDER' as const, categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB' as const };
|
||||
await expect(accruePoints(ctx, { ...base, amount: 2000, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_REASON_REQUIRED' });
|
||||
await accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER);
|
||||
await expect(accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_DUPLICATE_ORDER' });
|
||||
});
|
||||
|
||||
it('бонус со своим сроком; «без ожидания» у поддержки — только с причиной и в пределах лимитов', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1003);
|
||||
const promo = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'promo' } });
|
||||
await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, OWNER);
|
||||
const a = await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } });
|
||||
expect(a).toMatchObject({ status: 'ACTIVE', amount: 1000 });
|
||||
expect(a.expiresAt?.getTime()).toBe(NOW.getTime() + 30 * D);
|
||||
// поддержка без разрешения в настройках — нельзя
|
||||
const support = { adminId: 'op1', owner: false };
|
||||
await expect(give(ctx, id, 100, { orderRef: 'S1' }).then(() => accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, support))).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
await db.setting.create({ data: { key: 'points.supportCanManage', value: true } });
|
||||
const big = await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, amount: 25_000, serviceDate: NOW, channel: 'WEB' }, support);
|
||||
expect(big.status).toBe('needs_approval');
|
||||
});
|
||||
|
||||
it('серия уборок: третья подряд — +2 п.п. к ставке', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1004);
|
||||
const c = await cat();
|
||||
for (const [i, ref] of ['s1', 's2', 's3'].entries()) {
|
||||
await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: c.id, orderAmount: 10_000, serviceDate: new Date(NOW.getTime() - (40 - i * 20) * D), channel: 'WEB', orderRef: ref }, OWNER);
|
||||
}
|
||||
const last = await db.pointsAccrual.findFirstOrThrow({ where: { orderRef: 's3' } });
|
||||
expect(last).toMatchObject({ ratePct: 7, streakBonusPct: 2, amount: 700 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('барики: списание', () => {
|
||||
it('FIFO, лимит 30% и минимум 1000; два списания одновременно не уводят в минус', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1101);
|
||||
await give(ctx, id, 3000, { orderRef: 'o1', serviceDate: new Date(NOW.getTime() - 10 * D) });
|
||||
await give(ctx, id, 2000, { orderRef: 'o2' });
|
||||
await expect(redeemPoints(ctx, { clientId: id, amount: 500, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_BELOW_MIN' });
|
||||
await expect(redeemPoints(ctx, { clientId: id, amount: 4000, orderAmount: 10_000, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_OVER_SHARE' });
|
||||
const results = await Promise.allSettled([
|
||||
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'a' }, OWNER),
|
||||
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'b' }, OWNER),
|
||||
]);
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
expect((await bal(id)).pointsActive).toBe(1500);
|
||||
const red = await db.pointsRedemption.findFirstOrThrow({ where: { clientId: id, status: 'CONFIRMED' }, include: { items: { include: { accrual: true } } } });
|
||||
expect(red.items.map((i) => [i.accrual.orderRef, i.amount])).toEqual([
|
||||
['o1', 3000],
|
||||
['o2', 500],
|
||||
]);
|
||||
});
|
||||
|
||||
it('запрос клиента → команда подтверждает по сумме заказа', async () => {
|
||||
const { ctx, events } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1102);
|
||||
await give(ctx, id, 10_000, { orderRef: 'o1' });
|
||||
const r = await requestRedemption(ctx, { clientId: id, amount: 8000 });
|
||||
expect(events).toContainEqual({ type: 'points.requested', redemptionId: r.id });
|
||||
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: 18_000 }, OWNER);
|
||||
expect(res.amount).toBe(5400);
|
||||
expect((await bal(id)).pointsActive).toBe(4600);
|
||||
});
|
||||
|
||||
it('в заказе: барики уменьшают сумму к оплате, кэшбек — с оплаченного деньгами; отмена всё возвращает', async () => {
|
||||
const { ctx, clock } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1103);
|
||||
await give(ctx, id, 5000, { orderRef: 'o1' });
|
||||
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 3000 });
|
||||
expect(o.pointsUsed).toBe(3000);
|
||||
expect((await bal(id)).pointsActive).toBe(2000);
|
||||
await expect(createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 1000, promoCode: 'X' })).rejects.toBeDefined();
|
||||
// выполнен и оплачен — кэшбек с (цена − барики)
|
||||
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
|
||||
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
|
||||
await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.maria.id } });
|
||||
clock.now = new Date(slotAt(2, 14).getTime());
|
||||
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
|
||||
const acc = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id } });
|
||||
expect(acc).toMatchObject({ channel: 'AUTO', paidWithPoints: 3000, amount: Math.floor(((o.totalPrice - 3000) * 5) / 100), status: 'PENDING' });
|
||||
const pay = await db.payment.findFirstOrThrow({ where: { orderId: o.id } });
|
||||
expect(pay.amount).toBe(o.totalPrice - 3000);
|
||||
});
|
||||
|
||||
it('отмена заказа клиентом возвращает списанные барики', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1104);
|
||||
await give(ctx, id, 5000, { orderRef: 'o1' });
|
||||
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 2000 });
|
||||
expect((await bal(id)).pointsActive).toBe(3000);
|
||||
await cancelByClient(ctx, { orderId: o.id, clientId: id });
|
||||
expect((await bal(id)).pointsActive).toBe(5000);
|
||||
expect(await db.pointsReversal.count({ where: { targetType: 'REDEMPTION' } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('барики: отмена и сгорание', () => {
|
||||
it('отмена частично потраченного — минус с предупреждением, гасится следующим начислением', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1201);
|
||||
const a = await give(ctx, id, 3000, { orderRef: 'o1' });
|
||||
await redeemPoints(ctx, { clientId: id, amount: 2000, channel: 'WEB', reason: 'звонок' }, OWNER);
|
||||
if (a.status !== 'created') throw new Error();
|
||||
expect(await reversePreview(db, { type: 'ACCRUAL', id: a.accrualId })).toMatchObject({ spent: 2000, balanceAfter: -2000 });
|
||||
await reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'возврат', channel: 'WEB' }, OWNER);
|
||||
expect(await bal(id)).toMatchObject({ pointsActive: -2000, pointsDebt: 2000 });
|
||||
const orig = await db.pointsAccrual.findUniqueOrThrow({ where: { id: a.accrualId } });
|
||||
expect(orig).toMatchObject({ status: 'REVERSED', amount: 3000 });
|
||||
await give(ctx, id, 2500, { orderRef: 'o2' });
|
||||
expect(await bal(id)).toMatchObject({ pointsActive: 500, pointsDebt: 0 });
|
||||
await expect(reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'ещё', channel: 'WEB' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_FINAL' });
|
||||
});
|
||||
|
||||
it('отмена списания возвращает барики в исходные начисления', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1202);
|
||||
await give(ctx, id, 3000, { orderRef: 'o1' });
|
||||
const r = await redeemPoints(ctx, { clientId: id, amount: 3000, channel: 'WEB', reason: 'x' }, OWNER);
|
||||
expect((await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).status).toBe('SPENT');
|
||||
await reverseRedemption(ctx, { redemptionId: r.redemptionId!, reason: 'ошибка', channel: 'WEB' }, OWNER);
|
||||
expect(await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).toMatchObject({ status: 'ACTIVE', remaining: 3000 });
|
||||
});
|
||||
|
||||
it('сгорание через 120 дней без активности, предупреждения за 14 и 3 дня', async () => {
|
||||
const { ctx, clock, events } = makeCtx(NOW);
|
||||
const id = await client(ctx, 1203);
|
||||
await give(ctx, id, 3250, { orderRef: 'o1' });
|
||||
clock.now = new Date(NOW.getTime() + 107 * D);
|
||||
await warnExpiringPoints(ctx);
|
||||
expect(events).toContainEqual({ type: 'points.expiring', clientId: id, amount: 3250, days: 13 });
|
||||
clock.now = new Date(NOW.getTime() + 118 * D);
|
||||
await warnExpiringPoints(ctx);
|
||||
await warnExpiringPoints(ctx);
|
||||
expect(events.filter((e) => e.type === 'points.expiring')).toHaveLength(2);
|
||||
clock.now = new Date(NOW.getTime() + 121 * D);
|
||||
expect(await burnPoints(ctx)).toBe(3250);
|
||||
expect((await bal(id)).pointsActive).toBe(0);
|
||||
expect(await reconcilePoints(db)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('новоселье от агентства — один раз', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
await db.agency.update({ where: { id: fx.agency.id }, data: { housewarmingPoints: 5000, housewarmingDays: 60 } });
|
||||
const id = await client(ctx, 1204);
|
||||
await applyAgencyCode(ctx, id, 'DOMIK');
|
||||
await applyAgencyCode(ctx, id, 'DOMIK');
|
||||
expect((await bal(id)).pointsActive).toBe(5000);
|
||||
expect(await db.pointsAccrual.count({ where: { clientId: id } })).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user