Files
portal/app/tests/Frontend/SalesTariffs.spec.ts
T
Дмитрий daa41bcecf feat(sales): пересмотр состава тарифов портала продаж — 2 вида
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный
оклад daily_salary и процент от пополнений topup_step. Убраны
percent_oborot и fix_per_client.

Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает
FK sales_users.current_tariff_id и sales_client_assignments.tariff_id,
удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов
возвращает 0 по default-ветке. Снимки привязок не трогаются.

Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics,
фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта,
CHANGELOG схемы v8.61 и спека портала.

Не на проде: ветка feat/sales-portal-demo.

Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0.

Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
2026-07-05 19:25:19 +03:00

249 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import SalesTariffsView from '../../resources/js/views/sales/SalesTariffsView.vue';
// ─── mock api/sales ───────────────────────────────────────────────────────────
// vi.mock hoisted к верху файла → мок-данные объявляем ВНУТРИ фабрики.
// Модель 03.07.2026: ДВА вида — topup_step (порог+бонус+ступени) и daily_salary (суточный оклад).
vi.mock('../../resources/js/api/sales', () => {
const tariffs = [
{
id: 1,
name: 'Стандарт-пополнения',
kind: 'topup_step',
params: {
threshold: 30000,
reward: 10000,
periods: [
{ from: 1, to: 3, rate: 10 },
{ from: 4, to: 6, rate: 7 },
],
},
},
{
id: 2,
name: 'Суточный оклад',
kind: 'daily_salary',
params: {},
},
];
const managers = [
{
id: 11,
name: 'Алексей Смирнов',
current_tariff_id: 2, // на суточном окладе
current_tariff_name: 'Суточный оклад',
current_tariff_kind: 'daily_salary',
base_salary_rub: 2000,
clients_count: 7,
topups_rub: 240000,
oborot_rub: 235050,
estimated_earned_rub: 44000,
},
{
id: 22,
name: 'Мария Котова',
current_tariff_id: 1, // на проценте от пополнений
current_tariff_name: 'Стандарт-пополнения',
current_tariff_kind: 'topup_step',
base_salary_rub: 0,
clients_count: 5,
topups_rub: 168000,
oborot_rub: 165300,
estimated_earned_rub: 54795,
},
];
return {
listSalesTariffs: vi.fn().mockResolvedValue({ tariffs, managers }),
createSalesTariff: vi
.fn()
.mockResolvedValue({
id: 99,
name: 'Новый',
kind: 'topup_step',
params: { threshold: 0, reward: 0, periods: [] },
}),
updateSalesTariff: vi
.fn()
.mockResolvedValue({ id: 1, name: 'Стандарт-пополнения', kind: 'topup_step', params: {} }),
assignSalesTariff: vi
.fn()
.mockResolvedValue({ id: 11, name: 'Алексей Смирнов', current_tariff_id: 1, base_salary_rub: 0 }),
extractSalesErrorMessage: vi.fn().mockReturnValue('Произошла ошибка. Попробуйте позже.'),
};
});
beforeEach(() => vi.clearAllMocks());
// ─── helpers ──────────────────────────────────────────────────────────────────
function makeRouter() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/sales/tariffs', name: 'sales-tariffs', component: SalesTariffsView },
{ path: '/sales', name: 'sales-overview', component: { template: '<div/>' } },
],
});
return router.push('/sales/tariffs').then(() => router);
}
async function mountView() {
const router = await makeRouter();
const wrapper = mount(SalesTariffsView, {
global: { plugins: [createVuetify(), createPinia(), router] },
});
await flushPromises();
return wrapper;
}
describe('SalesTariffsView', () => {
it('вызывает listSalesTariffs при монтировании', async () => {
await mountView();
const api = await import('../../resources/js/api/sales');
expect(api.listSalesTariffs).toHaveBeenCalled();
});
it('рендерит два вида тарифов', async () => {
const wrapper = await mountView();
const text = wrapper.text();
// Заголовки двух секций
expect(text).toContain('Процент от пополнений');
expect(text).toContain('Суточный оклад');
// По карточке/строке на каждый вид
expect(wrapper.find('[data-testid="tariff-card-1"]').exists()).toBe(true); // topup_step
expect(wrapper.find('[data-testid="daily-row-2"]').exists()).toBe(true); // daily_salary
// Названия тарифов лежат в input value (v-text-field)
const nameInput = wrapper.find('[data-testid="tariff-name-1"] input').element as HTMLInputElement;
expect(nameInput.value).toBe('Стандарт-пополнения');
// Порог и бонус — поля topup_step
expect(wrapper.find('[data-testid="tariff-threshold-1"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tariff-reward-1"]').exists()).toBe(true);
});
it('рендерит таблицу менеджеров с оценкой начисления', async () => {
const wrapper = await mountView();
const text = wrapper.text();
expect(text).toContain('Алексей Смирнов');
expect(text).toContain('Мария Котова');
// estimated_earned_rub форматируется ru-RU
expect(text).toContain('44');
expect(text).toContain('54');
});
it('«добавить период» добавляет строку в topup_step-тариф', async () => {
const wrapper = await mountView();
const before = wrapper.findAll('[data-testid="period-row"]').length;
const addBtn = wrapper.find('[data-testid="add-period-1"]');
expect(addBtn.exists()).toBe(true);
await addBtn.trigger('click');
await flushPromises();
const after = wrapper.findAll('[data-testid="period-row"]').length;
expect(after).toBe(before + 1);
});
it('сохранение topup_step-тарифа кладёт threshold, reward и periods в params', async () => {
const wrapper = await mountView();
const api = await import('../../resources/js/api/sales');
const saveBtn = wrapper.find('[data-testid="save-tariff-1"]');
expect(saveBtn.exists()).toBe(true);
await saveBtn.trigger('click');
await flushPromises();
expect(api.updateSalesTariff).toHaveBeenCalledWith(
1,
expect.objectContaining({
name: 'Стандарт-пополнения',
params: expect.objectContaining({
threshold: 30000,
reward: 10000,
periods: expect.arrayContaining([expect.objectContaining({ from: 1, to: 3, rate: 10 })]),
}),
}),
);
});
it('создание нового topup_step-тарифа вызывает createSalesTariff с kind=topup_step', async () => {
const wrapper = await mountView();
const api = await import('../../resources/js/api/sales');
const nameInput = wrapper.find('[data-testid="new-topup-name"] input');
expect(nameInput.exists()).toBe(true);
await nameInput.setValue('Мой новый тариф');
const createBtn = wrapper.find('[data-testid="create-topup"]');
await createBtn.trigger('click');
await flushPromises();
expect(api.createSalesTariff).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Мой новый тариф',
kind: 'topup_step',
params: expect.objectContaining({ threshold: 0, reward: 0, periods: expect.any(Array) }),
}),
);
});
it('сохранение daily_salary-тарифа вызывает updateSalesTariff с пустыми params', async () => {
const wrapper = await mountView();
const api = await import('../../resources/js/api/sales');
const saveBtn = wrapper.find('[data-testid="save-tariff-2"]');
expect(saveBtn.exists()).toBe(true);
await saveBtn.trigger('click');
await flushPromises();
expect(api.updateSalesTariff).toHaveBeenCalledWith(
2,
expect.objectContaining({ name: 'Суточный оклад', params: {} }),
);
});
it('создание нового daily_salary-тарифа вызывает createSalesTariff с kind=daily_salary', async () => {
const wrapper = await mountView();
const api = await import('../../resources/js/api/sales');
const nameInput = wrapper.find('[data-testid="new-daily-name"] input');
expect(nameInput.exists()).toBe(true);
await nameInput.setValue('Оклад стажёра');
const createBtn = wrapper.find('[data-testid="create-daily"]');
await createBtn.trigger('click');
await flushPromises();
expect(api.createSalesTariff).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Оклад стажёра', kind: 'daily_salary', params: {} }),
);
});
it('менеджер на суточном окладе → поле «Ставка, ₽/день» + assign со ставкой', async () => {
const wrapper = await mountView();
const api = await import('../../resources/js/api/sales');
// Смирнов (id 11) на daily_salary (tariff 2) → поле ставки видно.
expect(wrapper.find('[data-testid="assign-salary-11"]').exists()).toBe(true);
const saveBtn = wrapper.find('[data-testid="save-assign-11"]');
expect(saveBtn.exists()).toBe(true);
await saveBtn.trigger('click');
await flushPromises();
expect(api.assignSalesTariff).toHaveBeenCalledWith(
expect.objectContaining({ manager_id: 11, tariff_id: 2, base_salary_rub: 2000 }),
);
});
it('смена тарифа менеджера на процент + сохранение вызывает assignSalesTariff без ставки', async () => {
const wrapper = await mountView();
const api = await import('../../resources/js/api/sales');
const vm = wrapper.vm as unknown as {
managers: Array<{ id: number; current_tariff_id: number | null }>;
};
// Котова (index 1) уже на topup_step (1); переключим Смирнова (index 0) на topup_step.
vm.managers[0].current_tariff_id = 1;
await flushPromises();
const saveBtn = wrapper.find('[data-testid="save-assign-11"]');
await saveBtn.trigger('click');
await flushPromises();
expect(api.assignSalesTariff).toHaveBeenCalledWith(expect.objectContaining({ manager_id: 11, tariff_id: 1 }));
});
});