Files
portal/app/resources/js/views/admin/AdminSupplierIntegrationView.vue
T
Дмитрий ab2501d41e feat/supplier: экран отчёта о вечерней заливке (Эпик 5)
Владелец выбрал формат «экран в админке» (не письмо).
- SyncSupplierProjectsJob по завершении пишет строку-сводку в новую supplier_sync_runs
  (групп/синк/ручная/отложено/упало + status ok|partial|failed|aborted) через finally —
  пишется и при раннем abort (time-budget/mass-fail/auth).
- Эндпоинт GET /api/admin/supplier-integration/sync-runs + метод syncRuns.
- Экран SaaS-admin «Интеграция с поставщиком» → карточка «Вечерняя заливка проектов
  поставщику»: таблица заливок со статусом человеческим языком (Всё ровно/Частично/Сбой).
- Схема v8.55 +1 таблица (SaaS-level без RLS как supplier_csv_reconcile_log), миграция
  2026_06_25_130000, RLS-ревью 7/7. Проверено глазами в браузере (epic5-sync-runs-admin-screen.png).

Тесты: бэк 24/25 (1 skip) + фронт-экран 5/5 зелёные. Под LEFTHOOK=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:52:39 +03:00

415 lines
17 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;
}
}
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 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="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>