import { Injectable, NotFoundException, BadRequestException, } from '@nestjs/common'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { CreateDealDto } from './dto/create-deal.dto'; import { UpdateDealDto } from './dto/update-deal.dto'; import { QueryDealsDto } from './dto/query-deals.dto'; import { CrmEventPublisher } from '../events/crm-event-publisher.service'; import { CustomFieldsService } from '../custom-fields/custom-fields.service'; import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto'; import { Prisma } from '.prisma/crm-client'; @Injectable() export class DealsService { constructor( private readonly prisma: CrmPrismaService, private readonly eventPublisher: CrmEventPublisher, private readonly customFieldsService: CustomFieldsService, ) {} async create(tenantId: string, userId: string, dto: CreateDealDto) { // Pipeline und Stage validieren const pipeline = await this.prisma.pipeline.findFirst({ where: { id: dto.pipelineId, tenantId }, }); if (!pipeline) { throw new NotFoundException('Pipeline nicht gefunden'); } const stage = await this.prisma.pipelineStage.findFirst({ where: { id: dto.stageId, pipelineId: dto.pipelineId }, }); if (!stage) { throw new NotFoundException('Pipeline-Stufe nicht gefunden'); } // Kontakt validieren (optional) if (dto.contactId) { const contact = await this.prisma.contact.findFirst({ where: { id: dto.contactId, tenantId }, }); if (!contact) { throw new NotFoundException('Kontakt nicht gefunden'); } } // Unternehmen validieren (optional) if (dto.companyId) { const company = await this.prisma.company.findFirst({ where: { id: dto.companyId, tenantId }, }); if (!company) { throw new NotFoundException('Unternehmen nicht gefunden'); } } const deal = await this.prisma.deal.create({ data: { tenantId, pipelineId: dto.pipelineId, stageId: dto.stageId, contactId: dto.contactId, companyId: dto.companyId, title: dto.title, value: dto.value, currency: dto.currency ?? 'EUR', status: dto.status ?? 'OPEN', expectedCloseDate: dto.expectedCloseDate ? new Date(dto.expectedCloseDate) : undefined, notes: dto.notes, lostReason: dto.lostReason, lostReasonText: dto.lostReasonText, createdBy: userId, owners: { create: { tenantId, userId, role: 'OWNER' }, }, }, include: { pipeline: { select: { id: true, name: true } }, stage: { select: { id: true, name: true, color: true } }, contact: { select: { id: true, firstName: true, lastName: true, companyName: true, }, }, company: { select: { id: true, name: true, }, }, owners: true, }, }); // Event publizieren (async, nicht blockierend) this.eventPublisher.dealCreated(tenantId, deal.id, userId).catch(() => {}); return deal; } async findAll(tenantId: string, query: QueryDealsDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; const where: Prisma.DealWhereInput = { tenantId }; if (query.pipelineId) { where.pipelineId = query.pipelineId; } if (query.stageId) { where.stageId = query.stageId; } if (query.contactId) { where.contactId = query.contactId; } if (query.companyId) { where.companyId = query.companyId; } if (query.status) { where.status = query.status; } if (query.search) { where.title = { contains: query.search, mode: 'insensitive' }; } const allowedSortFields = [ 'createdAt', 'updatedAt', 'title', 'value', 'expectedCloseDate', ]; const sortField = allowedSortFields.includes(query.sort ?? '') ? (query.sort as string) : 'createdAt'; const [data, total] = await Promise.all([ this.prisma.deal.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { [sortField]: query.order ?? 'desc' }, include: { pipeline: { select: { id: true, name: true } }, stage: { select: { id: true, name: true, color: true } }, contact: { select: { id: true, firstName: true, lastName: true, companyName: true, }, }, company: { select: { id: true, name: true, }, }, owners: true, }, }), this.prisma.deal.count({ where }), ]); return { data, total, page, pageSize }; } async findOne(tenantId: string, id: string) { const deal = await this.prisma.deal.findFirst({ where: { id, tenantId }, include: { pipeline: { include: { stages: { orderBy: { sortOrder: 'asc' } } } }, stage: true, contact: true, company: true, owners: true, dealVouchers: { include: { voucher: { select: { id: true, voucherType: true, voucherNumber: true, voucherDate: true, voucherStatus: true, totalGrossAmount: true, currency: true, title: true, lexwareDeepLink: true, }, }, }, orderBy: { linkedAt: 'desc' }, }, }, }); if (!deal) { throw new NotFoundException('Vorgang nicht gefunden'); } // Custom Fields anhaengen const customFields = await this.customFieldsService.getCustomFieldsForEntity( tenantId, CustomFieldEntityType.DEAL, id, ); return { ...deal, customFields }; } async update( tenantId: string, id: string, userId: string, dto: UpdateDealDto, ) { const existing = await this.findOne(tenantId, id); // Stage validieren wenn geaendert if (dto.stageId) { const pipelineId = dto.pipelineId ?? existing.pipelineId; const stage = await this.prisma.pipelineStage.findFirst({ where: { id: dto.stageId, pipelineId }, }); if (!stage) { throw new NotFoundException('Pipeline-Stufe nicht gefunden'); } } // Lost-Reason Validation if (dto.status === 'LOST') { // Wenn LOST gesetzt wird, muss lostReason vorhanden sein (im DTO oder bereits gesetzt) if (!dto.lostReason && !existing.lostReason) { throw new BadRequestException( 'Verlustgrund (lostReason) ist Pflicht wenn Deal auf LOST gesetzt wird', ); } } const { lostReason, lostReasonText, ...restDto } = dto; const updateData: Prisma.DealUpdateInput = { ...restDto, expectedCloseDate: dto.expectedCloseDate ? new Date(dto.expectedCloseDate) : undefined, updatedBy: userId, }; // Lost-Reason Felder setzen if (lostReason !== undefined) { updateData.lostReason = lostReason; } if (lostReasonText !== undefined) { updateData.lostReasonText = lostReasonText; } // Wenn Deal gewonnen → lostReason und lostReasonText loeschen if (dto.status === 'WON') { updateData.lostReason = null; updateData.lostReasonText = null; } // Wenn Deal gewonnen/verloren, closedAt setzen if (dto.status === 'WON' || dto.status === 'LOST') { updateData.closedAt = new Date(); } const updated = await this.prisma.deal.update({ where: { id }, data: updateData, include: { pipeline: { select: { id: true, name: true } }, stage: { select: { id: true, name: true, color: true } }, contact: { select: { id: true, firstName: true, lastName: true, companyName: true, }, }, company: { select: { id: true, name: true, }, }, owners: true, }, }); // Events publizieren (async, nicht blockierend) if (dto.stageId && dto.stageId !== existing.stageId) { this.eventPublisher.dealStageChanged(tenantId, id, userId, { previousStageId: existing.stageId, newStageId: dto.stageId, }).catch(() => {}); } if (dto.status === 'WON') { this.eventPublisher.dealWon(tenantId, id, userId, { value: updated.value?.toNumber() ?? null, currency: updated.currency ?? 'EUR', }).catch(() => {}); } if (dto.status === 'LOST') { this.eventPublisher.dealLost(tenantId, id, userId, { lostReason: updated.lostReason ?? null, }).catch(() => {}); } return updated; } async remove(tenantId: string, id: string) { await this.findOne(tenantId, id); // Custom Field Values entfernen (entityId hat keinen FK, daher manuell) await this.prisma.customFieldValue.deleteMany({ where: { tenantId, entityId: id }, }); return this.prisma.deal.delete({ where: { id } }); } }