2026-10-21 17:16:31 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
ConflictException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
|
|
|
|
|
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
|
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
|
|
|
|
|
const NAME_MAX = 80;
|
|
|
|
|
|
|
|
|
|
/** نام گروه برای مقایسه: فاصلههای اضافه و ی/ک عربی یکسان میشوند */
|
|
|
|
|
function normalizeGroupName(raw: string): string {
|
|
|
|
|
return raw
|
|
|
|
|
.replace(/\u064A/g, '\u06CC')
|
|
|
|
|
.replace(/\u0643/g, '\u06A9')
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
.trim()
|
|
|
|
|
.slice(0, NAME_MAX);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */
|
|
|
|
|
export function splitGroupNames(raw: string | null | undefined): string[] {
|
|
|
|
|
if (!raw) return [];
|
|
|
|
|
return [
|
|
|
|
|
...new Set(
|
|
|
|
|
raw
|
|
|
|
|
.split(/\s*[،,/]\s*/)
|
|
|
|
|
.map(normalizeGroupName)
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const groupSummarySelect = {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
color: true,
|
|
|
|
|
} satisfies Prisma.CustomerGroupSelect;
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class CustomerGroupsService {
|
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
|
|
|
|
|
|
async list(organizationId: string): Promise<CustomerGroupDto[]> {
|
|
|
|
|
const rows = await this.prisma.customerGroup.findMany({
|
|
|
|
|
where: { organizationId },
|
|
|
|
|
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
2026-10-26 11:05:34 +03:30
|
|
|
include: {
|
|
|
|
|
_count: { select: { people: true, companies: true } },
|
|
|
|
|
parent: { select: { name: true } },
|
|
|
|
|
},
|
2026-10-21 17:16:31 +03:30
|
|
|
});
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.name,
|
|
|
|
|
color: r.color,
|
|
|
|
|
position: r.position,
|
|
|
|
|
fromTahesab: Boolean(r.tahesabGid),
|
|
|
|
|
personCount: r._count.people,
|
|
|
|
|
companyCount: r._count.companies,
|
2026-10-26 11:05:34 +03:30
|
|
|
parentId: r.parentId,
|
|
|
|
|
parentName: r.parent?.name ?? null,
|
2026-10-21 17:16:31 +03:30
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async create(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
dto: { name: string; color?: CustomerGroupColor },
|
|
|
|
|
): Promise<CustomerGroupDto> {
|
|
|
|
|
const name = this.cleanName(dto.name);
|
|
|
|
|
const max = await this.prisma.customerGroup.aggregate({
|
|
|
|
|
where: { organizationId },
|
|
|
|
|
_max: { position: true },
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await this.prisma.customerGroup.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
name,
|
|
|
|
|
color: this.cleanColor(dto.color),
|
|
|
|
|
position: (max._max.position ?? -1) + 1,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw this.mapError(e);
|
|
|
|
|
}
|
|
|
|
|
return (await this.list(organizationId)).find((g) => g.name === name)!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
id: string,
|
2026-10-26 11:05:34 +03:30
|
|
|
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
|
2026-10-21 17:16:31 +03:30
|
|
|
): Promise<CustomerGroupDto> {
|
|
|
|
|
await this.require(organizationId, id);
|
2026-10-26 11:05:34 +03:30
|
|
|
const parentId =
|
|
|
|
|
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
|
|
|
|
|
if (parentId !== undefined) {
|
|
|
|
|
await this.assertParent(organizationId, id, parentId);
|
|
|
|
|
}
|
2026-10-21 17:16:31 +03:30
|
|
|
try {
|
|
|
|
|
await this.prisma.customerGroup.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data: {
|
|
|
|
|
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
|
|
|
|
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
2026-10-26 11:05:34 +03:30
|
|
|
...(parentId !== undefined ? { parentId } : {}),
|
2026-10-21 17:16:31 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw this.mapError(e);
|
|
|
|
|
}
|
|
|
|
|
return (await this.list(organizationId)).find((g) => g.id === id)!;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 11:05:34 +03:30
|
|
|
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
|
|
|
|
|
private async assertParent(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
id: string,
|
|
|
|
|
parentId: string | null,
|
|
|
|
|
) {
|
|
|
|
|
if (parentId == null) return;
|
|
|
|
|
if (parentId === id) {
|
|
|
|
|
throw new BadRequestException('گروه نمیتواند زیرمجموعهٔ خودش باشد.');
|
|
|
|
|
}
|
|
|
|
|
const parent = await this.prisma.customerGroup.findFirst({
|
|
|
|
|
where: { id: parentId, organizationId },
|
|
|
|
|
select: { id: true, parentId: true },
|
|
|
|
|
});
|
|
|
|
|
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
|
|
|
|
|
if (parent.parentId) {
|
|
|
|
|
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
|
|
|
|
|
}
|
|
|
|
|
const childCount = await this.prisma.customerGroup.count({
|
|
|
|
|
where: { organizationId, parentId: id },
|
|
|
|
|
});
|
|
|
|
|
if (childCount > 0) {
|
|
|
|
|
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمیتواند فرعی شود.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 17:16:31 +03:30
|
|
|
/** مشتریها حذف نمیشوند؛ فقط این گروه از رویشان برداشته میشود */
|
|
|
|
|
async remove(organizationId: string, id: string): Promise<void> {
|
|
|
|
|
await this.require(organizationId, id);
|
|
|
|
|
await this.prisma.customerGroup.delete({ where: { id } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** شناسههای معتبر همین محیط (بقیه نادیده گرفته میشوند) */
|
|
|
|
|
async validIds(organizationId: string, ids: string[] | undefined): Promise<string[]> {
|
|
|
|
|
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
|
|
|
|
|
if (!unique.length) return [];
|
|
|
|
|
const rows = await this.prisma.customerGroup.findMany({
|
|
|
|
|
where: { organizationId, id: { in: unique } },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (rows.length !== unique.length) {
|
|
|
|
|
throw new BadRequestException('یکی از گروههای انتخابشده معتبر نیست');
|
|
|
|
|
}
|
|
|
|
|
return rows.map((r) => r.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** گروهها با نام (برای اکسل)؛ نبود → ساخته میشود */
|
|
|
|
|
async idsForNames(organizationId: string, names: string[]): Promise<string[]> {
|
|
|
|
|
const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))];
|
|
|
|
|
if (!clean.length) return [];
|
|
|
|
|
const ids: string[] = [];
|
|
|
|
|
for (const name of clean) {
|
|
|
|
|
ids.push((await this.findOrCreateByName(organizationId, name)).id);
|
|
|
|
|
}
|
|
|
|
|
return ids;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async findOrCreateByName(organizationId: string, name: string) {
|
|
|
|
|
const existing = await this.prisma.customerGroup.findFirst({
|
|
|
|
|
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
|
|
|
|
|
select: { id: true, tahesabGid: true },
|
|
|
|
|
});
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
const max = await this.prisma.customerGroup.aggregate({
|
|
|
|
|
where: { organizationId },
|
|
|
|
|
_max: { position: true },
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
return await this.prisma.customerGroup.create({
|
|
|
|
|
data: { organizationId, name, position: (max._max.position ?? -1) + 1 },
|
|
|
|
|
select: { id: true, tahesabGid: true },
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
|
|
|
|
return this.prisma.customerGroup.findFirstOrThrow({
|
|
|
|
|
where: { organizationId, name },
|
|
|
|
|
select: { id: true, tahesabGid: true },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* گروه متناظر یک گروه تهحساب: اول با GID روی همان اتصال، بعد با نام.
|
|
|
|
|
* با تغییر نام در تهحساب گروه تکراری ساخته نمیشود.
|
|
|
|
|
*/
|
|
|
|
|
async ensureTahesabGroup(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
connectionId: string,
|
|
|
|
|
gid: string | null,
|
|
|
|
|
rawName: string,
|
|
|
|
|
): Promise<string | null> {
|
|
|
|
|
const name = normalizeGroupName(rawName);
|
|
|
|
|
const cleanGid = gid?.trim() || null;
|
|
|
|
|
if (!name && !cleanGid) return null;
|
|
|
|
|
if (cleanGid) {
|
|
|
|
|
const byGid = await this.prisma.customerGroup.findFirst({
|
|
|
|
|
where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
});
|
|
|
|
|
if (byGid) {
|
|
|
|
|
if (name && byGid.name !== name) {
|
|
|
|
|
await this.prisma.customerGroup
|
|
|
|
|
.update({ where: { id: byGid.id }, data: { name } })
|
|
|
|
|
.catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی میماند
|
|
|
|
|
}
|
|
|
|
|
return byGid.id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!name) return null;
|
|
|
|
|
const group = await this.findOrCreateByName(organizationId, name);
|
|
|
|
|
if (cleanGid && !group.tahesabGid) {
|
|
|
|
|
await this.prisma.customerGroup.update({
|
|
|
|
|
where: { id: group.id },
|
|
|
|
|
data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return group.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** گروههای دستی شخص (جایگزین کامل). منبع لینکهایی که میمانند حفظ میشود. */
|
|
|
|
|
async setPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
|
|
|
|
await this.prisma.$transaction([
|
|
|
|
|
this.prisma.personGroupLink.deleteMany({
|
|
|
|
|
where: { personId, groupId: { notIn: groupIds } },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.personGroupLink.createMany({
|
|
|
|
|
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
|
|
|
|
await this.prisma.$transaction([
|
|
|
|
|
this.prisma.companyGroupLink.deleteMany({
|
|
|
|
|
where: { companyId, groupId: { notIn: groupIds } },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.companyGroupLink.createMany({
|
|
|
|
|
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** فقط اضافه میکند (ایمپورت اکسل) */
|
|
|
|
|
async addPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
|
|
|
|
if (!groupIds.length) return;
|
|
|
|
|
await this.prisma.personGroupLink.createMany({
|
|
|
|
|
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async addCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
|
|
|
|
if (!groupIds.length) return;
|
|
|
|
|
await this.prisma.companyGroupLink.createMany({
|
|
|
|
|
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* گروهِ آمده از تهحساب: گروه تهحسابی قبلی برداشته و این یکی گذاشته میشود.
|
|
|
|
|
* گروههای دستی دست نمیخورند. اگر همین گروه از قبل دستی بوده، دستی میماند.
|
|
|
|
|
* @returns آیا چیزی عوض شد
|
|
|
|
|
*/
|
|
|
|
|
async setPersonTahesabGroup(personId: string, groupId: string | null): Promise<boolean> {
|
|
|
|
|
const current = await this.prisma.personGroupLink.findMany({
|
|
|
|
|
where: { personId },
|
|
|
|
|
select: { groupId: true, source: true },
|
|
|
|
|
});
|
|
|
|
|
const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId);
|
|
|
|
|
const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true;
|
|
|
|
|
if (!staleTahesab.length && alreadyLinked) return false;
|
|
|
|
|
await this.prisma.$transaction([
|
|
|
|
|
...(staleTahesab.length
|
|
|
|
|
? [
|
|
|
|
|
this.prisma.personGroupLink.deleteMany({
|
|
|
|
|
where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } },
|
|
|
|
|
}),
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
...(groupId && !alreadyLinked
|
|
|
|
|
? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })]
|
|
|
|
|
: []),
|
|
|
|
|
]);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** گروهی که موقع «ثبت در تهحساب» فرستاده میشود: اولین گروه (به ترتیب گروهها) */
|
|
|
|
|
async firstGroupName(personId: string): Promise<string | null> {
|
|
|
|
|
const link = await this.prisma.personGroupLink.findFirst({
|
|
|
|
|
where: { personId },
|
|
|
|
|
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
|
|
|
|
select: { group: { select: { name: true } } },
|
|
|
|
|
});
|
|
|
|
|
return link?.group.name ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async require(organizationId: string, id: string) {
|
|
|
|
|
const row = await this.prisma.customerGroup.findFirst({
|
|
|
|
|
where: { id, organizationId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (!row) throw new NotFoundException('گروه پیدا نشد');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private cleanName(raw: string): string {
|
|
|
|
|
const name = normalizeGroupName(raw ?? '');
|
|
|
|
|
if (!name) throw new BadRequestException('نام گروه لازم است');
|
|
|
|
|
return name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private cleanColor(raw?: string): CustomerGroupColor {
|
|
|
|
|
return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '')
|
|
|
|
|
? (raw as CustomerGroupColor)
|
|
|
|
|
: 'teal';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private mapError(e: unknown) {
|
|
|
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
|
|
|
|
return new ConflictException('گروهی با این نام وجود دارد');
|
|
|
|
|
}
|
|
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
}
|