Files
portal/app/resources/js/views/admin/AdminSupplierIntegrationView.vue
T
Дмитрий f9f86ca05f
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat/admin: тумблер разблокировки смены источника на экране интеграции с поставщиком
Дружелюбный переключатель ВКЛ/ВЫКЛ флага routing_match_by_snapshot для владельца — без правки БД и без 30-символьного основания общего edit-flow. GET/POST source-edit-flag в AdminSupplierIntegrationController пишут в system_settings type=bool + audit-журнал. На экране карточка с VSwitch и диалогом подтверждения, бамп ключа возвращает тумблер к факту при отмене. TDD: 5 эндпоинт-тестов + фронт-спек. Larastan чист, baseline дополнен Pest-шумом. Проверено глазами через Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:27:32 +03:00

522 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';
interface ReconcileRow {
started_at: string;
finished_at: string | null;
window_start: string;
window_end: string;
status: string;
total_csv_rows: number;
matched_count: number;
recovered_count: number;
drift_ratio: number;
}
interface Health {
last_run_at: string | null;
last_status: string | null;
drift_ratio: number | null;
webhook_state: string;
}
const health = ref<Health | null>(null);
const history = ref<ReconcileRow[]>([]);
const loading = ref(false);
const reconciling = ref(false);
const error = ref<string | null>(null);
// --- Plan 4 Task 1: глобальный режим экспорта проектов (online|batch) ---
type ExportMode = 'online' | 'batch';
const exportMode = ref<ExportMode>('batch');
const exportModeError = ref<string | null>(null);
const exportModeSaving = ref(false);
async function loadExportMode(): Promise<void> {
try {
const { data } = await axios.get('/api/admin/supplier-integration/export-mode');
if (data?.mode === 'online' || data?.mode === 'batch') {
exportMode.value = data.mode;
}
} catch {
exportModeError.value = 'Не удалось загрузить режим экспорта.';
}
}
async function setExportMode(mode: ExportMode): Promise<void> {
if (exportMode.value === mode) return;
exportModeSaving.value = true;
exportModeError.value = null;
try {
const { data } = await axios.post('/api/admin/supplier-integration/export-mode', { mode });
exportMode.value = data?.mode === 'online' ? 'online' : 'batch';
} catch {
exportModeError.value = 'Не удалось сохранить режим экспорта.';
} finally {
exportModeSaving.value = false;
}
}
// --- Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot) ---
const sourceEditEnabled = ref(false);
const sourceEditError = ref<string | null>(null);
const sourceEditSaving = ref(false);
const sourceEditConfirmOpen = ref(false);
const pendingSourceEditValue = ref(false);
// VSwitch флипает внутреннее состояние по клику; бамп ключа ре-маунтит тумблер,
// чтобы он вернулся к фактическому sourceEditEnabled после отмены/ошибки.
const sourceEditSwitchKey = ref(0);
async function loadSourceEditFlag(): Promise<void> {
try {
const { data } = await axios.get('/api/admin/supplier-integration/source-edit-flag');
sourceEditEnabled.value = data?.enabled === true;
} catch {
sourceEditError.value = 'Не удалось загрузить переключатель.';
}
}
// Тумблер привязан к sourceEditEnabled один-в-один; запрос смены открывает
// подтверждение, фактическое значение меняется только после «Подтвердить».
function onSourceEditToggleRequest(val: boolean | null): void {
pendingSourceEditValue.value = val === true;
sourceEditConfirmOpen.value = true;
}
function cancelSourceEditToggle(): void {
sourceEditConfirmOpen.value = false;
sourceEditSwitchKey.value++; // вернуть тумблер к фактическому состоянию
}
async function confirmSourceEditToggle(): Promise<void> {
sourceEditConfirmOpen.value = false;
sourceEditSaving.value = true;
sourceEditError.value = null;
try {
const { data } = await axios.post('/api/admin/supplier-integration/source-edit-flag', {
enabled: pendingSourceEditValue.value,
});
sourceEditEnabled.value = data?.enabled === true;
} catch {
sourceEditError.value = 'Не удалось сохранить переключатель.';
} finally {
sourceEditSaving.value = false;
sourceEditSwitchKey.value++; // синхронизировать тумблер с фактом (вкл. при ошибке)
}
}
async function load(): Promise<void> {
loading.value = true;
error.value = null;
try {
const { data } = await axios.get('/api/admin/supplier-integration');
health.value = data.health;
history.value = data.history;
} catch {
error.value = 'Не удалось загрузить состояние канала.';
} finally {
loading.value = false;
}
}
async function reconcileNow(): Promise<void> {
reconciling.value = true;
try {
await axios.post('/api/admin/supplier-integration/reconcile');
// Сверка асинхронная (queued job) — ждём ~4с и перезагружаем здоровье канала.
setTimeout(() => void load(), 4000);
} finally {
reconciling.value = false;
}
}
function statusColor(status: string | null): string {
if (status === 'ok') return 'success';
if (status === 'drift_alert') return 'warning';
if (status === 'failed') return 'error';
return 'grey';
}
// --- Эпик 5: история вечерних заливок проектов поставщику (supplier_sync_runs) ---
interface SyncRun {
started_at: string;
finished_at: string | null;
groups_total: number;
synced_ok: number;
manual_queued: number;
deferred: number;
failed: number;
status: string;
}
const syncRuns = ref<SyncRun[]>([]);
const syncRunsError = ref<string | null>(null);
async function loadSyncRuns(): Promise<void> {
try {
const { data } = await axios.get('/api/admin/supplier-integration/sync-runs');
syncRuns.value = Array.isArray(data?.runs) ? data.runs : [];
} catch {
syncRunsError.value = 'Не удалось загрузить историю заливок.';
}
}
function runStatusColor(status: string): string {
if (status === 'ok') return 'success';
if (status === 'partial') return 'warning';
if (status === 'failed' || status === 'aborted') return 'error';
return 'grey';
}
function runStatusLabel(status: string): string {
if (status === 'ok') return 'Всё ровно';
if (status === 'partial') return 'Частично';
if (status === 'failed') return 'Сбой';
if (status === 'aborted') return 'Прервано';
return status;
}
// --- Ручная очередь (ярус 3 резерва канала миграции проектов) ---
interface ManualQueueRow {
id: number;
project_id: number;
platform: string;
operation: string;
external_id: string | null;
payload_snapshot: Record<string, unknown>;
failure_reason: string;
created_at: string;
}
const manualQueue = ref<ManualQueueRow[]>([]);
const manualQueueError = ref<string | null>(null);
const resolvingId = ref<number | null>(null);
async function loadManualQueue(): Promise<void> {
try {
const { data } = await axios.get('/api/admin/supplier-integration/manual-queue');
manualQueue.value = Array.isArray(data?.queue) ? data.queue : [];
} catch {
manualQueueError.value = 'Не удалось загрузить очередь.';
}
}
// UI-аудит: window.confirm/alert → v-dialog/v-snackbar.
const confirmResolveId = ref<number | null>(null);
const resolveToastOpen = ref(false);
const resolveToastText = ref('');
const resolveToastColor = ref<'success' | 'error'>('success');
function askResolve(id: number): void {
confirmResolveId.value = id;
}
async function doResolve(): Promise<void> {
const id = confirmResolveId.value;
confirmResolveId.value = null;
if (id === null) return;
resolvingId.value = id;
try {
await axios.post(`/api/admin/supplier-integration/manual-queue/${id}/resolve`);
await loadManualQueue();
resolveToastColor.value = 'success';
resolveToastText.value = 'Запись закрыта.';
resolveToastOpen.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
resolveToastColor.value = 'error';
resolveToastText.value = err?.response?.data?.message ?? 'Не удалось закрыть запись.';
resolveToastOpen.value = true;
} finally {
resolvingId.value = null;
}
}
function formatDate(s: string): string {
return new Date(s).toLocaleString('ru-RU');
}
onMounted(() => {
void load();
void loadManualQueue();
void loadExportMode();
void loadSourceEditFlag();
void loadSyncRuns();
});
</script>
<template>
<div class="pa-6">
<h1 class="text-h5 mb-4">Интеграция с поставщиком</h1>
<v-card class="mb-4">
<v-card-title>Режим экспорта проектов</v-card-title>
<v-card-text>
<v-alert v-if="exportModeError" type="error" density="compact" class="mb-3">
{{ exportModeError }}
</v-alert>
<div data-testid="export-mode-toggle">
<v-btn-toggle
:model-value="exportMode"
mandatory
color="primary"
density="comfortable"
:disabled="exportModeSaving"
>
<v-btn data-testid="export-mode-online" value="online" @click="setExportMode('online')">
Онлайн
</v-btn>
<v-btn data-testid="export-mode-batch" value="batch" @click="setExportMode('batch')">
Пакетный
</v-btn>
</v-btn-toggle>
</div>
<p class="text-caption text-medium-emphasis mt-3 mb-0">
Онлайн изменения проекта переносятся к поставщику сразу. Пакетный ночной синк в 18:00
(SyncSupplierProjectsJob).
</p>
</v-card-text>
</v-card>
<v-card class="mb-4" data-testid="source-edit-flag-card">
<v-card-title>Разблокировка смены источника</v-card-title>
<v-card-text>
<v-alert v-if="sourceEditError" type="error" density="compact" class="mb-3">
{{ sourceEditError }}
</v-alert>
<v-switch
:key="sourceEditSwitchKey"
:model-value="sourceEditEnabled"
:loading="sourceEditSaving"
:disabled="sourceEditSaving"
color="primary"
hide-details
inset
data-testid="source-edit-flag-switch"
:label="sourceEditEnabled ? 'Включена' : 'Выключена'"
@update:model-value="onSourceEditToggleRequest"
/>
<p class="text-caption text-medium-emphasis mt-1 mb-0">
ВКЛ клиенты могут менять источник проекта без потери лидов (маршрутизация по слепку).
ВЫКЛ смена источника заблокирована. Откат безопасен в любой момент.
</p>
</v-card-text>
</v-card>
<v-dialog v-model="sourceEditConfirmOpen" max-width="480" data-testid="source-edit-confirm">
<v-card>
<v-card-title>
{{ pendingSourceEditValue ? 'Включить' : 'Выключить' }} разблокировку смены источника?
</v-card-title>
<v-card-text>
<template v-if="pendingSourceEditValue">
Клиенты смогут менять источник проекта без потери лидов (матч по слепку).
Рекомендуется сутки понаблюдать по «Вечерней заливке», что лиды доезжают.
</template>
<template v-else>
Вернётся прежнее поведение: смена источника заблокирована. Откат безопасен.
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" data-testid="source-edit-confirm-cancel" @click="cancelSourceEditToggle">
Отмена
</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="sourceEditSaving"
data-testid="source-edit-confirm-apply"
@click="confirmSourceEditToggle"
>
Подтвердить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-card class="mb-4" data-testid="sync-runs-card">
<v-card-title>Вечерняя заливка проектов поставщику</v-card-title>
<v-card-text>
<p class="text-caption text-medium-emphasis mb-3">
История запусков синхронизации проектов у поставщика (18:05 МСК). Сверяйте,
что заливка прошла ровно от этого зависят заказанные на завтра лиды.
</p>
<v-alert v-if="syncRunsError" type="error" density="compact" class="mb-3">
{{ syncRunsError }}
</v-alert>
<v-table v-if="syncRuns.length > 0" density="comfortable" data-testid="sync-runs-table">
<thead>
<tr>
<th>Когда</th>
<th>Статус</th>
<th class="text-right">Групп</th>
<th class="text-right">Готово</th>
<th class="text-right">В ручную</th>
<th class="text-right">Ошибок</th>
</tr>
</thead>
<tbody>
<tr v-for="(run, i) in syncRuns" :key="i" data-testid="sync-run-row">
<td>{{ run.finished_at ? formatDate(run.finished_at) : formatDate(run.started_at) }}</td>
<td>
<v-chip :color="runStatusColor(run.status)" size="small">
{{ runStatusLabel(run.status) }}
</v-chip>
</td>
<td class="text-right">{{ run.groups_total }}</td>
<td class="text-right">{{ run.synced_ok }}</td>
<td class="text-right">
<span :class="run.manual_queued > 0 ? 'text-warning font-weight-bold' : ''">
{{ run.manual_queued }}
</span>
</td>
<td class="text-right">
<span :class="run.failed > 0 ? 'text-error font-weight-bold' : ''">
{{ run.failed }}
</span>
</td>
</tr>
</tbody>
</v-table>
<div v-else class="text-medium-emphasis">Заливок пока не было.</div>
</v-card-text>
</v-card>
<v-card class="mb-4">
<v-card-title>Здоровье резервного канала</v-card-title>
<v-card-text>
<v-alert v-if="error" type="error" density="compact" class="mb-4">
{{ error }}
</v-alert>
<template v-if="health">
<div class="mb-2">
Webhook:
<v-chip :color="health.webhook_state === 'live' ? 'success' : 'error'" size="small">
{{ health.webhook_state }}
</v-chip>
</div>
<div class="mb-2">
Последняя сверка:
<v-chip :color="statusColor(health.last_status)" size="small">
{{ health.last_status ?? '—' }}
</v-chip>
<span class="ml-2">{{ health.last_run_at ?? '—' }}</span>
</div>
<div class="mb-4">
Расхождение (drift):
{{ health.drift_ratio !== null ? (health.drift_ratio * 100).toFixed(2) + ' %' : '—' }}
</div>
</template>
<div v-else-if="loading" class="mb-4 text-medium-emphasis">Загрузка</div>
<v-btn data-test="reconcile-now" color="primary" :loading="reconciling" @click="reconcileNow">
Сверить сейчас
</v-btn>
</v-card-text>
</v-card>
<v-card>
<v-card-title>История сверок</v-card-title>
<v-table>
<thead>
<tr>
<th>Начало</th>
<th>Статус</th>
<th>Строк CSV</th>
<th>Совпало</th>
<th>Подобрано</th>
<th>Drift</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.started_at">
<td>{{ row.started_at }}</td>
<td>
<v-chip :color="statusColor(row.status)" size="x-small">{{ row.status }}</v-chip>
</td>
<td>{{ row.total_csv_rows }}</td>
<td>{{ row.matched_count }}</td>
<td>{{ row.recovered_count }}</td>
<td>{{ (row.drift_ratio * 100).toFixed(2) }} %</td>
</tr>
</tbody>
</v-table>
</v-card>
<v-card class="mt-4">
<v-card-title>
Ручная очередь
<v-chip v-if="manualQueue.length" color="warning" class="ml-2" size="small">
{{ manualQueue.length }}
</v-chip>
</v-card-title>
<v-card-text>
<v-alert v-if="manualQueueError" type="error" density="compact">
{{ manualQueueError }}
</v-alert>
<p v-else-if="!manualQueue.length" class="text-medium-emphasis">
Очередь пуста авто-фейловер не понадобился.
</p>
<v-table v-else density="compact">
<thead>
<tr>
<th>Project</th>
<th>Платформа</th>
<th>Операция</th>
<th>Параметры</th>
<th>Причина</th>
<th>Создано</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in manualQueue" :key="row.id">
<td>#{{ row.project_id }}</td>
<td>{{ row.platform }}</td>
<td>{{ row.operation }}</td>
<td>
<code>{{ row.payload_snapshot.unique_key }}</code>
· limit {{ row.payload_snapshot.limit ?? '—' }}
</td>
<td>{{ row.failure_reason }}</td>
<td>{{ formatDate(row.created_at) }}</td>
<td>
<v-btn
size="small"
color="primary"
:data-testid="`resolve-${row.id}`"
:loading="resolvingId === row.id"
@click="askResolve(row.id)"
>
Отметить выполнено
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
<v-dialog :model-value="confirmResolveId !== null" max-width="420" @update:model-value="confirmResolveId = null">
<v-card class="pa-2">
<v-card-title class="text-subtitle-1">Закрыть запись очереди?</v-card-title>
<v-card-text>Подтверждаете, что внесли изменения в crm.bp-gr.ru?</v-card-text>
<v-card-actions class="px-4 pb-3">
<v-spacer />
<v-btn variant="text" @click="confirmResolveId = null">Отмена</v-btn>
<v-btn color="primary" variant="flat" @click="doResolve">Подтверждаю</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="resolveToastOpen" :timeout="3500" :color="resolveToastColor" location="bottom right">
{{ resolveToastText }}
</v-snackbar>
</div>
</template>