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> { const search = buildSearchWhere(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 { 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 { 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 { 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 { 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 { await this.ensureExists(actor.organizationId, id, actor); await this.prisma.company.delete({ where: { id } }); } async ensureInOrg( organizationId: string, companyId: string, actor?: Pick, ): Promise { await this.ensureExists(organizationId, companyId, actor); } private async ensureExists( organizationId: string, id: string, actor?: Pick, ) { 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(), }; } }