feat(import): api/imports.ts + UnknownStatusesDialog (wizard маппинга)
- api/imports.ts: типы ImportLogResource/UnknownStatus/StatusMapping, функции uploadImport/listImports/getImport/getUnknownStatuses/resolveUnknownStatuses (apiClient из ./client, стиль api/dashboard.ts) - UnknownStatusesDialog.vue: wizard маппинга незамапленных статусов воронки (ТЗ §6.4/§6.6), 14 канонических slug, defineExpose(selection, save) - Vitest 3/3 (tests/Frontend/UnknownStatusesDialog.spec.ts) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент исторической миграции лидов (ТЗ §6).
|
||||
* Эндпоинты: POST/GET /api/imports, /api/imports/unknown-statuses, /api/imports/unknown-statuses/resolve.
|
||||
*/
|
||||
|
||||
export interface ImportLogResource {
|
||||
id: number;
|
||||
filename: string;
|
||||
status: 'pending' | 'processing' | 'done' | 'failed';
|
||||
rows_total: number;
|
||||
rows_added: number;
|
||||
rows_updated: number;
|
||||
rows_skipped: number;
|
||||
unknown_statuses_count: number;
|
||||
dry_run: boolean;
|
||||
error_message: string | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface UnknownStatus {
|
||||
id: number;
|
||||
status_ru: string;
|
||||
occurrences: number;
|
||||
}
|
||||
|
||||
export interface StatusMapping {
|
||||
status_ru: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/** POST /api/imports — загрузить CSV. */
|
||||
export async function uploadImport(file: File, dryRun = false): Promise<ImportLogResource> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (dryRun) {
|
||||
form.append('dry_run', '1');
|
||||
}
|
||||
const { data } = await apiClient.post<{ data: ImportLogResource }>('/api/imports', form);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports — история импортов. */
|
||||
export async function listImports(): Promise<ImportLogResource[]> {
|
||||
const { data } = await apiClient.get<{ data: ImportLogResource[] }>('/api/imports');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports/{id} — прогресс одного импорта. */
|
||||
export async function getImport(id: number): Promise<ImportLogResource> {
|
||||
const { data } = await apiClient.get<{ data: ImportLogResource }>(`/api/imports/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** GET /api/imports/unknown-statuses — незамапленные статусы. */
|
||||
export async function getUnknownStatuses(): Promise<UnknownStatus[]> {
|
||||
const { data } = await apiClient.get<{ data: UnknownStatus[] }>('/api/imports/unknown-statuses');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** POST /api/imports/unknown-statuses/resolve — сохранить маппинг. */
|
||||
export async function resolveUnknownStatuses(mappings: StatusMapping[]): Promise<void> {
|
||||
await apiClient.post('/api/imports/unknown-statuses/resolve', { mappings });
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
|
||||
*
|
||||
* Для каждого незамапленного русского статуса пользователь выбирает один из
|
||||
* 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
statuses: UnknownStatus[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
resolved: [];
|
||||
}>();
|
||||
|
||||
/** 14 канонических статусов воронки (ТЗ §6.4). */
|
||||
const STATUS_OPTIONS: { value: string; title: string }[] = [
|
||||
{ value: 'new', title: 'Новые' },
|
||||
{ value: 'viewed', title: 'Просмотрено' },
|
||||
{ value: 'worked', title: 'Проработан' },
|
||||
{ value: 'base', title: 'База' },
|
||||
{ value: 'missed', title: 'Недозвон' },
|
||||
{ value: 'negotiations', title: 'Переговоры' },
|
||||
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
|
||||
{ value: 'partnership', title: 'Партнерка' },
|
||||
{ value: 'paid', title: 'Оплачено' },
|
||||
{ value: 'closed', title: 'Закрыто и не реализовано' },
|
||||
{ value: 'test_drive', title: 'Тест драйв' },
|
||||
{ value: 'hot', title: 'Горячий' },
|
||||
{ value: 'replacement', title: 'На замену' },
|
||||
{ value: 'final_missed', title: 'Конечный недозвон' },
|
||||
];
|
||||
|
||||
const selection = reactive<Record<string, string | null>>({});
|
||||
const saving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const dialogOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const allMapped = computed(
|
||||
() => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]),
|
||||
);
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!allMapped.value) {
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const mappings: StatusMapping[] = props.statuses.map((s) => ({
|
||||
status_ru: s.status_ru,
|
||||
slug: selection[s.status_ru] as string,
|
||||
}));
|
||||
await resolveUnknownStatuses(mappings);
|
||||
emit('resolved');
|
||||
} catch {
|
||||
error.value = 'Не удалось сохранить маппинг. Повторите попытку.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ selection, save });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog v-model="dialogOpen" max-width="640">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Эти статусы из CSV не входят в стандартную воронку. Выберите
|
||||
соответствие — повторный импорт применит маппинг автоматически.
|
||||
</p>
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="d-flex align-center ga-3 mb-3"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{{ status.status_ru }}</strong>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
({{ status.occurrences }} шт.)
|
||||
</span>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="selection[status.status_ru]"
|
||||
:items="STATUS_OPTIONS"
|
||||
label="Статус воронки"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 280px"
|
||||
/>
|
||||
</div>
|
||||
<v-alert v-if="error" type="error" variant="tonal" class="mt-2">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="dialogOpen = false">Отмена</v-btn>
|
||||
<v-btn
|
||||
data-test="save-mappings"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="saving"
|
||||
:disabled="!allMapped"
|
||||
@click="save"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
vi.mock('../../resources/js/api/imports', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/imports')>();
|
||||
return {
|
||||
...orig,
|
||||
resolveUnknownStatuses: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const importsApi = await import('../../resources/js/api/imports');
|
||||
const UnknownStatusesDialog = (await import('../../resources/js/components/import/UnknownStatusesDialog.vue')).default;
|
||||
|
||||
// VDialog в JSDOM не рендерит через teleport — стаб делает <slot/> доступным
|
||||
// для wrapper.text() / find(). Паттерн из EditProjectDialog.spec.ts.
|
||||
function mountDialog() {
|
||||
return mount(UnknownStatusesDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
statuses: [
|
||||
{ id: 1, status_ru: 'Архив', occurrences: 3 },
|
||||
{ id: 2, status_ru: 'Спам', occurrences: 1 },
|
||||
],
|
||||
},
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VDialog: {
|
||||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('UnknownStatusesDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(importsApi.resolveUnknownStatuses).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('рендерит строку на каждый неизвестный статус', async () => {
|
||||
const wrapper = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Архив');
|
||||
expect(wrapper.text()).toContain('Спам');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('кнопка сохранения заблокирована пока не выбраны все маппинги', async () => {
|
||||
const wrapper = mountDialog();
|
||||
await flushPromises();
|
||||
const saveBtn = wrapper.find('[data-test="save-mappings"]');
|
||||
expect(saveBtn.exists()).toBe(true);
|
||||
expect(saveBtn.attributes('disabled')).toBeDefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('сохраняет маппинги и эмитит resolved', async () => {
|
||||
const spy = vi.mocked(importsApi.resolveUnknownStatuses);
|
||||
const wrapper = mountDialog();
|
||||
await flushPromises();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.selection['Архив'] = 'closed';
|
||||
vm.selection['Спам'] = 'closed';
|
||||
await flushPromises();
|
||||
await vm.save();
|
||||
await flushPromises();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith([
|
||||
{ status_ru: 'Архив', slug: 'closed' },
|
||||
{ status_ru: 'Спам', slug: 'closed' },
|
||||
]);
|
||||
expect(wrapper.emitted('resolved')).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user