2026-05-18 17:55:07 +03:00
|
|
|
|
<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);
|
|
|
|
|
|
|
2026-05-20 14:00:22 +03:00
|
|
|
|
// --- 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 04:27:32 +03:00
|
|
|
|
// --- Тумблер «Разблокировка смены источника» (флаг 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++; // синхронизировать тумблер с фактом (вкл. при ошибке)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 17:55:07 +03:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 18:52:39 +03:00
|
|
|
|
// --- Эпик 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 12:44:34 +03:00
|
|
|
|
// --- Ручная очередь (ярус 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 = 'Не удалось загрузить очередь.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 17:08:51 +03:00
|
|
|
|
// 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;
|
2026-05-19 12:44:34 +03:00
|
|
|
|
resolvingId.value = id;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await axios.post(`/api/admin/supplier-integration/manual-queue/${id}/resolve`);
|
|
|
|
|
|
await loadManualQueue();
|
2026-06-21 17:08:51 +03:00
|
|
|
|
resolveToastColor.value = 'success';
|
|
|
|
|
|
resolveToastText.value = 'Запись закрыта.';
|
|
|
|
|
|
resolveToastOpen.value = true;
|
2026-05-19 12:44:34 +03:00
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
|
const err = e as { response?: { data?: { message?: string } } };
|
2026-06-21 17:08:51 +03:00
|
|
|
|
resolveToastColor.value = 'error';
|
|
|
|
|
|
resolveToastText.value = err?.response?.data?.message ?? 'Не удалось закрыть запись.';
|
|
|
|
|
|
resolveToastOpen.value = true;
|
2026-05-19 12:44:34 +03:00
|
|
|
|
} finally {
|
|
|
|
|
|
resolvingId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(s: string): string {
|
|
|
|
|
|
return new Date(s).toLocaleString('ru-RU');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
void loadManualQueue();
|
2026-05-20 14:00:22 +03:00
|
|
|
|
void loadExportMode();
|
2026-06-26 04:27:32 +03:00
|
|
|
|
void loadSourceEditFlag();
|
2026-06-25 18:52:39 +03:00
|
|
|
|
void loadSyncRuns();
|
2026-05-19 12:44:34 +03:00
|
|
|
|
});
|
2026-05-18 17:55:07 +03:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="pa-6">
|
|
|
|
|
|
<h1 class="text-h5 mb-4">Интеграция с поставщиком</h1>
|
|
|
|
|
|
|
2026-05-20 14:00:22 +03:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-06-17 05:17:12 +03:00
|
|
|
|
<v-btn data-testid="export-mode-online" value="online" @click="setExportMode('online')">
|
2026-05-20 14:00:22 +03:00
|
|
|
|
Онлайн
|
|
|
|
|
|
</v-btn>
|
2026-06-17 05:17:12 +03:00
|
|
|
|
<v-btn data-testid="export-mode-batch" value="batch" @click="setExportMode('batch')">
|
2026-05-20 14:00:22 +03:00
|
|
|
|
Пакетный
|
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
</v-btn-toggle>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-caption text-medium-emphasis mt-3 mb-0">
|
2026-06-17 05:17:12 +03:00
|
|
|
|
Онлайн — изменения проекта переносятся к поставщику сразу. Пакетный — ночной синк в 18:00
|
|
|
|
|
|
(SyncSupplierProjectsJob).
|
2026-05-20 14:00:22 +03:00
|
|
|
|
</p>
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
</v-card>
|
|
|
|
|
|
|
2026-06-26 04:27:32 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-06-25 18:52:39 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-18 17:55:07 +03:00
|
|
|
|
<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>
|
2026-06-17 05:17:12 +03:00
|
|
|
|
<v-btn data-test="reconcile-now" color="primary" :loading="reconciling" @click="reconcileNow">
|
2026-05-18 17:55:07 +03:00
|
|
|
|
Сверить сейчас
|
|
|
|
|
|
</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>
|
2026-05-19 12:44:34 +03:00
|
|
|
|
|
|
|
|
|
|
<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"
|
2026-06-21 17:08:51 +03:00
|
|
|
|
@click="askResolve(row.id)"
|
2026-05-19 12:44:34 +03:00
|
|
|
|
>
|
|
|
|
|
|
Отметить выполнено
|
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</v-table>
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
</v-card>
|
2026-06-21 17:08:51 +03:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-05-18 17:55:07 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|