mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +02:00
Backend (CRM expert): Custom field definitions CRUD, bulk value upsert, 7 endpoints, Prisma schema with CustomFieldDef + CustomFieldValue tables. Frontend: Types, API, hooks, admin settings page with field management, CustomFieldsDisplay for detail pages, CustomFieldsForm for edit modals. Also fix Vite allowedHosts for insight.xinion.lan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
336 lines
9.2 KiB
TypeScript
336 lines
9.2 KiB
TypeScript
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 } });
|
|
}
|
|
}
|