INSIGHT-MVP/packages/crm-service/src/lexware/lexware-vouchers.service.ts
Thomas Reitz 4924875e92 fix(crm): add mandatory voucherStatus=any to Lexware voucherlist API call
The Lexware Office API requires voucherStatus as a mandatory parameter
for the /v1/voucherlist endpoint. Without it, the API returns 400 Bad
Request. Using 'any' fetches vouchers regardless of status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:00:29 +01:00

382 lines
10 KiB
TypeScript

// ============================================================
// 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<number> {
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<LexwareVoucherListResponse>(
'/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<LexwareVoucherDetail>(
`/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 };
}
}