mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:56:39 +02:00
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>
244 lines
6.4 KiB
TypeScript
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 } });
|
|
}
|
|
}
|