Files
portal/app/resources/js/stores/projectsStore.ts
T
Дмитрий 9ff7bc8fa0 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>
2026-07-02 12:04:16 +03:00

265 lines
9.3 KiB
TypeScript

import { defineStore } from 'pinia';
import { ref, reactive, watch } from 'vue';
import axios from 'axios';
export interface Project {
id: number;
name: string;
signal_type: 'site' | 'call' | 'sms';
signal_identifier?: string | null;
sms_senders?: string[] | null;
sms_keyword?: string | null;
daily_limit_target: number;
delivered_today: number;
delivered_in_month?: number;
is_active: boolean;
region_mask?: number;
region_mode?: string;
regions?: number[]; // Plan 6 — subject codes 1..89; пустой массив = вся РФ
delivery_days_mask?: number;
sync_status: 'ok' | 'pending' | 'failed';
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;
source_unlock_projected?: boolean;
// Эпик 6.3: единый текст правила смены источника (из ProjectRuleMessages на бэкенде).
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);
// #6/#7: per_page 20/50/100/200, sort ('-delivered_today' default), region (subject code 1..89), delivery_day (0..6).
const filters = reactive<{
signal_type: string;
status: string;
search: string;
page: number;
per_page: number;
sort: string;
region: number | null;
delivery_day: number | null;
}>({
signal_type: '',
status: '',
search: '',
page: 1,
per_page: 20,
sort: '-delivered_today',
region: null,
delivery_day: null,
});
const selectedIds = ref<Set<number>>(new Set());
const pendingIds = ref<Set<number>>(new Set());
const loading = ref(false);
const selectAllByFilter = ref<boolean>(false);
// Closure state for polling — kept outside returned store surface.
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
let currentDelay = 5000;
const DELAY_OK = 5000;
const DELAY_MAX = 30000;
async function fetch() {
loading.value = true;
try {
const params: Record<string, unknown> = { page: filters.page, per_page: filters.per_page };
if (filters.signal_type) params.signal_type = filters.signal_type;
if (filters.status) params.status = filters.status;
if (filters.search) params.search = filters.search;
if (filters.sort) params.sort = filters.sort;
if (filters.region !== null) params.region = filters.region;
if (filters.delivery_day !== null) params.delivery_day = filters.delivery_day;
const { data } = await axios.get('/api/projects', { params });
items.value = data.data;
total.value = data.meta.total;
} finally {
loading.value = false;
}
}
async function create(payload: Partial<Project>) {
const { data } = await axios.post('/api/projects', payload);
pendingIds.value.add(data.data.id);
await fetch();
return data.data;
}
async function update(id: number, payload: Partial<Project>) {
const { data } = await axios.patch(`/api/projects/${id}`, payload);
await fetch();
return data.data;
}
async function del(id: number) {
await axios.delete(`/api/projects/${id}`);
await fetch();
}
async function syncNow(id: number) {
await axios.post(`/api/projects/${id}/sync`);
pendingIds.value.add(id);
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) {
selectAllByFilter.value = false; // user opted into manual mode
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
} else {
selectedIds.value.add(id);
}
}
function clearSelection() {
selectedIds.value.clear();
}
async function bulkAction(action: 'pause' | 'resume' | 'delete') {
const ids = Array.from(selectedIds.value);
if (!ids.length) return;
await axios.post('/api/projects/bulk', { action, ids });
clearSelection();
await fetch();
}
interface BulkPayload {
action: 'pause' | 'resume' | 'delete' | 'update_regions' | 'update_days' | 'update_limit';
add?: number;
remove?: number;
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
add_regions?: number[];
remove_regions?: number[];
delta?: number;
replace?: number;
}
interface BulkResponse {
updated: number;
skipped: Array<{ id: number; reason: string }>;
warnings: string[];
}
async function bulkUpdate(payload: BulkPayload): Promise<BulkResponse> {
const body: Record<string, unknown> = { ...payload };
if (selectAllByFilter.value) {
const f: Record<string, unknown> = {};
if (filters.signal_type) f.signal_type = filters.signal_type;
if (filters.status) f.status = filters.status;
if (filters.search) f.search = filters.search;
body.scope = { filter: f };
} else {
body.ids = Array.from(selectedIds.value);
}
const { data } = await axios.post('/api/projects/bulk', body);
clearSelection();
selectAllByFilter.value = false;
await fetch();
return data;
}
// Watch filter changes — clear selection on switch
watch(
() => [filters.signal_type, filters.status, filters.search, filters.region, filters.delivery_day] as const,
() => {
clearSelection();
selectAllByFilter.value = false;
},
);
function scheduleNext() {
pollTimeout = setTimeout(async () => {
pollTimeout = null;
if (pendingIds.value.size === 0) {
currentDelay = DELAY_OK;
return;
}
try {
const ids = Array.from(pendingIds.value).join(',');
const { data } = await axios.get<{ data: Project[] }>('/api/projects', { params: { ids } });
for (const project of data.data) {
const idx = items.value.findIndex((i) => i.id === project.id);
if (idx !== -1) items.value[idx] = project;
if (project.sync_status === 'ok' || project.sync_status === 'failed') {
pendingIds.value.delete(project.id);
}
}
currentDelay = DELAY_OK;
} catch {
// Exponential backoff to avoid hammering on transient errors.
currentDelay = Math.min(currentDelay * 2, DELAY_MAX);
}
if (pendingIds.value.size > 0) {
scheduleNext();
}
}, currentDelay);
}
function startPolling() {
if (pollTimeout !== null) return;
scheduleNext();
}
function stopPolling() {
if (pollTimeout !== null) {
clearTimeout(pollTimeout);
pollTimeout = null;
}
currentDelay = DELAY_OK;
}
return {
items,
total,
filters,
selectedIds,
pendingIds,
loading,
selectAllByFilter,
fetch,
create,
update,
del,
syncNow,
toggleActive,
toggleSelect,
clearSelection,
bulkAction,
bulkUpdate,
startPolling,
stopPolling,
};
});