387 lines
13 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|