Files
portal/app/resources/js/views/admin/AdminSupplierIntegrationView.vue
T
Дмитрий 01d292f5a9 feat(admin): supplier export-mode toggle (online|batch) endpoint + UI
План 4 Task 1 эпика project-migration-redesign.

- AdminSupplierIntegrationController +getExportMode/setExportMode
  (validation in:online,batch; system_settings upsert).
- Routes: GET/POST /api/admin/supplier-integration/export-mode
  в admin-группе рядом с manual-queue.
- AdminSupplierIntegrationView.vue +секция «Режим экспорта проектов»
  с v-btn-toggle (online|batch), подпись о ночном синке 18:00.
- Pest 3/3 + Vitest 2/2 (+ соседние 5 не сломаны).
- phpstan-baseline.neon +6 ignore (Pest TestCall::actingAs/getJson/postJson
  — типовой паттерн, как в SupplierManualQueueTest).
2026-05-20 14:34:22 +03:00

306 lines
12 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';
}
// --- Ручная очередь (ярус 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();
void loadExportMode();
});
</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">
<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>