Отделы: общий фильтр — во все отделы, руководителю уведомления по всем отделам
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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user