INSIGHT-MVP/packages/crm-service/src/deals/deals.service.ts
Thomas Reitz 9d496d2e53 feat(crm): integrate Lexware Office for vouchers and contact sync
Adds complete Lexware Office integration to CRM service:
- Rate-limited HTTP client (Token Bucket, 2 req/s)
- Bidirectional contact sync (manual import + ERP-push)
- Voucher caching (quotes, orders, invoices, credit notes)
- Deal-voucher linking (m:n join table with audit)
- Cron jobs: voucher refresh (4h), ERP push (30min)
- Distributed locks via Redis for job deduplication
- Health check extended with Lexware status
- Prisma schema: LexwareVoucher, DealVoucher, VoucherType enum
- Companies/Contacts/Deals services extended with Lexware data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:28:41 +01:00

244 lines
6.4 KiB
TypeScript

import { Injectable, NotFoundException } 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 { Prisma } from '.prisma/crm-client';
@Injectable()
export class DealsService {
constructor(private readonly prisma: CrmPrismaService) {}
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');
}
}
return 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,
createdBy: userId,
},
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,
},
},
},
});
}
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,
},
},
},
}),
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,
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');
}
return deal;
}
async update(
tenantId: string,
id: string,
userId: string,
dto: UpdateDealDto,
) {
await this.findOne(tenantId, id);
// Stage validieren wenn geaendert
if (dto.stageId) {
const deal = await this.prisma.deal.findUnique({ where: { id } });
const pipelineId = dto.pipelineId ?? deal?.pipelineId;
const stage = await this.prisma.pipelineStage.findFirst({
where: { id: dto.stageId, pipelineId },
});
if (!stage) {
throw new NotFoundException('Pipeline-Stufe nicht gefunden');
}
}
const updateData: Prisma.DealUpdateInput = {
...dto,
expectedCloseDate: dto.expectedCloseDate
? new Date(dto.expectedCloseDate)
: undefined,
updatedBy: userId,
};
// Wenn Deal gewonnen/verloren, closedAt setzen
if (dto.status === 'WON' || dto.status === 'LOST') {
updateData.closedAt = new Date();
}
return 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,
},
},
},
});
}
async remove(tenantId: string, id: string) {
await this.findOne(tenantId, id);
return this.prisma.deal.delete({ where: { id } });
}
}