01d292f5a9
План 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).
306 lines
12 KiB
Vue
306 lines
12 KiB
Vue
<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>
|