From 9ff7bc8fa0184daf2c27a00b15d8d3df4daa1ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 2 Jul 2026 12:04:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=B1=D0=B8=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?-=D1=84=D1=80=D0=BE=D0=BD=D1=82):=20=D0=B1=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=20=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B5=20=E2=80=94=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=80=D1=83=D0=B1=D0=BB=D1=8F?= =?UTF-8?q?=D1=85,=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20=C2=AB=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=83=D1=89=D0=B5=D0=BD=C2=BB,=20=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фронт для правила «баланс блокирует запуск, а не создание»: - 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 --- app/app/Http/Resources/ProjectResource.php | 2 + app/resources/js/api/autopodbor.ts | 26 +- .../js/components/projects/BulkActionsBar.vue | 13 + .../js/components/projects/ProjectCard.vue | 55 +++-- .../projects/ProjectDetailsDrawer.vue | 33 ++- .../projects/ProjectLimitOverloadDialog.vue | 46 ++-- app/resources/js/stores/autopodborStore.ts | 19 +- app/resources/js/stores/projectsStore.ts | 28 ++- app/resources/js/views/ProjectsView.vue | 36 ++- .../views/autopodbor/screens/CreateScreen.vue | 29 ++- .../screens/FieldCompetitorScreen.vue | 34 ++- .../js/views/projects/NewProjectDialog.vue | 24 +- .../Frontend/AutopodborCreateScreen.spec.ts | 2 +- .../AutopodborCreateScreenLaunch.spec.ts | 106 ++++++++ .../AutopodborFieldCompetitorScreen.spec.ts | 7 +- .../Frontend/BulkActionsBarResume.spec.ts | 121 +++++++++ .../FieldCompetitorScreenBulk.spec.ts | 177 +++++++++++++ .../Frontend/NewProjectDialog.launch.spec.ts | 232 ++++++++++++++++++ app/tests/Frontend/NewProjectDialog.spec.ts | 43 ++-- app/tests/Frontend/ProjectCardBadge.spec.ts | 48 ++++ .../ProjectLimitOverloadDialog.spec.ts | 98 +++++--- .../Frontend/ProjectsStoreToggle.spec.ts | 71 ++++++ 22 files changed, 1116 insertions(+), 134 deletions(-) create mode 100644 app/tests/Frontend/AutopodborCreateScreenLaunch.spec.ts create mode 100644 app/tests/Frontend/BulkActionsBarResume.spec.ts create mode 100644 app/tests/Frontend/FieldCompetitorScreenBulk.spec.ts create mode 100644 app/tests/Frontend/NewProjectDialog.launch.spec.ts create mode 100644 app/tests/Frontend/ProjectCardBadge.spec.ts create mode 100644 app/tests/Frontend/ProjectsStoreToggle.spec.ts diff --git a/app/app/Http/Resources/ProjectResource.php b/app/app/Http/Resources/ProjectResource.php index ba31b0b8..34acf32b 100644 --- a/app/app/Http/Resources/ProjectResource.php +++ b/app/app/Http/Resources/ProjectResource.php @@ -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(), diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts index 6f0139df..fa40b9b8 100644 --- a/app/resources/js/api/autopodbor.ts +++ b/app/resources/js/api/autopodbor.ts @@ -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> { - const { data } = await apiClient.post<{ data: Array<{ id: number; name: string }> }>('/api/autopodbor/projects', p); - return data.data; +}): Promise { + const { data } = await apiClient.post('/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 { diff --git a/app/resources/js/components/projects/BulkActionsBar.vue b/app/resources/js/components/projects/BulkActionsBar.vue index 16736cae..d9c1b3c7 100644 --- a/app/resources/js/components/projects/BulkActionsBar.vue +++ b/app/resources/js/components/projects/BulkActionsBar.vue @@ -102,6 +102,19 @@ async function runBulk(payload: Parameters[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( diff --git a/app/resources/js/components/projects/ProjectCard.vue b/app/resources/js/components/projects/ProjectCard.vue index 25d0d056..3e958358 100644 --- a/app/resources/js/components/projects/ProjectCard.vue +++ b/app/resources/js/components/projects/ProjectCard.vue @@ -79,7 +79,16 @@
На паузе
- + + + Пополните баланс, чтобы запустить проект + + {{ syncStatusIcon }} {{ syncStatusLabel }} @@ -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]; +});