feat(воронка): поле «Ниша» в карточке с подсказкой похожих

This commit is contained in:
Дмитрий
2026-08-04 14:05:12 +03:00
parent 4b8c4cc729
commit 64a426ec0e
7 changed files with 337 additions and 2 deletions
@@ -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" />
+76
View File
@@ -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([]);
});
});
+38
View File
@@ -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([]);
});
});