Отделы: общий фильтр — во все отделы, руководителю уведомления по всем отделам
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
eede13d577
commit
2da10169b8
4 files changed
+81
-17
No files matched your search
@@ -122,7 +122,9 @@ pnpm dev:web # http://localhost:5174
|
||||
- **Отделы** (раздел «Команда»): руководитель создаёт отделы, назначает и снимает сотрудников, задаёт фильтры
|
||||
отдела. У отдела свои объекты и клиенты: сотрудник видит только данные своего отдела, руководитель и
|
||||
суперадмин — все отделы (с выбором отдела в списках). Одно объявление под фильтрами двух отделов приходит
|
||||
каждому своей карточкой; уведомления — сотрудникам отдела. При смене отдела объекты и клиенты сотрудника
|
||||
каждому своей карточкой; общий фильтр компании — в каждый отдел с сотрудниками (и в «без отдела»,
|
||||
если там есть агенты). Уведомления — сотрудникам отдела; руководителю — по общим фильтрам и фильтрам
|
||||
отделов, одно на объект. При смене отдела объекты и клиенты сотрудника
|
||||
переходят с ним. Удалить можно отдел без сотрудников, клиентов и объектов в работе.
|
||||
- **Рабочий список объектов** показывает только актуальное: снятые с List.am и закрытые сделкой или отказом
|
||||
объекты не попадают в список, поиск, подбор, страницу клиента и витрину — они остаются в базе и в «Базе»
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,7 @@ interface ActiveFilter {
|
||||
}
|
||||
|
||||
/** Включённые фильтры компаний с действующим доступом: только по ним парсер что-то ищет. */
|
||||
async function activeFilters(db: Db): Promise<ActiveFilter[]> {
|
||||
export async function activeFilters(db: Db): Promise<ActiveFilter[]> {
|
||||
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<number | null, { common: boolean; personal: Set<number> }>();
|
||||
const target = (department: number | null) => {
|
||||
const t = targets.get(department) ?? { common: false, personal: new Set<number>() };
|
||||
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<number>() };
|
||||
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<number>();
|
||||
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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in new issue
Block a user