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 @@ + + + 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([]); + }); +});