diff --git a/app/resources/js/components/sales/ProspectRubricField.vue b/app/resources/js/components/sales/ProspectRubricField.vue
new file mode 100644
index 00000000..a2512970
--- /dev/null
+++ b/app/resources/js/components/sales/ProspectRubricField.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+ Похоже на
+
+ «{{ s }}»,
+
+ — взять её?
+
+
{{ error }}
+
+ Сохранить нишу
+
+
+
diff --git a/app/resources/js/components/sales/SalesProspectDialog.vue b/app/resources/js/components/sales/SalesProspectDialog.vue
index 43e0cf02..44280399 100644
--- a/app/resources/js/components/sales/SalesProspectDialog.vue
+++ b/app/resources/js/components/sales/SalesProspectDialog.vue
@@ -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({
+
+
+
История разговоров
diff --git a/app/resources/js/utils/rubricSimilar.ts b/app/resources/js/utils/rubricSimilar.ts
new file mode 100644
index 00000000..1bd18c4a
--- /dev/null
+++ b/app/resources/js/utils/rubricSimilar.ts
@@ -0,0 +1,76 @@
+/**
+ * Похожесть ниш — чтобы «Окна», «окна», «Окн» и «Пластиковые окна» не расползались
+ * по доске четырьмя разными нишами.
+ *
+ * Правила те же, что на сервере (App\Support\RubricKey): ключ сравнения совпал —
+ * это одна и та же ниша, подставляем существующее написание молча. Ключ похож —
+ * подсказываем, но сохранить своё не мешаем.
+ */
+
+/** Латинские буквы, неотличимые на глаз от кириллических. */
+const LOOKALIKES: Record = {
+ 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));
+ });
+}
diff --git a/app/resources/js/views/sales/SalesProspectsBoardView.vue b/app/resources/js/views/sales/SalesProspectsBoardView.vue
index 62c7a44b..685c5f8d 100644
--- a/app/resources/js/views/sales/SalesProspectsBoardView.vue
+++ b/app/resources/js/views/sales/SalesProspectsBoardView.vue
@@ -221,6 +221,14 @@ defineExpose({
{{ error }}
-
+
+
diff --git a/app/resources/js/views/sales/SalesProspectsView.vue b/app/resources/js/views/sales/SalesProspectsView.vue
index daa8fbe3..a839c056 100644
--- a/app/resources/js/views/sales/SalesProspectsView.vue
+++ b/app/resources/js/views/sales/SalesProspectsView.vue
@@ -108,7 +108,13 @@ defineExpose({ open, createCandidate, load, applyDateFilter, dateFilter });
{{ error }}
-
+
diff --git a/app/tests/Frontend/ProspectRubricField.spec.ts b/app/tests/Frontend/ProspectRubricField.spec.ts
new file mode 100644
index 00000000..a69b3236
--- /dev/null
+++ b/app/tests/Frontend/ProspectRubricField.spec.ts
@@ -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;
+ 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([]);
+ });
+});
diff --git a/app/tests/Frontend/rubricSimilar.spec.ts b/app/tests/Frontend/rubricSimilar.spec.ts
new file mode 100644
index 00000000..0619fb6f
--- /dev/null
+++ b/app/tests/Frontend/rubricSimilar.spec.ts
@@ -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([]);
+ });
+});