// ============================================================ // Lexware Office - Voucher Operations Service // ============================================================ import { Injectable, Logger, NotFoundException, ConflictException, } from '@nestjs/common'; import { Prisma } from '.prisma/crm-client'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { LexwareClientService } from './lexware-client.service'; import { QueryLexwareVouchersDto } from './dto/query-lexware-vouchers.dto'; import { LexwareVoucherListResponse, LexwareVoucherDetail, } from './interfaces/lexware-api.interfaces'; import { voucherTypeFromLexware, voucherTypeToLexwareEndpoint, voucherDetailToCacheData, } from './utils/lexware-mapper'; @Injectable() export class LexwareVouchersService { private readonly logger = new Logger(LexwareVouchersService.name); constructor( private readonly prisma: CrmPrismaService, private readonly lexwareClient: LexwareClientService, ) {} // -------------------------------------------------------- // Belege von Lexware laden und cachen // -------------------------------------------------------- async fetchAndCacheVouchers( tenantId: string, lexwareContactId: string, companyId?: string | null, contactId?: string | null, ): Promise { this.logger.log( `Lade Belege fuer Lexware-Kontakt ${lexwareContactId}...`, ); // Alle relevanten Belegtypen laden const voucherTypes = [ 'invoice', 'quotation', 'orderconfirmation', 'creditnote', ]; let totalUpserted = 0; for (const voucherType of voucherTypes) { let page = 0; let hasMore = true; while (hasMore) { const listResponse = await this.lexwareClient.get( '/v1/voucherlist', { contactId: lexwareContactId, voucherType, voucherStatus: 'any', page, size: 100, }, ); for (const item of listResponse.content) { try { // Detail laden fuer vollstaendige Daten const crmVoucherType = voucherTypeFromLexware(item.voucherType); const endpoint = voucherTypeToLexwareEndpoint(crmVoucherType); const detail = await this.lexwareClient.get( `/v1/${endpoint}/${item.voucherId}`, ); const cacheData = voucherDetailToCacheData( detail, crmVoucherType, lexwareContactId, ); // Upsert: Existierenden Beleg aktualisieren oder neuen anlegen await this.prisma.lexwareVoucher.upsert({ where: { tenantId_lexwareVoucherId: { tenantId, lexwareVoucherId: item.voucherId, }, }, create: { tenantId, lexwareVoucherId: item.voucherId, ...cacheData, companyId: companyId || undefined, contactId: contactId || undefined, fetchedAt: new Date(), }, update: { ...cacheData, companyId: companyId || undefined, contactId: contactId || undefined, fetchedAt: new Date(), }, }); totalUpserted++; } catch (error) { this.logger.warn( `Fehler beim Laden von Beleg ${item.voucherId}: ${error instanceof Error ? error.message : 'Unbekannt'}`, ); } } hasMore = !listResponse.last; page++; } } this.logger.log( `${totalUpserted} Belege fuer Lexware-Kontakt ${lexwareContactId} gecacht.`, ); return totalUpserted; } // -------------------------------------------------------- // Belege fuer Company abrufen (aus Cache) // -------------------------------------------------------- async getVouchersForCompany( tenantId: string, companyId: string, query: QueryLexwareVouchersDto, ) { const company = await this.prisma.company.findFirst({ where: { id: companyId, tenantId }, }); if (!company) { throw new NotFoundException('Unternehmen nicht gefunden'); } return this.queryVouchers(tenantId, { companyId }, query); } // -------------------------------------------------------- // Belege fuer Contact abrufen (aus Cache) // -------------------------------------------------------- async getVouchersForContact( tenantId: string, contactId: string, query: QueryLexwareVouchersDto, ) { const contact = await this.prisma.contact.findFirst({ where: { id: contactId, tenantId }, }); if (!contact) { throw new NotFoundException('Kontakt nicht gefunden'); } return this.queryVouchers(tenantId, { contactId }, query); } // -------------------------------------------------------- // Belege fuer Deal abrufen (ueber DealVoucher Join-Table) // -------------------------------------------------------- async getVouchersForDeal( tenantId: string, dealId: string, query: QueryLexwareVouchersDto, ) { const deal = await this.prisma.deal.findFirst({ where: { id: dealId, tenantId }, }); if (!deal) { throw new NotFoundException('Vorgang nicht gefunden'); } const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; const where: Prisma.DealVoucherWhereInput = { tenantId, dealId, }; // Filter auf Voucher-Ebene const voucherWhere: Prisma.LexwareVoucherWhereInput = {}; if (query.voucherType) { voucherWhere.voucherType = query.voucherType; } if (query.voucherStatus) { voucherWhere.voucherStatus = query.voucherStatus; } if (Object.keys(voucherWhere).length > 0) { where.voucher = voucherWhere; } const [dealVouchers, total] = await Promise.all([ this.prisma.dealVoucher.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { linkedAt: 'desc' }, include: { voucher: true, }, }), this.prisma.dealVoucher.count({ where }), ]); return { data: dealVouchers.map((dv) => ({ ...dv.voucher, linkedBy: dv.linkedBy, linkedAt: dv.linkedAt, dealVoucherId: dv.id, })), total, page, pageSize, }; } // -------------------------------------------------------- // Beleg mit Deal verknuepfen // -------------------------------------------------------- async linkVoucherToDeal( tenantId: string, dealId: string, voucherId: string, userId: string, ) { // Deal pruefen const deal = await this.prisma.deal.findFirst({ where: { id: dealId, tenantId }, }); if (!deal) { throw new NotFoundException('Vorgang nicht gefunden'); } // Voucher pruefen const voucher = await this.prisma.lexwareVoucher.findFirst({ where: { id: voucherId, tenantId }, }); if (!voucher) { throw new NotFoundException('Beleg nicht gefunden'); } // Pruefe ob bereits verknuepft const existing = await this.prisma.dealVoucher.findUnique({ where: { dealId_voucherId: { dealId, voucherId } }, }); if (existing) { throw new ConflictException('Beleg ist bereits mit diesem Vorgang verknuepft'); } return this.prisma.dealVoucher.create({ data: { tenantId, dealId, voucherId, linkedBy: userId, }, include: { voucher: true, }, }); } // -------------------------------------------------------- // Beleg von Deal trennen // -------------------------------------------------------- async unlinkVoucherFromDeal( tenantId: string, dealId: string, voucherId: string, ) { const dealVoucher = await this.prisma.dealVoucher.findFirst({ where: { tenantId, dealId, voucherId }, }); if (!dealVoucher) { throw new NotFoundException('Verknuepfung nicht gefunden'); } return this.prisma.dealVoucher.delete({ where: { id: dealVoucher.id }, }); } // -------------------------------------------------------- // Cache manuell aktualisieren // -------------------------------------------------------- async refreshCompanyVouchers(tenantId: string, companyId: string) { const company = await this.prisma.company.findFirst({ where: { id: companyId, tenantId }, }); if (!company) { throw new NotFoundException('Unternehmen nicht gefunden'); } if (!company.lexwareContactId) { throw new NotFoundException( 'Unternehmen ist nicht mit Lexware verknuepft', ); } return this.fetchAndCacheVouchers( tenantId, company.lexwareContactId, companyId, null, ); } async refreshContactVouchers(tenantId: string, contactId: string) { const contact = await this.prisma.contact.findFirst({ where: { id: contactId, tenantId }, }); if (!contact) { throw new NotFoundException('Kontakt nicht gefunden'); } if (!contact.lexwareContactId) { throw new NotFoundException('Kontakt ist nicht mit Lexware verknuepft'); } return this.fetchAndCacheVouchers( tenantId, contact.lexwareContactId, null, contactId, ); } // -------------------------------------------------------- // Interne Hilfsfunktion: Paginierte Voucher-Abfrage // -------------------------------------------------------- private async queryVouchers( tenantId: string, filter: { companyId?: string; contactId?: string }, query: QueryLexwareVouchersDto, ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; const where: Prisma.LexwareVoucherWhereInput = { tenantId, ...filter, }; if (query.voucherType) { where.voucherType = query.voucherType; } if (query.voucherStatus) { where.voucherStatus = query.voucherStatus; } const [data, total] = await Promise.all([ this.prisma.lexwareVoucher.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { voucherDate: 'desc' }, }), this.prisma.lexwareVoucher.count({ where }), ]); return { data, total, page, pageSize }; } }