Files
CRM_Montzar/apps/api/src/contacts/companies.service.ts
T

387 lines
13 KiB
TypeScript

import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CompanyDto,
CreateCompanyRequest,
CustomerGroupDto,
PaginatedResponse,
UpdateCompanyRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
asCustomFields,
toJsonCustomFields,
} from '../common/custom-fields';
import { customersCompanyWhere } from '../common/data-access';
import {
type Actor,
canReassignOwner,
elevated,
resolveOwnerId,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci, splitCsv } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
import {
CustomerGroupsService,
groupSummarySelect,
} from '../customer-groups/customer-groups.service';
function parseDateOrNull(raw?: string | null): Date | null {
if (!raw?.trim()) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
const companyInclude = {
_count: { select: { people: true } },
owner: { select: { id: true, fullName: true } },
groupLinks: {
select: { group: { select: groupSummarySelect } },
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
},
} satisfies Prisma.CompanyInclude;
type CompanyRow = {
id: string;
name: string;
code: string | null;
importSource: string;
sourceRef: string | null;
nationalId: string | null;
economicCode: string | null;
registrationNumber: string | null;
phone: string | null;
otherPhones: string | null;
fax: string | null;
email: string | null;
otherEmails: string | null;
website: string | null;
province: string | null;
city: string | null;
postalCode: string | null;
address: string | null;
groupName: string | null;
notes: string | null;
registeredAt: Date | null;
tags: string[];
customFields: unknown;
createdAt: Date;
updatedAt: Date;
_count: { people: number };
owner: { id: string; fullName: string } | null;
groupLinks: Array<{ group: { id: string; name: string; color: string } }>;
};
@Injectable()
export class CompaniesService {
constructor(
private readonly prisma: PrismaService,
private readonly game: GamificationService,
private readonly groups: CustomerGroupsService,
) {}
async list(
actor: Actor,
q?: string,
page = 1,
limit = 20,
opts?: { groupId?: string },
): Promise<PaginatedResponse<CompanyDto>> {
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
{ name: ci(t) },
{ email: ci(t) },
{ phone: { contains: t } },
{ otherPhones: { contains: t } },
{ nationalId: { contains: t } },
{ economicCode: { contains: t } },
{ code: ci(t) },
{ sourceRef: ci(t) },
{ city: ci(t) },
{ province: ci(t) },
]);
const groupIds = splitCsv(opts?.groupId);
const and: Prisma.CompanyWhereInput[] = [
...(search ? [search] : []),
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
];
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
...(and.length ? { AND: and } : {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.company.findMany({
where,
include: companyInclude,
orderBy: [{ name: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.company.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
/** آمار گروه برای فیلتر شرکت‌ها — فقط شرکت‌های در دسترس کاربر */
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
const counts = await this.prisma.companyGroupLink.groupBy({
by: ['groupId'],
where: {
company: {
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
},
_count: { _all: true },
});
if (!counts.length) return [];
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
const groups = await this.prisma.customerGroup.findMany({
where: {
organizationId: actor.organizationId,
id: { in: counts.map((c) => c.groupId) },
},
orderBy: [{ position: 'asc' }, { name: 'asc' }],
});
return groups.map((g) => ({
id: g.id,
name: g.name,
color: g.color,
position: g.position,
fromTahesab: Boolean(g.tahesabGid),
personCount: 0,
companyCount: countMap.get(g.id) ?? 0,
}));
}
async get(actor: Actor, id: string): Promise<CompanyDto> {
const row = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
include: companyInclude,
});
if (!row) throw new NotFoundException('شرکت پیدا نشد');
return this.toDto(row);
}
async create(actor: Actor, dto: CreateCompanyRequest): Promise<CompanyDto> {
const data: Prisma.CompanyUncheckedCreateInput = {
organizationId: actor.organizationId,
name: dto.name.trim(),
code: emptyToNull(dto.code),
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
nationalId: emptyToNull(dto.nationalId),
economicCode: emptyToNull(dto.economicCode),
registrationNumber: emptyToNull(dto.registrationNumber),
phone: normalizePhone(dto.phone),
otherPhones: emptyToNull(dto.otherPhones),
fax: emptyToNull(dto.fax),
email: normalizeEmail(dto.email),
otherEmails: emptyToNull(dto.otherEmails),
website: emptyToNull(dto.website),
province: emptyToNull(dto.province),
city: emptyToNull(dto.city),
postalCode: emptyToNull(dto.postalCode),
address: emptyToNull(dto.address),
groupName: emptyToNull(dto.groupName),
notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
};
await this.assertNoDuplicate(actor.organizationId, data);
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
let row = await this.prisma.company.create({
data,
include: companyInclude,
});
if (groupIds.length) {
await this.groups.addCompanyGroups(row.id, groupIds);
row = await this.prisma.company.findUniqueOrThrow({
where: { id: row.id },
include: companyInclude,
});
}
if (row.importSource === 'manual') {
this.game.emit({
organizationId: actor.organizationId,
userId: actor.userId,
event: 'COMPANY_CREATED',
entityType: 'company',
entityId: row.id,
entityTitle: row.name,
});
}
return this.toDto(row);
}
async update(
actor: Actor,
id: string,
dto: UpdateCompanyRequest,
): Promise<CompanyDto> {
await this.ensureExists(actor.organizationId, id, actor);
const data: Prisma.CompanyUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('code' in dto) data.code = emptyToNull(dto.code);
if (dto.importSource !== undefined) {
data.importSource = normalizePersonImportSource(dto.importSource);
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
if ('economicCode' in dto) data.economicCode = emptyToNull(dto.economicCode);
if ('registrationNumber' in dto) {
data.registrationNumber = emptyToNull(dto.registrationNumber);
}
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('otherPhones' in dto) data.otherPhones = emptyToNull(dto.otherPhones);
if ('fax' in dto) data.fax = emptyToNull(dto.fax);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('otherEmails' in dto) data.otherEmails = emptyToNull(dto.otherEmails);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('province' in dto) data.province = emptyToNull(dto.province);
if ('city' in dto) data.city = emptyToNull(dto.city);
if ('postalCode' in dto) data.postalCode = emptyToNull(dto.postalCode);
if ('address' in dto) data.address = emptyToNull(dto.address);
if ('groupName' in dto) data.groupName = emptyToNull(dto.groupName);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('registeredAt' in dto) data.registeredAt = parseDateOrNull(dto.registeredAt);
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
await this.assertNoDuplicate(actor.organizationId, data, id);
if (dto.groupIds !== undefined) {
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
await this.groups.setCompanyGroups(id, groupIds);
}
const row = await this.prisma.company.update({
where: { id },
data,
include: companyInclude,
});
return this.toDto(row);
}
async remove(actor: Actor, id: string): Promise<void> {
await this.ensureExists(actor.organizationId, id, actor);
await this.prisma.company.delete({ where: { id } });
}
async ensureInOrg(
organizationId: string,
companyId: string,
actor?: Pick<Actor, 'userId' | 'role'>,
): Promise<void> {
await this.ensureExists(organizationId, companyId, actor);
}
private async ensureExists(
organizationId: string,
id: string,
actor?: Pick<Actor, 'userId' | 'role'>,
) {
const found = await this.prisma.company.findFirst({
where: {
id,
organizationId,
...(actor && !elevated(actor.role)
? { ownerId: actor.userId }
: {}),
},
select: { id: true },
});
if (!found) throw new NotFoundException('شرکت پیدا نشد');
}
private async assertNoDuplicate(
organizationId: string,
data: { email?: unknown; phone?: unknown; nationalId?: unknown },
excludeId?: string,
) {
const email = typeof data.email === 'string' ? data.email : null;
const phone = typeof data.phone === 'string' ? data.phone : null;
const nationalId =
typeof data.nationalId === 'string' ? data.nationalId : null;
const or: Prisma.CompanyWhereInput[] = [];
if (email) or.push({ email });
if (phone) or.push({ phone });
if (nationalId) or.push({ nationalId });
if (!or.length) return;
const clash = await this.prisma.company.findFirst({
where: {
organizationId,
OR: or,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!clash) return;
if (email && clash.email === email) {
throw new ConflictException('شرکتی با این ایمیل از قبل وجود دارد');
}
if (phone && clash.phone === phone) {
throw new ConflictException('شرکتی با این تلفن از قبل وجود دارد');
}
if (nationalId && clash.nationalId === nationalId) {
throw new ConflictException('شرکتی با این شناسه ملی از قبل وجود دارد');
}
throw new ConflictException('شرکت تکراری است');
}
private toDto(row: CompanyRow): CompanyDto {
return {
id: row.id,
name: row.name,
code: row.code,
importSource: row.importSource || 'manual',
sourceRef: row.sourceRef,
nationalId: row.nationalId,
economicCode: row.economicCode,
registrationNumber: row.registrationNumber,
phone: row.phone,
otherPhones: row.otherPhones,
fax: row.fax,
email: row.email,
otherEmails: row.otherEmails,
website: row.website,
province: row.province,
city: row.city,
postalCode: row.postalCode,
address: row.address,
groupName: row.groupName,
notes: row.notes,
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
groups: (row.groupLinks ?? []).map((l) => l.group),
peopleCount: row._count.people,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}