Files
portal/app/resources/js/views/admin/AdminSupplierIntegrationView.vue
T
Дмитрий 9443b5b446 feat(supplier): manual queue section in AdminSupplierIntegrationView
Таблица pending-записей яруса 3 + кнопка «Отметить выполнено» с confirm-
диалогом, дёргает POST .../manual-queue/{id}/resolve. Реюз существующего
админ-экрана интеграции с поставщиком (после «Истории сверок»).

NB: spec в tests/Frontend/ (vitest include — tests/Frontend/**, не
resources/.../__tests__/ как указал план Step 11.1). loadManualQueue
defensive Array.isArray-guard — иначе onMounted в чужих spec'ах
(mockResolvedValue без queue-ключа) ловил undefined.length.

Spec §4.6. Task 11 of 12. Vitest 5/5 (2 новых + 3 существующих).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:10 +03:00

236 lines
8.6 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);
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';
}
// --- Ручная очередь (ярус 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 = 'Не удалось загрузить очередь.';
}
}
async function resolveRow(id: number): Promise<void> {
if (!confirm('Подтверждаете, что внесли изменения в crm.bp-gr.ru?')) return;
resolvingId.value = id;
try {
await axios.post(`/api/admin/supplier-integration/manual-queue/${id}/resolve`);
await loadManualQueue();
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
alert(err?.response?.data?.message ?? 'Не удалось закрыть запись.');
} finally {
resolvingId.value = null;
}
}
function formatDate(s: string): string {
return new Date(s).toLocaleString('ru-RU');
}
onMounted(() => {
void load();
void loadManualQueue();
});
</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="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="resolveRow(row.id)"
>
Отметить выполнено
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</div>
</template>