daa41bcecf
Решение заказчика 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>
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
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 }));
|
||
});
|
||
});
|