diff --git a/README.md b/README.md index ff4c1b6..f35d668 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,9 @@ pnpm dev:web # http://localhost:5174 - **Отделы** (раздел «Команда»): руководитель создаёт отделы, назначает и снимает сотрудников, задаёт фильтры отдела. У отдела свои объекты и клиенты: сотрудник видит только данные своего отдела, руководитель и суперадмин — все отделы (с выбором отдела в списках). Одно объявление под фильтрами двух отделов приходит - каждому своей карточкой; уведомления — сотрудникам отдела. При смене отдела объекты и клиенты сотрудника + каждому своей карточкой; общий фильтр компании — в каждый отдел с сотрудниками (и в «без отдела», + если там есть агенты). Уведомления — сотрудникам отдела; руководителю — по общим фильтрам и фильтрам + отделов, одно на объект. При смене отдела объекты и клиенты сотрудника переходят с ним. Удалить можно отдел без сотрудников, клиентов и объектов в работе. - **Рабочий список объектов** показывает только актуальное: снятые с List.am и закрытые сделкой или отказом объекты не попадают в список, поиск, подбор, страницу клиента и витрину — они остаются в базе и в «Базе» diff --git a/apps/api/src/departments.int.test.ts b/apps/api/src/departments.int.test.ts index a600e14..1f049d5 100644 --- a/apps/api/src/departments.int.test.ts +++ b/apps/api/src/departments.int.test.ts @@ -2,7 +2,8 @@ import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { hashPassword } from './auth/password.ts'; import { createSession } from './auth/sessions.ts'; -import { leads, listings, users } from './db/schema.ts'; +import { leads, listings, outbox, users } from './db/schema.ts'; +import { activeFilters, distribute } from './ingest/service.ts'; import { testApp } from './testing.ts'; // Неактуальные объекты, «Отправить в бот», избранное и «Не просмотренные», отделы, цена в АМД и ДОЛ. @@ -257,3 +258,41 @@ describe('отделы', () => { expect(((await a2.api.get('/api/departments')).body as { name: string }[]).map((d) => d.name)).toEqual(['Аренда']); }); }); + +describe('общий фильтр компании при отделах', () => { + it('объект приходит в каждый отдел; руководителю — одно уведомление на объект', async () => { + const c = await company(3); + const [a1, a2, a3] = c.agents; + const sales = (await c.boss.api.post('/api/departments', { name: 'Продажи' })).body.id as number; + const rent = (await c.boss.api.post('/api/departments', { name: 'Аренда' })).body.id as number; + await c.boss.api.put(`/api/members/${a1!.id}/department`, { departmentId: sales }); + await c.boss.api.put(`/api/members/${a2!.id}/department`, { departmentId: rent }); + // Третий агент пока без отдела. + await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } }); + + const listing = await addListing({ title: 'Для всех отделов' }); + // Только фильтры этой компании: у прошлых сценариев свои фильтры. + const active = (await activeFilters(t.db)).filter((a) => a.company.id === c.id); + expect(await distribute(t.db, listing, active)).toBe(3); + + for (const agent of [a1!, a2!, a3!]) expect(await titles(agent.api, 'tab=pool')).toContain('Для всех отделов'); + const copies = await t.db.select().from(leads).where(eq(leads.listingId, listing.id)); + expect(copies.map((l) => l.departmentId).sort()).toEqual([sales, rent, null].sort()); + + const notified = async (userId: number) => + (await t.db.select().from(outbox).where(eq(outbox.userId, userId))).filter((m) => m.kind === 'lead' && copies.some((l) => l.id === m.leadId)); + expect(await notified(c.boss.id)).toHaveLength(1); + // Уведомление руководителя — о карточке отдела, а не «без отдела». + const bossLeadId = (await notified(c.boss.id))[0]!.leadId; + const bossLead = copies.find((l) => l.id === bossLeadId)!; + expect(bossLead.departmentId).not.toBeNull(); + for (const agent of [a1!, a2!, a3!]) expect(await notified(agent.id)).toHaveLength(1); + + // Все агенты в отделах — копии «без отдела» больше нет. + await c.boss.api.put(`/api/members/${a3!.id}/department`, { departmentId: sales }); + const next = await addListing({ title: 'Только отделам' }); + expect(await distribute(t.db, next, active)).toBe(2); + const nextCopies = await t.db.select().from(leads).where(eq(leads.listingId, next.id)); + expect(nextCopies.every((l) => l.departmentId !== null)).toBe(true); + }); +}); diff --git a/apps/api/src/ingest/service.ts b/apps/api/src/ingest/service.ts index 3c80ba5..cae8f93 100644 --- a/apps/api/src/ingest/service.ts +++ b/apps/api/src/ingest/service.ts @@ -100,7 +100,7 @@ interface ActiveFilter { } /** Включённые фильтры компаний с действующим доступом: только по ним парсер что-то ищет. */ -async function activeFilters(db: Db): Promise { +export async function activeFilters(db: Db): Promise { const rows = await db .select({ filter: filters, company: companies }) .from(filters) @@ -179,9 +179,11 @@ async function similarPrevious(db: Db, companyId: number, departmentId: number | /** * Сопоставляет объявление с фильтрами и создаёт лиды. У каждого отдела компании свой лид: объект - * приходит в отдел, чей фильтр сработал (общий фильтр компании — в «без отдела», личный фильтр — в отдел - * владельца). Уведомление получают сотрудники этого отдела, если сработал общий фильтр или фильтр отдела, - * и владелец сработавшего личного фильтра. + * приходит в отдел, чей фильтр сработал. Фильтр отдела — в этот отдел, личный фильтр — в отдел владельца, + * общий фильтр компании — в каждый отдел, где есть сотрудники, и в «без отдела», если там есть агенты + * (пока отделов нет — как раньше, в «без отдела»). Уведомление получают сотрудники отдела, если сработал + * общий фильтр или фильтр отдела, и владелец сработавшего личного фильтра. Руководитель получает + * уведомление по общему фильтру и фильтру любого отдела — один раз на объект, в каком бы отделе тот ни оказался. * `republished` — объявление сняли и опубликовали снова под тем же номером: компании, у которой * прошлый лид закрыт сделкой или отказом, объект приходит новым лидом со ссылкой на прошлый. */ @@ -216,21 +218,39 @@ export async function distribute(db: Db, listing: Listing, active: ActiveFilter[ .from(users) .where(and(eq(users.companyId, companyId), isNull(users.removedAt))); const departmentOf = new Map(members.map((u) => [u.id, u.departmentId ?? null])); + // Куда идёт объект по общему фильтру компании: отделы, где есть сотрудники, и «без отдела», если там + // есть агенты. Руководитель видит все отделы, поэтому сам он «без отдела» не держит. + const staffed = [...new Set(members.filter((u) => u.departmentId !== null).map((u) => u.departmentId!))]; + const companyWide: (number | null)[] = [ + ...staffed, + ...(!staffed.length || members.some((u) => u.departmentId === null && u.role !== 'manager') ? [null] : []), + ]; /** Отдел → сработал общий фильтр (компании или отдела) и чьи личные фильтры сработали. */ const targets = new Map }>(); + const target = (department: number | null) => { + const t = targets.get(department) ?? { common: false, personal: new Set() }; + targets.set(department, t); + return t; + }; for (const { filter } of matched) { // Личный фильтр удалённого сотрудника объектов не приносит. if (filter.ownerUserId !== null && !departmentOf.has(filter.ownerUserId)) continue; - const department = filter.ownerUserId === null ? (filter.departmentId ?? null) : departmentOf.get(filter.ownerUserId)!; - const target = targets.get(department) ?? { common: false, personal: new Set() }; - if (filter.ownerUserId === null) target.common = true; - else target.personal.add(filter.ownerUserId); - targets.set(department, target); + if (filter.ownerUserId !== null) target(departmentOf.get(filter.ownerUserId)!).personal.add(filter.ownerUserId); + else if (filter.departmentId !== null) target(filter.departmentId).common = true; + else for (const department of companyWide) target(department).common = true; } - for (const [departmentId, { common, personal }] of targets) { + // Руководителю — одно уведомление на объект; «без отдела» — последним, чтобы уведомление было + // привязано к карточке отдела, где объект скорее возьмут в работу. + const managersNotified = new Set(); + const ordered = [...targets].sort(([a], [b]) => (a === null ? 1 : 0) - (b === null ? 1 : 0)); + for (const [departmentId, { common, personal }] of ordered) { const recipients = notify - ? members.filter((u) => (common && sameDepartment(u.departmentId, departmentId)) || personal.has(u.id)) + ? members.filter((u) => { + // Руководитель — по общим фильтрам и фильтрам любого отдела (по личным фильтрам агентов — нет). + if (u.role === 'manager') return !managersNotified.has(u.id) && (common || personal.has(u.id)); + return (common && sameDepartment(u.departmentId, departmentId)) || personal.has(u.id); + }) : []; const [last] = await db .select({ id: leads.id, code: pipelineStatuses.code }) @@ -245,7 +265,10 @@ export async function distribute(db: Db, listing: Listing, active: ActiveFilter[ const previousLeadId = last?.id ?? (await similarPrevious(db, companyId, departmentId, listing)); const lead = await db.transaction((tx) => createLeadFromListing(tx, companyId, listing, recipients, previousLeadId, departmentId)); - if (lead) created++; + if (lead) { + created++; + for (const u of recipients) if (u.role === 'manager') managersNotified.add(u.id); + } } } return created; diff --git a/apps/web/src/crm/i18n.ts b/apps/web/src/crm/i18n.ts index 13c470c..53043cf 100644 --- a/apps/web/src/crm/i18n.ts +++ b/apps/web/src/crm/i18n.ts @@ -216,7 +216,7 @@ const ru = { linkBtn: 'Привязать Telegram', openBot: 'Открыть бота', departments: 'Отделы', department: 'Отдел', noDepartment: 'Без отдела', allDepartments: 'Все отделы', add: 'Новый отдел', namePh: 'Название отдела', rename: 'Переименовать', del: 'Удалить отдел', delConfirm: 'Удалить отдел? Его фильтры и объекты из пула, которые никто не брал, удалятся.', - hint: 'У каждого отдела свои фильтры, объекты и клиенты. Сотрудник видит только данные своего отдела, руководитель — все отделы. При смене отдела объекты и клиенты, которые ведёт сотрудник, переходят вместе с ним.', + hint: 'У каждого отдела свои фильтры, объекты и клиенты. Сотрудник видит только данные своего отдела, руководитель — все отделы. Объекты по общим фильтрам компании приходят в каждый отдел своей карточкой. При смене отдела объекты и клиенты, которые ведёт сотрудник, переходят вместе с ним.', stats: (m: number, f: number, l: number, c: number) => `сотрудников: ${m} · фильтров: ${f} · объектов: ${l} · клиентов: ${c}`, kept: (n: number) => `Объектов осталось в прежнем отделе: ${n} — в новом уже есть карточки тех же объявлений.`, none: 'Отделов пока нет — вся компания работает с общими объектами и клиентами.', @@ -455,7 +455,7 @@ const hy: Dict = { linkBtn: 'Կապել Telegram-ը', openBot: 'Բացել բոտը', departments: 'Բաժիններ', department: 'Բաժին', noDepartment: 'Առանց բաժնի', allDepartments: 'Բոլոր բաժինները', add: 'Նոր բաժին', namePh: 'Բաժնի անվանումը', rename: 'Վերանվանել', del: 'Ջնջել բաժինը', delConfirm: 'Ջնջե՞լ բաժինը։ Կջնջվեն նրա ֆիլտրերը և պուլի այն օբյեկտները, որոնք ոչ ոք չի վերցրել։', - hint: 'Յուրաքանչյուր բաժին ունի իր ֆիլտրերը, օբյեկտները և հաճախորդները։ Աշխատակիցը տեսնում է միայն իր բաժնի տվյալները, ղեկավարը՝ բոլոր բաժինները։ Բաժինը փոխելիս աշխատակցի վարած օբյեկտներն ու հաճախորդները տեղափոխվում են նրա հետ։', + hint: 'Յուրաքանչյուր բաժին ունի իր ֆիլտրերը, օբյեկտները և հաճախորդները։ Աշխատակիցը տեսնում է միայն իր բաժնի տվյալները, ղեկավարը՝ բոլոր բաժինները։ Ընկերության ընդհանուր ֆիլտրերով օբյեկտները գալիս են յուրաքանչյուր բաժին՝ իր քարտով։ Բաժինը փոխելիս աշխատակցի վարած օբյեկտներն ու հաճախորդները տեղափոխվում են նրա հետ։', stats: (m: number, f: number, l: number, c: number) => `աշխատակից՝ ${m} · ֆիլտր՝ ${f} · օբյեկտ՝ ${l} · հաճախորդ՝ ${c}`, kept: (n: number) => `Նախկին բաժնում մնաց ${n} օբյեկտ․ նորում արդեն կան նույն հայտարարությունների քարտերը։`, none: 'Բաժիններ դեռ չկան․ ամբողջ ընկերությունն աշխատում է ընդհանուր օբյեկտներով և հաճախորդներով։', @@ -692,7 +692,7 @@ const en: Dict = { linkBtn: 'Link Telegram', openBot: 'Open the bot', departments: 'Departments', department: 'Department', noDepartment: 'No department', allDepartments: 'All departments', add: 'New department', namePh: 'Department name', rename: 'Rename', del: 'Delete department', delConfirm: 'Delete the department? Its filters and the pool properties nobody took will be deleted.', - hint: 'Each department has its own filters, properties and clients. An employee sees only their department’s data; the manager sees all departments. When an employee changes department, the properties and clients they handle move with them.', + hint: 'Each department has its own filters, properties and clients. An employee sees only their department’s data; the manager sees all departments. Properties from company-wide filters reach every department as its own card. When an employee changes department, the properties and clients they handle move with them.', stats: (m: number, f: number, l: number, c: number) => `employees: ${m} · filters: ${f} · properties: ${l} · clients: ${c}`, kept: (n: number) => `Properties left in the previous department: ${n} — the new one already has cards for the same listings.`, none: 'No departments yet — the whole company works with shared properties and clients.',