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:
Дмитрий
2026-07-02 12:04:16 +03:00
parent f78ab4af3d
commit 9ff7bc8fa0
22 changed files with 1116 additions and 134 deletions
@@ -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(),
+23 -3
View File
@@ -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>
+18 -1
View File
@@ -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,
};
+25 -3
View File
@@ -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) {
+33 -3
View File
@@ -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');
});
});
+22 -21
View File
@@ -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();
});
});