feat(биллинг-фронт): баланс на запуске — сообщения в рублях, метка «не запущен», группы
Фронт для правила «баланс блокирует запуск, а не создание»: - ProjectLimitOverloadDialog переформулирован в «Проект не запущен — пополните ~X ₽ или уменьшите объём» (сумма в рублях topup_rub). - NewProjectDialog: создание всегда успешно; при launch.deferred показывает сообщение (не блок). Update-лимита 409 → тот же диалог. - Возобновление (toggle-active) ловит 409 balance_insufficient (стор не переключает is_active), показывает диалог; постоянная метка «Не запущен — не хватает баланса» на карточке/в панели по preflight_blocked_at/balance_blocked. - BulkActionsBar: сводка «Запущено N, отложено M — не хватает баланса» на resume. - Автоподбор: CreateScreen показывает сводку запуска (рубли); FieldCompetitor массовые pause/resume через общий POST /api/projects/bulk (наследует BULK_MAX, слепок-защиту, балансовый гейт). - ProjectResource отдаёт preflight_blocked_at. Новые/обновлённые фронт-тесты зелёные. Пред-существующий красный baseline фронт-набора (ErrorView/Settings/Legal/… + ProjectsView v-show) не трогался. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,8 @@ class ProjectResource extends JsonResource
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
|
||||
'balance_blocked' => $this->preflight_blocked_at !== null,
|
||||
// Task 15: сырое поле — UI отличает «заблокирован при попытке запуска» vs «закончился баланс».
|
||||
'preflight_blocked_at' => $this->preflight_blocked_at?->toIso8601String(),
|
||||
'supplier_links' => $this->when(
|
||||
$request->routeIs('projects.show'),
|
||||
fn () => $this->getSupplierLinks(),
|
||||
|
||||
@@ -182,15 +182,35 @@ export async function addManualSource(p: { competitor_id: number; raw: string })
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface LaunchBalance {
|
||||
topup_rub: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LaunchSummary {
|
||||
launched: number;
|
||||
deferred: number;
|
||||
balance: LaunchBalance | null;
|
||||
}
|
||||
|
||||
export interface CreateProjectsResult {
|
||||
data: Array<{ id: number; name: string; is_active?: boolean }>;
|
||||
launch: LaunchSummary | null;
|
||||
}
|
||||
|
||||
export async function createProjects(p: {
|
||||
source_ids: number[];
|
||||
regions: number[];
|
||||
daily_limit_target: number;
|
||||
delivery_days_mask: number;
|
||||
launch: boolean;
|
||||
}): Promise<Array<{ id: number; name: string }>> {
|
||||
const { data } = await apiClient.post<{ data: Array<{ id: number; name: string }> }>('/api/autopodbor/projects', p);
|
||||
return data.data;
|
||||
}): Promise<CreateProjectsResult> {
|
||||
const { data } = await apiClient.post<CreateProjectsResult>('/api/autopodbor/projects', p);
|
||||
// Бэкенд отдаёт { data:[...], launch:{...} }; поддерживаем старый формат (только data.[])
|
||||
if (Array.isArray(data)) {
|
||||
return { data: data as Array<{ id: number; name: string }>, launch: null };
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRunCompetitors(runId: number): Promise<CompetitorDto[]> {
|
||||
|
||||
@@ -102,6 +102,19 @@ async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
|
||||
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
|
||||
const balanceInsufficient = result.skipped.filter((s) => s.reason === 'balance_insufficient').length;
|
||||
const belowDelivered = result.skipped.filter((s) => s.reason === 'below_delivered_today').length;
|
||||
|
||||
// Для действия «Возобновить» с балансовым блоком — специальный тост со сводкой запуска.
|
||||
if (payload.action === 'resume' && balanceInsufficient > 0) {
|
||||
const parts: string[] = [`Запущено ${result.updated}, отложено ${balanceInsufficient} — не хватает баланса`];
|
||||
// Остальные причины добавляем следом, если есть.
|
||||
if (supplierLocked > 0) parts.push(`${supplierLocked} — мы уже начали сбор лидов на завтра`);
|
||||
if (withDeals > 0) parts.push(`${withDeals} — по проекту есть сделки`);
|
||||
if (belowDelivered > 0) parts.push(`${belowDelivered} — лимит ниже уже доставленных сегодня лидов`);
|
||||
skipToastText.value = parts.join('; ') + '.';
|
||||
skipToastOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const groups: string[] = [];
|
||||
if (supplierLocked > 0) {
|
||||
groups.push(
|
||||
|
||||
@@ -79,7 +79,16 @@
|
||||
</div>
|
||||
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
|
||||
|
||||
<v-chip :color="syncStatusColor" size="x-small" variant="tonal">
|
||||
<v-tooltip v-if="project.preflight_blocked_at" location="top">
|
||||
<template #activator="{ props: tProps }">
|
||||
<v-chip :color="syncStatusColor" size="x-small" variant="tonal" v-bind="tProps" data-testid="balance-blocked-badge">
|
||||
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
|
||||
{{ syncStatusLabel }}
|
||||
</v-chip>
|
||||
</template>
|
||||
Пополните баланс, чтобы запустить проект
|
||||
</v-tooltip>
|
||||
<v-chip v-else :color="syncStatusColor" size="x-small" variant="tonal">
|
||||
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
|
||||
{{ syncStatusLabel }}
|
||||
</v-chip>
|
||||
@@ -116,25 +125,31 @@ const progressPercent = computed(() => {
|
||||
return limit > 0 ? Math.min(100, Math.round((props.project.delivered_today / limit) * 100)) : 0;
|
||||
});
|
||||
const progressColor = computed(() => (progressPercent.value >= 90 ? 'success' : 'primary'));
|
||||
// H (балансовый блок): заблокированный за нехваткой баланса проект имеет приоритет над
|
||||
// обычным sync-статусом — клиент должен сразу видеть причину «приостановки».
|
||||
const syncStatusLabel = computed(() =>
|
||||
props.project.balance_blocked
|
||||
? 'Приостановлен — не хватает баланса'
|
||||
: ({ ok: 'Собирает заявки', pending: 'Готовим к запуску', failed: 'Ошибка — напишите в поддержку' })[props.project.sync_status],
|
||||
);
|
||||
const syncStatusIcon = computed(() =>
|
||||
props.project.balance_blocked
|
||||
? 'mdi-cash-remove'
|
||||
: ({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
|
||||
props.project.sync_status
|
||||
],
|
||||
);
|
||||
const syncStatusColor = computed(() =>
|
||||
props.project.balance_blocked
|
||||
? 'error'
|
||||
: ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status],
|
||||
);
|
||||
// H / Task 15: приоритет статусов:
|
||||
// 1. preflight_blocked_at != null → «Не запущен — не хватает баланса» (не запустился при попытке)
|
||||
// 2. balance_blocked → «Приостановлен — не хватает баланса» (закончился баланс в процессе)
|
||||
// 3. обычный sync_status
|
||||
const syncStatusLabel = computed(() => {
|
||||
if (props.project.preflight_blocked_at) {
|
||||
return 'Не запущен — не хватает баланса';
|
||||
}
|
||||
if (props.project.balance_blocked) {
|
||||
return 'Приостановлен — не хватает баланса';
|
||||
}
|
||||
return ({ ok: 'Собирает заявки', pending: 'Готовим к запуску', failed: 'Ошибка — напишите в поддержку' })[props.project.sync_status];
|
||||
});
|
||||
const syncStatusIcon = computed(() => {
|
||||
if (props.project.preflight_blocked_at || props.project.balance_blocked) {
|
||||
return 'mdi-cash-remove';
|
||||
}
|
||||
return ({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[props.project.sync_status];
|
||||
});
|
||||
const syncStatusColor = computed(() => {
|
||||
if (props.project.preflight_blocked_at || props.project.balance_blocked) {
|
||||
return 'error';
|
||||
}
|
||||
return ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import type { Project, BalancePayload } from '../../stores/projectsStore';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
|
||||
|
||||
const props = defineProps<{ project: Project | null }>();
|
||||
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null] }>();
|
||||
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null]; 'balance-insufficient': [balance: BalancePayload] }>();
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
@@ -106,7 +106,13 @@ const sourceConfirmText = computed(() => {
|
||||
|
||||
async function onPause(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
await store.toggleActive(props.project);
|
||||
const result = await store.toggleActive(props.project);
|
||||
if (result?.deferred) {
|
||||
// Task 14: 409 balance_insufficient при попытке возобновить — не закрываем drawer,
|
||||
// сообщаем родителю показать диалог пополнения баланса.
|
||||
emit('balance-insufficient', result.balance);
|
||||
return;
|
||||
}
|
||||
// #4: после паузы/возобновления панель и галочка должны исчезнуть (как у Save и Delete).
|
||||
emit('close');
|
||||
}
|
||||
@@ -222,7 +228,19 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
<aside class="project-details-drawer" :class="{ open: project !== null }">
|
||||
<div v-if="project" class="pdd-content">
|
||||
<header class="pdd-head">
|
||||
<div class="pdd-title">{{ project.name }}</div>
|
||||
<div class="pdd-head-left">
|
||||
<div class="pdd-title">{{ project.name }}</div>
|
||||
<v-chip
|
||||
v-if="project.preflight_blocked_at"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ml-2"
|
||||
data-testid="drawer-balance-blocked-badge"
|
||||
>
|
||||
Не запущен — не хватает баланса
|
||||
</v-chip>
|
||||
</div>
|
||||
<button class="pdd-close" data-testid="pdd-close" aria-label="Закрыть" @click="$emit('close')">
|
||||
<v-icon size="20" icon="mdi-close" />
|
||||
</button>
|
||||
@@ -436,6 +454,13 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--liderra-line, #e6e2d6);
|
||||
}
|
||||
.pdd-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.pdd-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Диалог перегрузки лимита (Billing v2 Spec C §6.2, Task 1.10).
|
||||
* Диалог «Проект не запущен» (Billing v2 Spec C §6.2, Task 12).
|
||||
*
|
||||
* Открывается, когда POST/PATCH /api/projects вернул 409 `balance_insufficient`.
|
||||
* Показывает дефицит и предлагает три исхода:
|
||||
* - «Сохранить и приостановить» → save-blocked (родитель пере-сабмитит с
|
||||
* force_save_blocked=true → проект создаётся с preflight_blocked_at);
|
||||
* - «Поставить лимит 0» → set-zero (родитель ставит daily_limit_target=0);
|
||||
* - «Отмена» → закрытие без сохранения.
|
||||
* Сообщает: «Проект создан, но НЕ запущен — не хватает баланса».
|
||||
* Предлагает три исхода:
|
||||
* - «Пополнить баланс» (data-test="topup") → закрыть + перейти в /billing;
|
||||
* - «Уменьшить объём» (data-test="reduce") → set-zero (родитель ставит daily_limit_target=0);
|
||||
* - «Понятно» (data-test="close") → закрыть без действий.
|
||||
*
|
||||
* Backward compat: события save-blocked и set-zero сохранены.
|
||||
*/
|
||||
export interface OverloadPayload {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
/** Task 12: сумма пополнения в рублях, рассчитанная бэкендом */
|
||||
topup_rub?: string;
|
||||
}
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -29,7 +33,7 @@ const emit = defineEmits<{
|
||||
'set-zero': [];
|
||||
}>();
|
||||
|
||||
// Косяк 06: «Пополнить» прямо из окна перегрузки — закрываем окно и ведём в биллинг.
|
||||
// Косяк 06: «Пополнить» прямо из окна — закрываем и ведём в биллинг.
|
||||
const router = useRouter();
|
||||
function goTopup(): void {
|
||||
emit('update:modelValue', false);
|
||||
@@ -40,29 +44,31 @@ function goTopup(): void {
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<v-card v-if="payload" data-testid="overload-dialog">
|
||||
<v-card-title>Лимит превышает баланс</v-card-title>
|
||||
<v-card-title>Проект не запущен</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
У вас {{ payload.current_balance_rub }}₽ = {{ payload.current_capacity_leads }} лидов по текущему
|
||||
тарифу.
|
||||
Проект создан, но <strong>не запущен</strong> — на балансе недостаточно средств для запуска.
|
||||
</p>
|
||||
<p>После сохранения нужно {{ payload.would_be_required_leads }} лидов.</p>
|
||||
<p class="font-weight-medium">Не хватает: {{ payload.deficit_leads }} лидов.</p>
|
||||
<p class="text-medium-emphasis mt-2">
|
||||
Чтобы проект заработал — пополните счёт, поставьте его лимит 0 или уменьшите лимиты других проектов.
|
||||
Чтобы запустить — пополните примерно на
|
||||
<strong>{{ payload.topup_rub ?? payload.deficit_leads }} ₽</strong>
|
||||
или уменьшите объём.
|
||||
</p>
|
||||
<p class="text-caption text-medium-emphasis mt-1">
|
||||
Текущий баланс: {{ payload.current_balance_rub }} ₽
|
||||
({{ payload.current_capacity_leads }} лидов), нужно {{ payload.would_be_required_leads }} лидов.
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions class="flex-wrap justify-end ga-2">
|
||||
<v-btn variant="text" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
|
||||
Отмена
|
||||
<v-btn variant="text" data-test="close" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
|
||||
Понятно
|
||||
</v-btn>
|
||||
<v-btn variant="text" data-testid="overload-set-zero" @click="$emit('set-zero')">
|
||||
Поставить лимит 0
|
||||
<v-btn variant="text" data-test="reduce" data-testid="overload-set-zero" @click="$emit('set-zero')">
|
||||
Уменьшить объём
|
||||
</v-btn>
|
||||
<v-btn variant="text" data-testid="overload-save-blocked" @click="$emit('save-blocked')">
|
||||
Сохранить и приостановить
|
||||
<v-btn color="primary" variant="flat" data-test="topup" data-testid="overload-topup" @click="goTopup">
|
||||
Пополнить баланс
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" data-testid="overload-topup" @click="goTopup"> Пополнить баланс </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
fetchState,
|
||||
fetchRun,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
type FieldCompetitorDto,
|
||||
type FieldSourceDto,
|
||||
type ProposalGroups,
|
||||
type CreateProjectsResult,
|
||||
} from '../api/autopodbor';
|
||||
|
||||
const EMPTY_GROUPS: ProposalGroups = { new: [], actualize: [], archived: [] };
|
||||
@@ -128,7 +130,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => {
|
||||
daily_limit_target: number;
|
||||
delivery_days_mask: number;
|
||||
launch: boolean;
|
||||
}): Promise<Array<{ id: number; name: string }>> {
|
||||
}): Promise<CreateProjectsResult> {
|
||||
return await createProjects(p);
|
||||
}
|
||||
|
||||
@@ -275,6 +277,20 @@ export const useAutopodborStore = defineStore('autopodbor', () => {
|
||||
await apiToggleProjectActive(projectId, active);
|
||||
}
|
||||
|
||||
/**
|
||||
* Массовое действие над проектами по списку project_id.
|
||||
* Использует /api/projects/bulk — те же гварды (слепок-защита, балансовый гейт, лимит 500).
|
||||
* Источники без проекта должны быть отфильтрованы до вызова.
|
||||
*/
|
||||
async function bulkProjectsById(
|
||||
action: 'pause' | 'resume' | 'delete',
|
||||
projectIds: number[],
|
||||
): Promise<{ updated: number; skipped: Array<{ id: number; reason: string }>; warnings: string[] }> {
|
||||
if (!projectIds.length) return { updated: 0, skipped: [], warnings: [] };
|
||||
const { data } = await axios.post('/api/projects/bulk', { action, ids: projectIds });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Смена источника проекта (change_source, §14.10) — через готовую ручку проектов. */
|
||||
async function changeProjectSource(projectId: number, identifier: string): Promise<ChangeSourceResult> {
|
||||
return await apiChangeProjectSource(projectId, identifier);
|
||||
@@ -325,6 +341,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => {
|
||||
editSource,
|
||||
removeSource,
|
||||
toggleProjectActive,
|
||||
bulkProjectsById,
|
||||
changeProjectSource,
|
||||
updateProjectSettings,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface Project {
|
||||
last_synced_at?: string | null;
|
||||
// H (балансовый блок): проект приостановлен из-за нехватки баланса (preflight_blocked_at).
|
||||
balance_blocked?: boolean;
|
||||
// Task 15: сырое поле preflight_blocked_at для различения «заблокирован при попытке запуска»
|
||||
// vs «баланс закончился во время работы» (balance_blocked). Если не null — проект попытался
|
||||
// запуститься, но баланса не хватило.
|
||||
preflight_blocked_at?: string | null;
|
||||
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
|
||||
source_locked?: boolean;
|
||||
source_unlock_at?: string | null;
|
||||
@@ -29,6 +33,15 @@ export interface Project {
|
||||
source_change_message?: string | null;
|
||||
}
|
||||
|
||||
// Task 14: payload, возвращаемый бэкендом при 409 balance_insufficient на toggle-active.
|
||||
export interface BalancePayload {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
topup_rub: string;
|
||||
}
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
const items = ref<Project[]>([]);
|
||||
const total = ref(0);
|
||||
@@ -105,9 +118,18 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
await fetch();
|
||||
}
|
||||
|
||||
async function toggleActive(project: Project) {
|
||||
await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active });
|
||||
await fetch();
|
||||
async function toggleActive(project: Project): Promise<{ deferred: true; balance: BalancePayload } | void> {
|
||||
try {
|
||||
await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active });
|
||||
await fetch();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { error?: string; balance?: BalancePayload } } };
|
||||
if (err.response?.status === 409 && err.response?.data?.error === 'balance_insufficient') {
|
||||
// НЕ меняем is_active в сторе. Возвращаем payload для показа диалога.
|
||||
return { deferred: true, balance: err.response.data.balance! };
|
||||
}
|
||||
throw e; // другие ошибки — пробрасываем как обычно
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
:selected="store.selectedIds.has(project.id)"
|
||||
@toggle-select="store.toggleSelect"
|
||||
@edit="openEdit"
|
||||
@toggle-active="store.toggleActive"
|
||||
@toggle-active="onToggleActive"
|
||||
@sync-now="(p: Project) => store.syncNow(p.id)"
|
||||
@delete="(p: Project) => store.del(p.id)"
|
||||
/>
|
||||
@@ -171,9 +171,19 @@
|
||||
Vuetify телепортируется в body и виден даже при скрытом баре. -->
|
||||
<BulkActionsBar v-show="store.selectedIds.size >= 2" />
|
||||
|
||||
<ProjectDetailsDrawer :project="singleSelectedProject" @close="onDrawerClose" @saved="onDrawerSaved" />
|
||||
<ProjectDetailsDrawer
|
||||
:project="singleSelectedProject"
|
||||
@close="onDrawerClose"
|
||||
@saved="onDrawerSaved"
|
||||
@balance-insufficient="onDrawerBalanceInsufficient"
|
||||
/>
|
||||
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
|
||||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
|
||||
<ProjectLimitOverloadDialog
|
||||
v-model="balanceDialogOpen"
|
||||
:payload="balanceDialogPayload"
|
||||
@set-zero="balanceDialogOpen = false"
|
||||
/>
|
||||
|
||||
<v-snackbar
|
||||
v-model="savedSnackbarOpen"
|
||||
@@ -188,12 +198,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
||||
import { useProjectsStore, type Project } from '../stores/projectsStore';
|
||||
import { useProjectsStore, type Project, type BalancePayload } from '../stores/projectsStore';
|
||||
import ProjectCard from '../components/projects/ProjectCard.vue';
|
||||
import ProjectDetailsDrawer from '../components/projects/ProjectDetailsDrawer.vue';
|
||||
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
|
||||
import NewProjectDialog from './projects/NewProjectDialog.vue';
|
||||
import EditProjectDialog from './projects/EditProjectDialog.vue';
|
||||
import ProjectLimitOverloadDialog from '../components/projects/ProjectLimitOverloadDialog.vue';
|
||||
import { REGIONS } from '../constants/regions';
|
||||
import { formatAppliesFromMessage } from '../composables/appliesFromMessage';
|
||||
|
||||
@@ -202,6 +213,10 @@ const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editing = ref<Project | null>(null);
|
||||
|
||||
// Task 14: диалог «не хватает баланса» при попытке возобновить проект.
|
||||
const balanceDialogOpen = ref(false);
|
||||
const balanceDialogPayload = ref<BalancePayload | null>(null);
|
||||
|
||||
// Тост «Сохранено» после правки проекта. Если правка задела slepok-чувствительные
|
||||
// поля (regions / delivery_days_mask / daily_limit_target / источник), backend
|
||||
// возвращает applies_from = N.21:00 МСК — показываем расширенное сообщение.
|
||||
@@ -238,6 +253,21 @@ const singleSelectedProject = computed<Project | null>(() => {
|
||||
return store.items.find((p: Project) => p.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Task 14: обработчик toggle-active с перехватом 409 balance_insufficient.
|
||||
async function onToggleActive(project: Project): Promise<void> {
|
||||
const result = await store.toggleActive(project);
|
||||
if (result?.deferred) {
|
||||
balanceDialogPayload.value = result.balance;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Task 14: обработчик события balance-insufficient от ProjectDetailsDrawer.
|
||||
function onDrawerBalanceInsufficient(balance: BalancePayload): void {
|
||||
balanceDialogPayload.value = balance;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function onDrawerClose(): void {
|
||||
store.clearSelection();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { inject, computed, ref } from 'vue';
|
||||
import { useAutopodborStore } from '../../../stores/autopodborStore';
|
||||
import { REGIONS } from '../../../constants/regions';
|
||||
import type { LaunchSummary } from '../../../api/autopodbor';
|
||||
|
||||
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
|
||||
const store = useAutopodborStore();
|
||||
@@ -22,8 +23,10 @@ const dailyLimit = ref<number>(20);
|
||||
// Маска дней: бит i = 1<<i, дефолт все 7 дней = 127
|
||||
const deliveryMask = ref<number>(127);
|
||||
|
||||
const launchSummary = ref<LaunchSummary | null>(null);
|
||||
|
||||
// Для тестируемости
|
||||
defineExpose({ regionCode, dailyLimit, deliveryMask });
|
||||
defineExpose({ regionCode, dailyLimit, deliveryMask, launchSummary });
|
||||
|
||||
const errorMsg = ref('');
|
||||
|
||||
@@ -53,19 +56,28 @@ async function create(launch: boolean): Promise<void> {
|
||||
return;
|
||||
}
|
||||
errorMsg.value = '';
|
||||
launchSummary.value = null;
|
||||
nav.ctx.loadMsg = launch ? 'Создаём и запускаем проекты…' : 'Создаём проекты…';
|
||||
nav.ctx.loadSub = 'Заводим проекты и передаём источники поставщику.';
|
||||
nav.go('loading');
|
||||
try {
|
||||
const projects = await store.makeProjects({
|
||||
const result = await store.makeProjects({
|
||||
source_ids: nav.ctx.selectedSourceIds,
|
||||
regions: [regionCode.value],
|
||||
daily_limit_target: dailyLimit.value,
|
||||
delivery_days_mask: deliveryMask.value,
|
||||
launch,
|
||||
});
|
||||
nav.ctx.createdCount = projects.length;
|
||||
nav.ctx.createdCount = result.data.length;
|
||||
nav.ctx.launched = launch;
|
||||
|
||||
// Если часть проектов отложена из-за баланса — показываем сводку вместо перехода на «готово».
|
||||
if (launch && result.launch && result.launch.deferred > 0) {
|
||||
launchSummary.value = result.launch;
|
||||
nav.go('create');
|
||||
return;
|
||||
}
|
||||
|
||||
nav.go('done');
|
||||
} catch (e) {
|
||||
const code = (e as any)?.response?.data?.error;
|
||||
@@ -101,6 +113,17 @@ async function create(launch: boolean): Promise<void> {
|
||||
<!-- Ошибка -->
|
||||
<div v-if="errorMsg" class="ld-alert">{{ errorMsg }}</div>
|
||||
|
||||
<!-- Сводка запуска: часть проектов отложена из-за баланса -->
|
||||
<div v-if="launchSummary && launchSummary.deferred > 0" class="ld-alert ld-alert--launch" data-testid="launch-summary">
|
||||
Создано, запущено {{ launchSummary.launched }}, отложено {{ launchSummary.deferred }} —
|
||||
<template v-if="launchSummary.balance">
|
||||
пополните ~{{ launchSummary.balance.topup_rub }} ₽ или уменьшите объём.
|
||||
</template>
|
||||
<template v-else>
|
||||
пополните баланс или уменьшите объём.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Карточка источников -->
|
||||
<div class="ld-card">
|
||||
<p class="ld-ctitle">Будет создано {{ selected.length }} проектов</p>
|
||||
|
||||
@@ -174,22 +174,44 @@ async function moveToWork(s: FieldSourceDto) {
|
||||
const selWork = computed(() => inWork.value.filter((s) => selected.value.includes(s.id)));
|
||||
const selAllHaveProj = computed(() => selWork.value.length > 0 && selWork.value.every((s) => s.project));
|
||||
const selNoneHaveProj = computed(() => selWork.value.length > 0 && selWork.value.every((s) => !s.project));
|
||||
|
||||
/**
|
||||
* Массовые pause/resume/delete через /api/projects/bulk — наследуем лимит 500,
|
||||
* слепок-защиту и балансовый гейт возобновления (Task 17b).
|
||||
* Источники без проекта пропускаются.
|
||||
*/
|
||||
async function bulkWork(action: 'pause' | 'resume' | 'delete') {
|
||||
if (busy.value || nav.ctx.competitorId === null) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
if (action === 'delete') {
|
||||
// Удаление источников: сначала проверяем через bulk (только без активного проекта),
|
||||
// удалять источники по одному — логика не изменилась.
|
||||
const projectIds = selWork.value
|
||||
.filter((s) => s.project && !s.project.is_active)
|
||||
.map((s) => s.project!.id);
|
||||
if (projectIds.length) {
|
||||
await store.bulkProjectsById('delete', projectIds);
|
||||
}
|
||||
// Также удаляем источники без проекта
|
||||
for (const s of selWork.value) {
|
||||
if (s.project && s.project.is_active) continue;
|
||||
await store.removeSource(nav.ctx.competitorId, s.id);
|
||||
if (!s.project) await store.removeSource(nav.ctx.competitorId, s.id);
|
||||
}
|
||||
flash('Удалено (активные проекты пропущены)');
|
||||
} else {
|
||||
const want = action === 'resume';
|
||||
for (const s of selWork.value) {
|
||||
if (s.project && s.project.is_active !== want) await store.toggleProjectActive(s.project.id, want);
|
||||
// pause/resume: собираем project_id выбранных источников, имеющих проект
|
||||
const projectIds = selWork.value
|
||||
.filter((s) => s.project !== null)
|
||||
.map((s) => s.project!.id);
|
||||
if (projectIds.length) {
|
||||
const result = await store.bulkProjectsById(action, projectIds);
|
||||
const deferred = result.skipped.filter((s) => s.reason === 'balance_insufficient').length;
|
||||
if (action === 'resume' && deferred > 0) {
|
||||
flash(`Возобновлено ${result.updated}, отложено ${deferred} — не хватает баланса`);
|
||||
} else {
|
||||
flash(action === 'resume' ? 'Возобновлено' : 'Приостановлено — уже заказанные лиды дойдут');
|
||||
}
|
||||
}
|
||||
flash(want ? 'Возобновлено' : 'Приостановлено — уже заказанные лиды дойдут');
|
||||
}
|
||||
selected.value = [];
|
||||
await reload();
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
class="mb-4"
|
||||
data-testid="np-lead-banner"
|
||||
>
|
||||
📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
|
||||
📣 Если баланса хватает — проект запустится сразу. Иначе будет создан на паузе, и вы сможете пополнить баланс. Первые лиды пойдут с {{ leadStart }}.
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex align-center mb-3 text-body-2 text-medium-emphasis" data-testid="np-boost-hint">
|
||||
@@ -309,7 +309,6 @@
|
||||
<ProjectLimitOverloadDialog
|
||||
v-model="overloadOpen"
|
||||
:payload="overloadPayload"
|
||||
@save-blocked="onOverloadSaveBlocked"
|
||||
@set-zero="onOverloadSetZero"
|
||||
/>
|
||||
|
||||
@@ -614,7 +613,16 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
|
||||
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
|
||||
appliesFrom = data?.data?.applies_from ?? null;
|
||||
} else {
|
||||
await apiClient.post('/api/projects', body);
|
||||
const { data } = await apiClient.post('/api/projects', body);
|
||||
// Create всегда 201. Если launch.deferred=1 — проект создан, но не запущен (баланс).
|
||||
if (data?.launch?.deferred) {
|
||||
// Проект создан — закрываем форму и показываем диалог перегрузки.
|
||||
emit('saved', null);
|
||||
close();
|
||||
overloadPayload.value = data.launch.balance ?? null;
|
||||
overloadOpen.value = true;
|
||||
return;
|
||||
}
|
||||
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
|
||||
}
|
||||
overloadOpen.value = false;
|
||||
@@ -630,9 +638,11 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
|
||||
step.value = 'requisites';
|
||||
void initCreateStep();
|
||||
}
|
||||
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
|
||||
// Spec C §6.2: лимит превышает баланс при UPDATE (edit-режим) — открываем диалог перегрузки.
|
||||
// Payload вложен в поле balance (новый контракт update-409).
|
||||
// CREATE не возвращает 409 по балансу — используется launch.deferred в success-ветке выше.
|
||||
else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
|
||||
overloadPayload.value = err.response.data as OverloadPayloadShape;
|
||||
overloadPayload.value = (err.response.data?.balance ?? null) as OverloadPayloadShape | null;
|
||||
overloadOpen.value = true;
|
||||
} else if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
@@ -683,10 +693,6 @@ async function submit() {
|
||||
}
|
||||
|
||||
// Spec C §6.2 — исходы диалога перегрузки лимита.
|
||||
async function onOverloadSaveBlocked(): Promise<void> {
|
||||
await persist({ force_save_blocked: true });
|
||||
}
|
||||
|
||||
async function onOverloadSetZero(): Promise<void> {
|
||||
form.daily_limit_target = 0;
|
||||
overloadOpen.value = false;
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('CreateScreen', () => {
|
||||
|
||||
it('«Создать (без запуска)» зовёт makeProjects(launch=false) и идёт на done', async () => {
|
||||
const store = useAutopodborStore(); seed(store);
|
||||
vi.spyOn(store, 'makeProjects').mockResolvedValue([{ id: 1, name: 'Окна Комфорт' }, { id: 2, name: 'Окна Комфорт ✓' }] as any);
|
||||
vi.spyOn(store, 'makeProjects').mockResolvedValue({ data: [{ id: 1, name: 'Окна Комфорт', is_active: true }, { id: 2, name: 'Окна Комфорт ✓', is_active: true }], launch: { launched: 2, deferred: 0, balance: null } } as any);
|
||||
const nav = makeNav();
|
||||
const w = mountCreate(nav);
|
||||
(w.vm as any).regionCode = 16;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
vi.mock('../../resources/js/api/autopodbor');
|
||||
import * as api from '../../resources/js/api/autopodbor';
|
||||
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
|
||||
import CreateScreen from '../../resources/js/views/autopodbor/screens/CreateScreen.vue';
|
||||
|
||||
// Минимальный nav inject
|
||||
const makeNav = (overrides: Record<string, unknown> = {}) => ({
|
||||
go: vi.fn(),
|
||||
ctx: {
|
||||
selectedSourceIds: [1, 2],
|
||||
loadMsg: '',
|
||||
loadSub: '',
|
||||
createdCount: 0,
|
||||
launched: false,
|
||||
...overrides,
|
||||
},
|
||||
screen: { value: 'create' },
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
DevIndexBadge: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('CreateScreen — launch summary (Task 17a)', () => {
|
||||
it('deferred > 0 после создания — показывается сводка с суммой пополнения', async () => {
|
||||
const nav = makeNav();
|
||||
|
||||
// Мокаем createProjects с launch.deferred > 0
|
||||
(api.createProjects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: [
|
||||
{ id: 1, name: 'Конкурент A', is_active: true },
|
||||
{ id: 2, name: 'Конкурент B', is_active: false },
|
||||
],
|
||||
launch: {
|
||||
launched: 1,
|
||||
deferred: 1,
|
||||
balance: { topup_rub: 350 },
|
||||
},
|
||||
});
|
||||
|
||||
// Нужен currentRun для regionCode
|
||||
const store = useAutopodborStore();
|
||||
store.currentRun = { id: 5, kind: 'search', status: 'done', region_code: 16 } as never;
|
||||
store.sources = [
|
||||
{ id: 1, competitor_id: 10, signal_type: 'site', identifier: 'a.ru', box: 'field', project: null } as never,
|
||||
{ id: 2, competitor_id: 10, signal_type: 'site', identifier: 'b.ru', box: 'field', project: null } as never,
|
||||
];
|
||||
|
||||
const wrapper = mount(CreateScreen, {
|
||||
global: {
|
||||
provide: { autopodborNav: nav },
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
|
||||
// Установить регион и нажать «Создать и запустить»
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).regionCode = 16;
|
||||
await wrapper.find('.ld-btn-primary').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Должна показаться сводка с «отложено» и суммой
|
||||
const text = wrapper.text();
|
||||
expect(text.toLowerCase()).toMatch(/отложено|deferred|пополните/i);
|
||||
expect(text).toMatch(/350/);
|
||||
});
|
||||
|
||||
it('deferred = 0 — навигация на done, никакой сводки', async () => {
|
||||
const nav = makeNav();
|
||||
|
||||
(api.createProjects as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: [{ id: 1, name: 'Конкурент A', is_active: true }],
|
||||
launch: { launched: 1, deferred: 0, balance: null },
|
||||
});
|
||||
|
||||
const store = useAutopodborStore();
|
||||
store.currentRun = { id: 5, kind: 'search', status: 'done', region_code: 16 } as never;
|
||||
store.sources = [
|
||||
{ id: 1, competitor_id: 10, signal_type: 'site', identifier: 'a.ru', box: 'field', project: null } as never,
|
||||
];
|
||||
|
||||
const wrapper = mount(CreateScreen, {
|
||||
global: {
|
||||
provide: { autopodborNav: nav },
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).regionCode = 16;
|
||||
await wrapper.find('.ld-btn-primary').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// nav.go('done') должен быть вызван
|
||||
expect(nav.go).toHaveBeenCalledWith('done');
|
||||
});
|
||||
});
|
||||
@@ -150,13 +150,13 @@ describe('FieldCompetitorScreen', () => {
|
||||
expect(changeSpy).toHaveBeenCalledWith(100, 'new.ru');
|
||||
});
|
||||
|
||||
it('массовое «Приостановить выбранные» паузит проекты выбранных источников', async () => {
|
||||
it('массовое «Приостановить выбранные» паузит проекты выбранных источников через bulkProjectsById', async () => {
|
||||
const store = useAutopodborStore();
|
||||
seed(store, [
|
||||
src({ id: 10, project: proj({ id: 100, signal_identifier: 'a.ru', is_active: true }) }),
|
||||
src({ id: 11, identifier: 'b.ru', project: proj({ id: 101, signal_identifier: 'b.ru', is_active: true }) }),
|
||||
]);
|
||||
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
|
||||
const bulkSpy = vi.spyOn(store, 'bulkProjectsById').mockResolvedValue({ updated: 2, skipped: [], warnings: [] });
|
||||
const w = mountFc(makeNav(3));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const boxes = w.findAll('.ld-pick');
|
||||
@@ -165,8 +165,7 @@ describe('FieldCompetitorScreen', () => {
|
||||
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Приостановить'));
|
||||
await btn!.trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(toggleSpy).toHaveBeenCalledWith(100, false);
|
||||
expect(toggleSpy).toHaveBeenCalledWith(101, false);
|
||||
expect(bulkSpy).toHaveBeenCalledWith('pause', expect.arrayContaining([100, 101]));
|
||||
});
|
||||
|
||||
it('у изучённого конкурента нет кнопки «Собрать источники», показано «Источники собраны»', async () => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import axios from 'axios';
|
||||
|
||||
import BulkActionsBar from '../../resources/js/components/projects/BulkActionsBar.vue';
|
||||
import { useProjectsStore } from '../../resources/js/stores/projectsStore';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
const defaultStubs = {
|
||||
VDialog: { template: '<div><slot /></div>' },
|
||||
DevIndexBadge: true,
|
||||
RegionsBulkDialog: true,
|
||||
DaysBulkDialog: true,
|
||||
LimitBulkDialog: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 0, skipped: [], warnings: [] } });
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
|
||||
});
|
||||
|
||||
describe('BulkActionsBar — resume с balance_insufficient', () => {
|
||||
it('resume с skipped balance_insufficient показывает тост «Запущено N, отложено M»', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useProjectsStore();
|
||||
store.selectedIds.add(1);
|
||||
store.selectedIds.add(2);
|
||||
vi.spyOn(store, 'bulkUpdate').mockResolvedValue({
|
||||
updated: 1,
|
||||
skipped: [{ id: 2, reason: 'balance_insufficient' }],
|
||||
warnings: [],
|
||||
} as never);
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const wrapper = mount(BulkActionsBar, {
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: defaultStubs,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find('[data-testid="bulk-resume"]').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const text = (wrapper.vm as any).skipToastText as string;
|
||||
expect(text).toContain('1'); // updated
|
||||
expect(text).toContain('1'); // skipped count
|
||||
// Должен содержать «Запущено» и «отложено»
|
||||
expect(text.toLowerCase()).toContain('запущено');
|
||||
expect(text.toLowerCase()).toContain('отложено');
|
||||
// Не должен говорить о лимите нового лимита (это update_limit причина)
|
||||
expect(text.toLowerCase()).not.toContain('лимит');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).skipToastOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('resume без skipped — тост не открывается', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useProjectsStore();
|
||||
store.selectedIds.add(1);
|
||||
vi.spyOn(store, 'bulkUpdate').mockResolvedValue({
|
||||
updated: 1,
|
||||
skipped: [],
|
||||
warnings: [],
|
||||
} as never);
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const wrapper = mount(BulkActionsBar, {
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: defaultStubs,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find('[data-testid="bulk-resume"]').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).skipToastOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('resume со смешанными причинами — balance_insufficient даёт «отложено», другие — обычный текст', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useProjectsStore();
|
||||
store.selectedIds.add(1);
|
||||
store.selectedIds.add(2);
|
||||
store.selectedIds.add(3);
|
||||
vi.spyOn(store, 'bulkUpdate').mockResolvedValue({
|
||||
updated: 1,
|
||||
skipped: [
|
||||
{ id: 2, reason: 'balance_insufficient' },
|
||||
{ id: 3, reason: 'supplier_snapshot_locked' },
|
||||
],
|
||||
warnings: [],
|
||||
} as never);
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const wrapper = mount(BulkActionsBar, {
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: defaultStubs,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find('[data-testid="bulk-resume"]').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const text = (wrapper.vm as any).skipToastText as string;
|
||||
// Оба skipped должны быть отражены
|
||||
expect(text.toLowerCase()).toContain('отложено');
|
||||
// supplier_snapshot_locked тоже должен быть
|
||||
expect(text.toLowerCase()).toContain('сбор лидов');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import axios from 'axios';
|
||||
|
||||
vi.mock('axios');
|
||||
vi.mock('../../resources/js/api/autopodbor');
|
||||
import * as api from '../../resources/js/api/autopodbor';
|
||||
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
|
||||
import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue';
|
||||
|
||||
const makeNav = (competitorId = 10) => ({
|
||||
go: vi.fn(),
|
||||
ctx: { competitorId, editProjectId: null, selectedSourceIds: [] },
|
||||
screen: { value: 'field-competitor' },
|
||||
});
|
||||
|
||||
// Фабрика источника с проектом
|
||||
const makeSource = (id: number, projectId: number, isActive: boolean) => ({
|
||||
id,
|
||||
competitor_id: 10,
|
||||
signal_type: 'site' as const,
|
||||
identifier: `site-${id}.ru`,
|
||||
phone_kind: null,
|
||||
phone_type: null,
|
||||
box: 'field' as const,
|
||||
provenance_url: null,
|
||||
provenance_label: null,
|
||||
created_project_id: projectId,
|
||||
project: {
|
||||
id: projectId,
|
||||
name: `Проект ${id}`,
|
||||
signal_identifier: `site-${id}.ru`,
|
||||
is_active: isActive,
|
||||
paused_at: null,
|
||||
preflight_blocked_at: null,
|
||||
daily_limit_target: 5,
|
||||
delivered_in_month: 10,
|
||||
delivery_days_mask: 127,
|
||||
regions: [16],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 2, skipped: [], warnings: [] } });
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
|
||||
});
|
||||
|
||||
describe('FieldCompetitorScreen — bulk actions via /api/projects/bulk (Task 17b)', () => {
|
||||
it('bulkWork("pause") шлёт POST /api/projects/bulk с project_id выбранных источников', async () => {
|
||||
const nav = makeNav();
|
||||
|
||||
// Настраиваем store с двумя источниками с проектами
|
||||
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' },
|
||||
sources: [makeSource(1, 101, true), makeSource(2, 102, true)],
|
||||
});
|
||||
|
||||
const store = useAutopodborStore();
|
||||
await store.loadCompetitor(10);
|
||||
|
||||
const wrapper = mount(FieldCompetitorScreen, {
|
||||
global: {
|
||||
provide: { autopodborNav: nav },
|
||||
stubs: { Transition: false },
|
||||
},
|
||||
});
|
||||
|
||||
// Ждём onMounted reload
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Выбираем оба источника
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.selected = [1, 2];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Вызываем bulkWork('pause') напрямую через exposed
|
||||
await vm.bulkWork('pause');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Должен быть вызван POST /api/projects/bulk с ids проектов [101, 102]
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledWith(
|
||||
'/api/projects/bulk',
|
||||
expect.objectContaining({
|
||||
action: 'pause',
|
||||
ids: expect.arrayContaining([101, 102]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('bulkWork("resume") шлёт POST /api/projects/bulk с project_id', async () => {
|
||||
const nav = makeNav();
|
||||
|
||||
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' },
|
||||
sources: [makeSource(1, 101, false), makeSource(2, 102, false)],
|
||||
});
|
||||
|
||||
const store = useAutopodborStore();
|
||||
await store.loadCompetitor(10);
|
||||
|
||||
const wrapper = mount(FieldCompetitorScreen, {
|
||||
global: {
|
||||
provide: { autopodborNav: nav },
|
||||
stubs: { Transition: false },
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.selected = [1, 2];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await vm.bulkWork('resume');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledWith(
|
||||
'/api/projects/bulk',
|
||||
expect.objectContaining({
|
||||
action: 'resume',
|
||||
ids: expect.arrayContaining([101, 102]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('bulkWork пропускает источники без проекта', async () => {
|
||||
const nav = makeNav();
|
||||
|
||||
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' },
|
||||
sources: [
|
||||
makeSource(1, 101, true),
|
||||
// Источник без проекта
|
||||
{
|
||||
id: 2, competitor_id: 10, signal_type: 'site' as const, identifier: 'b.ru',
|
||||
phone_kind: null, phone_type: null, box: 'field' as const,
|
||||
provenance_url: null, provenance_label: null, created_project_id: null,
|
||||
project: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const store = useAutopodborStore();
|
||||
await store.loadCompetitor(10);
|
||||
|
||||
const wrapper = mount(FieldCompetitorScreen, {
|
||||
global: {
|
||||
provide: { autopodborNav: nav },
|
||||
stubs: { Transition: false },
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.selected = [1, 2];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await vm.bulkWork('pause');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Должен отправить bulk только с id проекта источника 1 (101), источник 2 без проекта пропущен
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledWith(
|
||||
'/api/projects/bulk',
|
||||
expect.objectContaining({
|
||||
action: 'pause',
|
||||
ids: [101],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Task 13: при ответе создания с launch.deferred=1
|
||||
* показывается диалог перегрузки (проект считается созданным).
|
||||
*
|
||||
* Контракт POST /api/projects → 201:
|
||||
* { data: {...}, launch: { launched: 0|1, deferred: 0|1, balance: OverloadPayload | null } }
|
||||
*
|
||||
* Если launch.deferred === 1:
|
||||
* - форма закрывается (emit saved)
|
||||
* - открывается ProjectLimitOverloadDialog с payload = launch.balance
|
||||
*
|
||||
* Если launch.deferred === 0: обычный успех, диалога нет.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
vi.mock('../../resources/js/api/client', () => ({
|
||||
apiClient: {
|
||||
post: vi.fn(),
|
||||
patch: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
|
||||
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/requisites', () => ({
|
||||
getRequisites: vi.fn().mockResolvedValue({
|
||||
subject_type: 'individual',
|
||||
contact_name: 'Иван',
|
||||
contact_phone: '+79161234567',
|
||||
inn: null,
|
||||
}),
|
||||
updateRequisites: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
// vue-router: stub для ProjectLimitOverloadDialog.goTopup
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { apiClient } from '../../resources/js/api/client';
|
||||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||||
import type { Project } from '../../resources/js/stores/projectsStore';
|
||||
|
||||
const factory = (
|
||||
props: { modelValue: boolean; mode?: 'create' | 'edit'; project?: Project | null } = {
|
||||
modelValue: true,
|
||||
mode: 'create',
|
||||
},
|
||||
) =>
|
||||
mount(NewProjectDialog, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VDialog: {
|
||||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('NewProjectDialog — launch.deferred (Task 13)', () => {
|
||||
it('при launch.deferred=1 открывается диалог перегрузки и эмитится saved', async () => {
|
||||
// Бэкенд вернул 201 с deferred=1
|
||||
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: {
|
||||
data: { id: 42, name: 'Тест' },
|
||||
launch: {
|
||||
launched: 0,
|
||||
deferred: 1,
|
||||
balance: {
|
||||
current_balance_rub: '500.00',
|
||||
current_capacity_leads: 15,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 35,
|
||||
topup_rub: '1050.00',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
confirmVsyaRf: () => void;
|
||||
form: { name: string; signal_identifier: string };
|
||||
submit: () => Promise<void>;
|
||||
};
|
||||
vm.confirmVsyaRf();
|
||||
vm.form.name = 'Тест';
|
||||
vm.form.signal_identifier = 'test.ru';
|
||||
await vm.submit();
|
||||
await flushPromises();
|
||||
|
||||
// Диалог перегрузки должен быть открыт
|
||||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||||
expect(overload.exists()).toBe(true);
|
||||
|
||||
// Payload пробрасывается — показывается сумма пополнения
|
||||
expect(overload.text()).toContain('1050.00');
|
||||
|
||||
// saved должен был эмититься (форма считается созданной)
|
||||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('при launch.deferred=0 диалога перегрузки нет, эмитится saved', async () => {
|
||||
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: {
|
||||
data: { id: 43, name: 'Тест2' },
|
||||
launch: {
|
||||
launched: 1,
|
||||
deferred: 0,
|
||||
balance: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
confirmVsyaRf: () => void;
|
||||
form: { name: string; signal_identifier: string };
|
||||
submit: () => Promise<void>;
|
||||
};
|
||||
vm.confirmVsyaRf();
|
||||
vm.form.name = 'Тест2';
|
||||
vm.form.signal_identifier = 'test2.ru';
|
||||
await vm.submit();
|
||||
await flushPromises();
|
||||
|
||||
// Диалога перегрузки быть не должно
|
||||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||||
expect(overload.exists()).toBe(false);
|
||||
|
||||
// saved эмититься должен
|
||||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('при launch.deferred=1 баннер не показывает ошибку в форме', async () => {
|
||||
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: {
|
||||
data: { id: 44 },
|
||||
launch: {
|
||||
launched: 0,
|
||||
deferred: 1,
|
||||
balance: {
|
||||
current_balance_rub: '0.00',
|
||||
current_capacity_leads: 0,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 50,
|
||||
topup_rub: '1500.00',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
confirmVsyaRf: () => void;
|
||||
form: { name: string; signal_identifier: string };
|
||||
submit: () => Promise<void>;
|
||||
};
|
||||
vm.confirmVsyaRf();
|
||||
vm.form.name = 'Тест3';
|
||||
vm.form.signal_identifier = 'test3.ru';
|
||||
await vm.submit();
|
||||
await flushPromises();
|
||||
|
||||
// В форме нет общей ошибки
|
||||
const errorAlert = wrapper.find('.dialog-stub .v-alert--type-error');
|
||||
expect(errorAlert.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('update (edit) 409 balance_insufficient читает payload из err.response.data.balance', async () => {
|
||||
// В edit-режиме PATCH всё ещё может вернуть 409 — payload вложен в .balance
|
||||
(apiClient.patch as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
error: 'balance_insufficient',
|
||||
balance: {
|
||||
current_balance_rub: '200.00',
|
||||
current_capacity_leads: 6,
|
||||
would_be_required_leads: 80,
|
||||
deficit_leads: 74,
|
||||
topup_rub: '2220.00',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const project = {
|
||||
id: 5,
|
||||
name: 'Проект',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'edit.ru',
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
regions: [77],
|
||||
delivery_days_mask: 127,
|
||||
sync_status: 'ok' as const,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
} as unknown as Project;
|
||||
|
||||
const wrapper = factory({ modelValue: true, mode: 'edit', project });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as { submit: () => Promise<void> };
|
||||
await vm.submit();
|
||||
await flushPromises();
|
||||
|
||||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||||
expect(overload.exists()).toBe(true);
|
||||
// Проверяем что payload из .balance дошёл (сумма 2220)
|
||||
expect(overload.text()).toContain('2220');
|
||||
});
|
||||
});
|
||||
@@ -166,18 +166,23 @@ describe('NewProjectDialog', () => {
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ regions: [82, 83] }));
|
||||
});
|
||||
|
||||
it('409 balance_insufficient → открывает диалог перегрузки; save-blocked пере-сабмитит с force_save_blocked', async () => {
|
||||
// Spec C §6.2 (Task 1.10): первый POST падает 409, второй (после выбора
|
||||
// «Сохранить и приостановить») уходит с force_save_blocked=true.
|
||||
(apiClient.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
error: 'balance_insufficient',
|
||||
current_balance_rub: '1000.00',
|
||||
current_capacity_leads: 30,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 20,
|
||||
it('launch.deferred=1 → открывает диалог перегрузки; проект считается созданным (saved эмитится)', async () => {
|
||||
// Task 13: POST /api/projects теперь всегда 201.
|
||||
// Если launch.deferred=1 — проект создан на паузе (баланс), форма закрывается,
|
||||
// открывается диалог перегрузки. Кнопка save-blocked удалена из диалога.
|
||||
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: {
|
||||
data: { id: 42, name: 'Проект' },
|
||||
launch: {
|
||||
launched: 0,
|
||||
deferred: 1,
|
||||
balance: {
|
||||
current_balance_rub: '1000.00',
|
||||
current_capacity_leads: 30,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 20,
|
||||
topup_rub: '600.00',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -198,16 +203,12 @@ describe('NewProjectDialog', () => {
|
||||
|
||||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||||
expect(overload.exists()).toBe(true);
|
||||
expect(overload.text()).toContain('20');
|
||||
// Диалог показывает сумму из topup_rub (600 ₽) или deficit_leads
|
||||
expect(overload.text()).toContain('600');
|
||||
|
||||
await wrapper.find('[data-testid="overload-save-blocked"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2);
|
||||
expect(apiClient.post).toHaveBeenLastCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({ force_save_blocked: true }),
|
||||
);
|
||||
// POST был 1 раз (не 2 — нет повторного submit)
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
// saved был эмитирован — проект считается созданным
|
||||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import ProjectCard from '../../resources/js/components/projects/ProjectCard.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const baseProject = {
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'test.ru',
|
||||
is_active: false,
|
||||
daily_limit_target: 5,
|
||||
delivered_today: 0,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
describe('ProjectCard — бейдж баланса', () => {
|
||||
it('проект с preflight_blocked_at показывает «Не запущен — не хватает баланса»', () => {
|
||||
const project = { ...baseProject, preflight_blocked_at: '2026-07-02T10:00:00Z', balance_blocked: true };
|
||||
const w = mount(ProjectCard, {
|
||||
props: { project, selected: false },
|
||||
global: { plugins: [vuetify], stubs: { RouterLink: true } },
|
||||
});
|
||||
expect(w.text()).toContain('Не запущен — не хватает баланса');
|
||||
});
|
||||
|
||||
it('обычная пауза (preflight_blocked_at=null) показывает «Приостановлен» а не блок баланса', () => {
|
||||
const project = { ...baseProject, preflight_blocked_at: null, balance_blocked: false, is_active: false };
|
||||
const w = mount(ProjectCard, {
|
||||
props: { project, selected: false },
|
||||
global: { plugins: [vuetify], stubs: { RouterLink: true } },
|
||||
});
|
||||
expect(w.text()).not.toContain('Не запущен — не хватает баланса');
|
||||
// «На паузе» из v-else блока
|
||||
expect(w.text()).toContain('На паузе');
|
||||
});
|
||||
|
||||
it('активный проект НЕ показывает бейдж баланса', () => {
|
||||
const project = { ...baseProject, is_active: true, preflight_blocked_at: null, balance_blocked: false };
|
||||
const w = mount(ProjectCard, {
|
||||
props: { project, selected: false },
|
||||
global: { plugins: [vuetify], stubs: { RouterLink: true } },
|
||||
});
|
||||
expect(w.text()).not.toContain('не хватает баланса');
|
||||
});
|
||||
});
|
||||
@@ -4,64 +4,69 @@ import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import ProjectLimitOverloadDialog from '../../resources/js/components/projects/ProjectLimitOverloadDialog.vue';
|
||||
|
||||
// Billing v2 Spec C Task 1.10 — диалог перегрузки лимита (§6.2). Принимает
|
||||
// 409-payload `balance_insufficient`, эмитит решение пользователя.
|
||||
/**
|
||||
* Task 12 — переделка диалога «перегрузки лимита» в «запуск отложен».
|
||||
* Billing v2 Spec C §6.2, план 2026-07-02-launch-gate-balance.md.
|
||||
* Новое поле payload: topup_rub (сумма пополнения в рублях).
|
||||
*/
|
||||
|
||||
const payload = {
|
||||
current_balance_rub: '1000.00',
|
||||
current_capacity_leads: 30,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 20,
|
||||
current_balance_rub: '200.00',
|
||||
current_capacity_leads: 4,
|
||||
would_be_required_leads: 12,
|
||||
deficit_leads: 8,
|
||||
topup_rub: '400.00',
|
||||
};
|
||||
|
||||
const mountDialog = () =>
|
||||
const mountDialog = (props?: Record<string, unknown>) =>
|
||||
mount(ProjectLimitOverloadDialog, {
|
||||
props: { modelValue: true, payload },
|
||||
props: { modelValue: true, payload, ...props },
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('ProjectLimitOverloadDialog', () => {
|
||||
it('рендерит данные 409-payload', () => {
|
||||
describe('ProjectLimitOverloadDialog — Task 12 (запуск отложен)', () => {
|
||||
it('монтируется без ошибок', () => {
|
||||
const w = mountDialog();
|
||||
const text = w.text();
|
||||
expect(text).toContain('1000.00');
|
||||
expect(text).toContain('30');
|
||||
expect(text).toContain('50');
|
||||
expect(text).toContain('20');
|
||||
expect(w.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('«Сохранить и приостановить» эмитит save-blocked', async () => {
|
||||
it('показывает текст «не запущен»', () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-save-blocked"]').trigger('click');
|
||||
expect(w.emitted('save-blocked')).toBeTruthy();
|
||||
expect(w.text().toLowerCase()).toContain('не запущен');
|
||||
});
|
||||
|
||||
it('«Поставить лимит 0» эмитит set-zero', async () => {
|
||||
it('показывает сумму пополнения из topup_rub', () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-set-zero"]').trigger('click');
|
||||
expect(w.emitted('set-zero')).toBeTruthy();
|
||||
expect(w.text()).toContain('400');
|
||||
});
|
||||
|
||||
it('«Отмена» закрывает диалог (update:modelValue=false)', async () => {
|
||||
it('содержит кнопку [data-test="topup"]', () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-cancel"]').trigger('click');
|
||||
expect(w.emitted('update:modelValue')?.[0]).toEqual([false]);
|
||||
expect(w.find('[data-test="topup"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('косяк 06: текст на «вы», без «ты»', () => {
|
||||
it('содержит кнопку [data-test="reduce"]', () => {
|
||||
const w = mountDialog();
|
||||
const text = w.text();
|
||||
expect(text).toContain('У вас');
|
||||
expect(text).not.toContain('У тебя');
|
||||
expect(text).not.toContain('пополни ');
|
||||
expect(text).not.toContain('поставь ');
|
||||
expect(text).not.toContain('уменьши ');
|
||||
expect(w.find('[data-test="reduce"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('косяк 06: «Пополнить баланс» закрывает окно и ведёт в /billing', async () => {
|
||||
it('содержит кнопку закрытия [data-test="close"]', () => {
|
||||
const w = mountDialog();
|
||||
expect(w.find('[data-test="close"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('[data-test="topup"] закрывает диалог (update:modelValue=false)', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-test="topup"]').trigger('click');
|
||||
const emitted = w.emitted('update:modelValue') as unknown[][];
|
||||
expect(emitted).toBeTruthy();
|
||||
expect(emitted[0][0]).toBe(false);
|
||||
});
|
||||
|
||||
it('[data-test="topup"] ведёт в /billing через router.push', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/billing', component: { template: '<div>billing</div>' } }],
|
||||
@@ -71,16 +76,37 @@ describe('ProjectLimitOverloadDialog', () => {
|
||||
props: { modelValue: true, payload },
|
||||
global: { plugins: [createVuetify(), router], stubs: { VDialog: { template: '<div><slot /></div>' } } },
|
||||
});
|
||||
await w.find('[data-testid="overload-topup"]').trigger('click');
|
||||
expect(w.emitted('update:modelValue')?.[0]).toEqual([false]);
|
||||
await w.find('[data-test="topup"]').trigger('click');
|
||||
expect(push).toHaveBeenCalledWith('/billing');
|
||||
});
|
||||
|
||||
it('payload=null — без падения, ничего не рендерит', () => {
|
||||
it('[data-test="reduce"] эмитит set-zero', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-test="reduce"]').trigger('click');
|
||||
expect(w.emitted('set-zero')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('[data-test="close"] закрывает диалог (update:modelValue=false)', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-test="close"]').trigger('click');
|
||||
const emitted = w.emitted('update:modelValue') as unknown[][];
|
||||
expect(emitted).toBeTruthy();
|
||||
expect(emitted[0][0]).toBe(false);
|
||||
});
|
||||
|
||||
it('payload=null — без падения, карточка не рендерится', () => {
|
||||
const w = mount(ProjectLimitOverloadDialog, {
|
||||
props: { modelValue: true, payload: null },
|
||||
global: { plugins: [createVuetify()], stubs: { VDialog: { template: '<div><slot /></div>' } } },
|
||||
});
|
||||
expect(w.find('[data-testid="overload-save-blocked"]').exists()).toBe(false);
|
||||
expect(w.find('[data-testid="overload-dialog"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('текст на «вы», без «ты»', () => {
|
||||
const w = mountDialog();
|
||||
const text = w.text();
|
||||
expect(text).not.toContain('У тебя');
|
||||
expect(text).not.toContain('пополни ');
|
||||
expect(text).not.toContain('поставь ');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import axios from 'axios';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
import { useProjectsStore } from '../../resources/js/stores/projectsStore';
|
||||
|
||||
const sampleProject = {
|
||||
id: 42,
|
||||
name: 'Test',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'x.ru',
|
||||
is_active: false,
|
||||
daily_limit_target: 5,
|
||||
delivered_today: 0,
|
||||
sync_status: 'ok' as const,
|
||||
preflight_blocked_at: null,
|
||||
};
|
||||
|
||||
describe('projectsStore toggleActive', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('при 409 balance_insufficient НЕ делает fetch и возвращает deferred+balance', async () => {
|
||||
const balancePayload = {
|
||||
current_balance_rub: '100.00',
|
||||
current_capacity_leads: 1,
|
||||
would_be_required_leads: 5,
|
||||
deficit_leads: 4,
|
||||
topup_rub: '400.00',
|
||||
};
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue({
|
||||
response: { status: 409, data: { error: 'balance_insufficient', balance: balancePayload } },
|
||||
});
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { data: [], meta: { total: 0 } },
|
||||
});
|
||||
const store = useProjectsStore();
|
||||
store.items = [{ ...sampleProject }] as any;
|
||||
|
||||
const result = await store.toggleActive({ ...sampleProject });
|
||||
|
||||
// Стор НЕ должен обновить is_active (fetch не вызывался при 409)
|
||||
expect(result).toEqual({ deferred: true, balance: balancePayload });
|
||||
expect(axios.get).not.toHaveBeenCalled(); // fetch НЕ вызван
|
||||
// is_active в сторе не изменился
|
||||
expect(store.items[0]?.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('при успехе (200) делает fetch и возвращает void', async () => {
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { data: { id: 42 } } });
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { data: [{ ...sampleProject, is_active: true }], meta: { total: 1 } },
|
||||
});
|
||||
const store = useProjectsStore();
|
||||
const result = await store.toggleActive({ ...sampleProject });
|
||||
expect(result).toBeUndefined();
|
||||
expect(axios.get).toHaveBeenCalled(); // fetch вызван
|
||||
});
|
||||
|
||||
it('другие ошибки (500) пробрасываются', async () => {
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue({
|
||||
response: { status: 500, data: { error: 'server_error' } },
|
||||
});
|
||||
const store = useProjectsStore();
|
||||
await expect(store.toggleActive({ ...sampleProject })).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user