feat(воронка): поле «Ниша» в карточке с подсказкой похожих
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Поле «Ниша» карточки воронки.
|
||||
*
|
||||
* Ниша обычно приезжает сама — из «Поиска клиентов» или из прогрева. Руками её
|
||||
* вписывают там, где взять неоткуда: карточки, заведённые менеджером самостоятельно.
|
||||
* Чтобы на доске не расплодились «Окна» / «Пластиковые окна» / «Окн», поле сверяет
|
||||
* ввод с уже живущими нишами: то же самое подставляет молча, похожее подсказывает,
|
||||
* но сохранить своё не мешает.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
extractSalesErrorMessage,
|
||||
listProspectRubrics,
|
||||
updateProspectRubric,
|
||||
} from '../../api/sales';
|
||||
import { findSameRubric, findSimilarRubrics } from '../../utils/rubricSimilar';
|
||||
|
||||
const props = defineProps<{ prospectId: number; rubric: string | null }>();
|
||||
const emit = defineEmits<{ saved: [rubric: string | null] }>();
|
||||
|
||||
const known = ref<string[]>([]);
|
||||
const draft = ref(props.rubric ?? '');
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
/** Ниша с тем же ключом — её написание и сохраним. */
|
||||
const same = computed(() => findSameRubric(draft.value, known.value));
|
||||
/** Похожие ниши — только подсказка. */
|
||||
const similar = computed(() => (same.value ? [] : findSimilarRubrics(draft.value, known.value)));
|
||||
|
||||
/** Что реально уйдёт на сервер: существующее написание, если ключ совпал. */
|
||||
const toSave = computed(() => same.value ?? draft.value.trim());
|
||||
|
||||
const dirty = computed(() => toSave.value !== (props.rubric ?? ''));
|
||||
|
||||
watch(
|
||||
() => props.rubric,
|
||||
(v) => {
|
||||
draft.value = v ?? '';
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
known.value = (await listProspectRubrics()).map((r) => r.value ?? '').filter(Boolean);
|
||||
} catch {
|
||||
/* без подсказок поле всё равно работает */
|
||||
}
|
||||
});
|
||||
|
||||
function takeExisting(value: string): void {
|
||||
draft.value = value;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await updateProspectRubric(props.prospectId, toSave.value);
|
||||
emit('saved', updated.rubric ?? null);
|
||||
if (updated.rubric && !known.value.includes(updated.rubric)) {
|
||||
known.value = [...known.value, updated.rubric];
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = extractSalesErrorMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ draft, same, similar, save, takeExisting });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prospect-rubric-field">
|
||||
<v-text-field
|
||||
v-model="draft"
|
||||
label="Ниша"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
data-testid="prospect-rubric-input"
|
||||
/>
|
||||
<div v-if="similar.length" class="text-caption mt-1" data-testid="prospect-rubric-hint">
|
||||
Похоже на
|
||||
<template v-for="(s, i) in similar" :key="s">
|
||||
<a href="#" @click.prevent="takeExisting(s)">«{{ s }}»</a
|
||||
><span v-if="i < similar.length - 1">, </span>
|
||||
</template>
|
||||
— взять её?
|
||||
</div>
|
||||
<div v-if="error" class="text-caption text-error mt-1">{{ error }}</div>
|
||||
<v-btn
|
||||
v-if="dirty"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="mt-1"
|
||||
:loading="saving"
|
||||
data-testid="prospect-rubric-save"
|
||||
@click="save"
|
||||
>
|
||||
Сохранить нишу
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,11 +16,14 @@ import { stageMeta } from '../../utils/prospectStages';
|
||||
import { cleanContacts, toForm, type ContactForm } from '../../utils/prospectContacts';
|
||||
import { siteHref as prospectSiteHref } from '../../utils/prospectSite';
|
||||
import ProspectContactsEditor from './ProspectContactsEditor.vue';
|
||||
import ProspectRubricField from './ProspectRubricField.vue';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; prospect: SalesProspect }>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: boolean];
|
||||
save: [payload: ProspectResultPayload];
|
||||
/** Ниша поправлена руками — доске надо перерисоваться с новой плашкой. */
|
||||
'rubric-saved': [rubric: string | null];
|
||||
}>();
|
||||
|
||||
const action = ref<
|
||||
@@ -408,6 +411,15 @@ defineExpose({
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Ниша: у карточек из поиска приезжает сама, «своим» её вписывают руками. -->
|
||||
<div class="mb-5">
|
||||
<ProspectRubricField
|
||||
:prospect-id="prospect.id"
|
||||
:rubric="prospect.rubric"
|
||||
@saved="emit('rubric-saved', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-overline text-medium-emphasis">История разговоров</div>
|
||||
<div class="mt-4">
|
||||
<v-progress-linear v-if="notesLoading" indeterminate height="2" />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Похожесть ниш — чтобы «Окна», «окна», «Окн» и «Пластиковые окна» не расползались
|
||||
* по доске четырьмя разными нишами.
|
||||
*
|
||||
* Правила те же, что на сервере (App\Support\RubricKey): ключ сравнения совпал —
|
||||
* это одна и та же ниша, подставляем существующее написание молча. Ключ похож —
|
||||
* подсказываем, но сохранить своё не мешаем.
|
||||
*/
|
||||
|
||||
/** Латинские буквы, неотличимые на глаз от кириллических. */
|
||||
const LOOKALIKES: Record<string, string> = {
|
||||
a: 'а', c: 'с', e: 'е', o: 'о', p: 'р', x: 'х',
|
||||
y: 'у', k: 'к', m: 'м', h: 'н', t: 'т', b: 'в',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ключ сравнения. Латинские двойники подменяются ТОЛЬКО в строке, где есть
|
||||
* кириллица: иначе честная ниша «SPA» превратилась бы в «sра».
|
||||
*/
|
||||
export function rubricKey(raw: string | null | undefined): string {
|
||||
let s = (raw ?? '').trim().toLowerCase();
|
||||
|
||||
if (/\p{Script=Cyrillic}/u.test(s)) {
|
||||
s = s.replace(/[acepxykmhtb]/g, (ch) => LOOKALIKES[ch] ?? ch);
|
||||
}
|
||||
|
||||
s = s.replace(/ё/g, 'е').replace(/[^\p{L}\p{N}]+/gu, ' ');
|
||||
|
||||
return s.trim().replace(/\s+/gu, ' ');
|
||||
}
|
||||
|
||||
/** Расстояние Левенштейна между двумя ключами. */
|
||||
function distance(a: string, b: string): number {
|
||||
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let diag = prev[0];
|
||||
prev[0] = i;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const tmp = prev[j];
|
||||
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
diag = tmp;
|
||||
}
|
||||
}
|
||||
return prev[b.length];
|
||||
}
|
||||
|
||||
/** Существующая ниша с тем же ключом, если такая есть. */
|
||||
export function findSameRubric(raw: string, known: string[]): string | null {
|
||||
const key = rubricKey(raw);
|
||||
if (key === '') return null;
|
||||
|
||||
return known.find((k) => rubricKey(k) === key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Существующие ниши, похожие на введённую: опечатка (расстояние ≤ 2) либо
|
||||
* вхождение целым словом («Окна» внутри «Пластиковые окна»). То же самое
|
||||
* похожим не считается — это отдельный случай, см. findSameRubric.
|
||||
*/
|
||||
export function findSimilarRubrics(raw: string, known: string[]): string[] {
|
||||
const key = rubricKey(raw);
|
||||
if (key === '') return [];
|
||||
|
||||
const words = key.split(' ');
|
||||
|
||||
return known.filter((candidate) => {
|
||||
const other = rubricKey(candidate);
|
||||
if (other === '' || other === key) return false;
|
||||
|
||||
if (distance(key, other) <= 2) return true;
|
||||
|
||||
const otherWords = other.split(' ');
|
||||
|
||||
return otherWords.every((w) => words.includes(w)) || words.every((w) => otherWords.includes(w));
|
||||
});
|
||||
}
|
||||
@@ -221,6 +221,14 @@ defineExpose({
|
||||
<v-alert v-if="error" type="warning" density="compact" class="mb-3">{{ error }}</v-alert>
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
|
||||
<SalesProspectBoard :prospects="prospects" @open="open" />
|
||||
<SalesProspectDialog v-if="selected" v-model="dialog" :prospect="selected" @save="save" />
|
||||
<!-- rubric-saved: ниша поправлена руками — перечитываем доску, чтобы плашка и
|
||||
список ниш в фильтре сошлись с тем, что теперь в базе. -->
|
||||
<SalesProspectDialog
|
||||
v-if="selected"
|
||||
v-model="dialog"
|
||||
:prospect="selected"
|
||||
@save="save"
|
||||
@rubric-saved="load"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -108,7 +108,13 @@ defineExpose({ open, createCandidate, load, applyDateFilter, dateFilter });
|
||||
<v-alert v-if="error" type="warning" density="compact" class="mb-3">{{ error }}</v-alert>
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
|
||||
<SalesProspectBoard :prospects="prospects" @open="open" />
|
||||
<SalesProspectDialog v-if="selected" v-model="dialog" :prospect="selected" @save="save" />
|
||||
<SalesProspectDialog
|
||||
v-if="selected"
|
||||
v-model="dialog"
|
||||
:prospect="selected"
|
||||
@save="save"
|
||||
@rubric-saved="load"
|
||||
/>
|
||||
<SalesProspectCreateDialog v-model="createDialog" @create="createCandidate" />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../resources/js/api/sales', () => ({
|
||||
listProspectRubrics: vi.fn().mockResolvedValue([
|
||||
{ value: 'Окна', count: 4 },
|
||||
{ value: 'Кровля', count: 2 },
|
||||
]),
|
||||
updateProspectRubric: vi.fn().mockImplementation((id: number, rubric: string) =>
|
||||
Promise.resolve({ id, rubric })),
|
||||
extractSalesErrorMessage: () => 'err',
|
||||
}));
|
||||
|
||||
import ProspectRubricField from '../../resources/js/components/sales/ProspectRubricField.vue';
|
||||
import { updateProspectRubric } from '../../resources/js/api/sales';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
interface FieldVm {
|
||||
draft: string;
|
||||
same: string | null;
|
||||
similar: string[];
|
||||
save: () => Promise<void>;
|
||||
takeExisting: (value: string) => void;
|
||||
}
|
||||
|
||||
describe('ProspectRubricField', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function mountField(rubric: string | null = null) {
|
||||
return mount(ProspectRubricField, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { prospectId: 5, rubric },
|
||||
});
|
||||
}
|
||||
|
||||
it('другой регистр молча подставляет существующее написание', async () => {
|
||||
const w = mountField();
|
||||
await flushPromises();
|
||||
const vm = w.vm as unknown as FieldVm;
|
||||
|
||||
vm.draft = 'окна';
|
||||
await flushPromises();
|
||||
expect(vm.same).toBe('Окна');
|
||||
expect(vm.similar).toEqual([]);
|
||||
|
||||
await vm.save();
|
||||
expect(updateProspectRubric).toHaveBeenCalledWith(5, 'Окна');
|
||||
});
|
||||
|
||||
it('похожая ниша подсказывается, но сохранить своё даёт', async () => {
|
||||
const w = mountField();
|
||||
await flushPromises();
|
||||
const vm = w.vm as unknown as FieldVm;
|
||||
|
||||
vm.draft = 'Пластиковые окна';
|
||||
await flushPromises();
|
||||
expect(vm.same).toBeNull();
|
||||
expect(vm.similar).toContain('Окна');
|
||||
|
||||
await vm.save();
|
||||
expect(updateProspectRubric).toHaveBeenCalledWith(5, 'Пластиковые окна');
|
||||
});
|
||||
|
||||
it('кнопка «взять её» подставляет существующую нишу', async () => {
|
||||
const w = mountField();
|
||||
await flushPromises();
|
||||
const vm = w.vm as unknown as FieldVm;
|
||||
|
||||
vm.draft = 'Пластиковые окна';
|
||||
await flushPromises();
|
||||
vm.takeExisting('Окна');
|
||||
expect(vm.draft).toBe('Окна');
|
||||
});
|
||||
|
||||
it('совсем новая ниша ничего не подсказывает', async () => {
|
||||
const w = mountField();
|
||||
await flushPromises();
|
||||
const vm = w.vm as unknown as FieldVm;
|
||||
|
||||
vm.draft = 'Автосервис';
|
||||
await flushPromises();
|
||||
expect(vm.same).toBeNull();
|
||||
expect(vm.similar).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rubricKey, findSameRubric, findSimilarRubrics } from '../../resources/js/utils/rubricSimilar';
|
||||
|
||||
describe('rubricSimilar', () => {
|
||||
const known = ['Окна', 'Кровля', 'Частные стоматологии'];
|
||||
|
||||
it('ключ не зависит от регистра, пробелов, ё и пунктуации', () => {
|
||||
expect(rubricKey(' ОКНА ')).toBe(rubricKey('окна'));
|
||||
expect(rubricKey('«Клёны».')).toBe(rubricKey('клены'));
|
||||
});
|
||||
|
||||
it('латинские двойники в русском слове приводятся к кириллице', () => {
|
||||
expect(rubricKey('Окнa')).toBe(rubricKey('Окна'));
|
||||
});
|
||||
|
||||
it('чисто латинское название не калечится', () => {
|
||||
expect(rubricKey('SPA')).toBe('spa');
|
||||
});
|
||||
|
||||
it('то же самое находится как точное совпадение', () => {
|
||||
expect(findSameRubric('окна', known)).toBe('Окна');
|
||||
expect(findSameRubric('Кровля', known)).toBe('Кровля');
|
||||
expect(findSameRubric('Кровельные работы', known)).toBeNull();
|
||||
});
|
||||
|
||||
it('опечатка и вложенное слово попадают в похожие', () => {
|
||||
expect(findSimilarRubrics('Окн', known)).toContain('Окна');
|
||||
expect(findSimilarRubrics('Пластиковые окна', known)).toContain('Окна');
|
||||
});
|
||||
|
||||
it('точное совпадение похожим не считается', () => {
|
||||
expect(findSimilarRubrics('окна', known)).toEqual([]);
|
||||
});
|
||||
|
||||
it('непохожее не предлагается', () => {
|
||||
expect(findSimilarRubrics('Автосервис', known)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user