feat: Equipment-System, Buchungsbearbeitung, Kundenadresse, LexOffice-Fix

- Vintage Modell hinzugefuegt
- Equipment Multi-Select (Neue Buchung + Bearbeitung)
- Kundenadresse in Formularen
- Bearbeiten-Seite fuer Buchungen
- Abbau-Zeiten in Formularen und Uebersicht
- Vertrag PDF nur bei Privatkunden
- LexOffice Kontakt-Erstellung Fix (BUSINESS)
- Zurueck-Pfeil auf Touren-Seite
This commit is contained in:
Julia Wehden
2026-03-19 16:21:55 +01:00
parent 0b6e429329
commit a2c95c70e7
79 changed files with 7396 additions and 538 deletions

View File

@@ -146,14 +146,22 @@ export class LexOfficeService {
return this.request('GET', `/contacts/${contactId}`);
}
async createQuotation(quotation: LexOfficeQuotation): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
return this.request('POST', '/quotations', quotation);
async createQuotation(quotation: LexOfficeQuotation, finalize: boolean = false): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
const url = finalize ? '/quotations?finalize=true' : '/quotations';
console.log(`📍 Creating quotation with URL: ${this.baseUrl}${url}, finalize=${finalize}`);
return this.request('POST', url, quotation);
}
async getQuotation(quotationId: string): Promise<LexOfficeQuotation> {
return this.request('GET', `/quotations/${quotationId}`);
}
async finalizeQuotation(quotationId: string): Promise<{ id: string; resourceUri: string }> {
return this.request('PUT', `/quotations/${quotationId}/pursue`, {
precedingSalesVoucherId: null,
});
}
async createInvoice(invoice: LexOfficeInvoice): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
return this.request('POST', '/invoices', invoice);
}
@@ -169,6 +177,52 @@ export class LexOfficeService {
});
}
async getQuotationPDF(quotationId: string): Promise<Buffer> {
try {
const response = await fetch(`${this.baseUrl}/quotations/${quotationId}/document`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Accept': 'application/pdf',
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('LexOffice PDF download failed:', error);
throw error;
}
}
async getInvoicePDF(invoiceId: string): Promise<Buffer> {
try {
const response = await fetch(`${this.baseUrl}/invoices/${invoiceId}/document`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Accept': 'application/pdf',
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('LexOffice PDF download failed:', error);
throw error;
}
}
async createContactFromBooking(booking: any): Promise<string> {
const contact: Partial<LexOfficeContact> = {
roles: {
@@ -191,21 +245,26 @@ export class LexOfficeService {
};
if (booking.invoiceType === 'BUSINESS' && booking.companyName) {
const nameParts = booking.customerName.trim().split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Ansprechpartner';
contact.company = {
name: booking.companyName,
contactPersons: [{
firstName: booking.customerName.split(' ')[0],
lastName: booking.customerName.split(' ').slice(1).join(' '),
firstName: firstName,
lastName: lastName,
primary: true,
emailAddress: booking.customerEmail,
phoneNumber: booking.customerPhone,
}],
};
} else {
const [firstName, ...lastNameParts] = booking.customerName.split(' ');
const nameParts = booking.customerName.trim().split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Unbekannt';
contact.person = {
firstName: firstName,
lastName: lastNameParts.join(' '),
lastName: lastName,
};
}
@@ -214,46 +273,203 @@ export class LexOfficeService {
}
async createQuotationFromBooking(booking: any, contactId: string): Promise<string> {
const quotation: LexOfficeQuotation = {
voucherDate: new Date().toISOString().split('T')[0],
address: {
contactId: contactId,
countryCode: 'DE',
const lineItems: Array<any> = [];
console.log('📊 Booking Data:', {
id: booking.id,
bookingNumber: booking.bookingNumber,
hasPhotobox: !!booking.photobox,
photoboxModel: booking.photobox?.model,
locationId: booking.locationId,
withPrintFlat: booking.withPrintFlat,
});
// WICHTIG: priceConfig separat laden, da keine direkte Relation existiert
let priceConfig = booking.priceConfig;
if (!priceConfig && booking.photobox?.model && booking.locationId) {
console.log('🔍 Lade PriceConfig nach...');
const { prisma } = await import('./prisma');
priceConfig = await prisma.priceConfig.findUnique({
where: {
locationId_model: {
locationId: booking.locationId,
model: booking.photobox.model,
},
},
});
console.log('📊 PriceConfig geladen:', priceConfig);
}
if (!priceConfig) {
throw new Error('Keine Preiskonfiguration gefunden. Bitte konfiguriere zuerst die Preise für dieses Fotobox-Modell.');
}
// 1. Fotobox als Hauptposition (IMMER als custom lineItem)
const withPrintFlat = booking.withPrintFlat !== false; // Default: true
const boxName = booking.photobox?.model || 'Fotobox';
const flatSuffix = withPrintFlat ? ' mit Druckflatrate' : ' (nur digital)';
const photoboxItem: any = {
type: 'custom',
name: `${boxName}${flatSuffix}`,
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
quantity: 1,
unitName: 'Stück',
unitPrice: {
currency: 'EUR',
netAmount: priceConfig?.basePrice || 1,
taxRatePercentage: 19,
},
lineItems: [
{
};
lineItems.push(photoboxItem);
console.log('📦 Photobox LineItem (custom):', photoboxItem);
// 2. Kilometerpauschale (falls Distanz vorhanden)
if (booking.distance && booking.distance > 0) {
// 2a. Kilometer-Pauschale (bis X km) - IMMER als custom lineItem
if (priceConfig?.kmFlatRate && priceConfig.kmFlatRate > 0) {
const kmFlatItem: any = {
type: 'custom',
name: 'Fotobox-Vermietung',
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
name: `Kilometerpauschale (bis ${priceConfig.kmFlatRateUpTo}km)`,
description: `Entfernung: ${booking.distance.toFixed(1)}km`,
quantity: 1,
unitName: 'Pauschale',
unitPrice: {
currency: 'EUR',
netAmount: priceConfig.kmFlatRate / 1.19,
taxRatePercentage: 19,
},
};
lineItems.push(kmFlatItem);
}
// 2b. Zusätzliche Kilometer (wenn über Flatrate) - IMMER als custom lineItem
if (priceConfig && booking.distance > priceConfig.kmFlatRateUpTo) {
const extraKm = booking.distance - priceConfig.kmFlatRateUpTo;
const totalExtraKm = extraKm * priceConfig.kmMultiplier;
const kmExtraItem: any = {
type: 'custom',
name: `Zusatzkilometer`,
description: `${extraKm.toFixed(1)}km × ${priceConfig.kmMultiplier} Strecken = ${totalExtraKm.toFixed(1)}km`,
quantity: totalExtraKm,
unitName: 'km',
unitPrice: {
currency: 'EUR',
netAmount: priceConfig.pricePerKm,
taxRatePercentage: 19,
},
};
lineItems.push(kmExtraItem);
}
}
// 3. Equipment/Extras (wenn vorhanden) - IMMER als custom lineItem
if (booking.bookingEquipment && booking.bookingEquipment.length > 0) {
for (const eq of booking.bookingEquipment) {
const equipmentItem: any = {
type: 'custom',
name: eq.equipment.name,
quantity: eq.quantity || 1,
unitName: 'Stück',
unitPrice: {
currency: 'EUR',
netAmount: booking.calculatedPrice || 0,
netAmount: eq.equipment.price || 1,
taxRatePercentage: 19,
},
},
],
};
lineItems.push(equipmentItem);
}
}
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const isDST = (date: Date) => {
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
return Math.max(jan, jul) !== date.getTimezoneOffset();
};
const timezone = isDST(now) ? '+02:00' : '+01:00';
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
const expirationDateObj = new Date(now);
expirationDateObj.setDate(expirationDateObj.getDate() + 14);
const expYear = expirationDateObj.getFullYear();
const expMonth = String(expirationDateObj.getMonth() + 1).padStart(2, '0');
const expDay = String(expirationDateObj.getDate()).padStart(2, '0');
const expirationDate = `${expYear}-${expMonth}-${expDay}T00:00:00.000${timezone}`;
const quotation: LexOfficeQuotation = {
voucherDate,
expirationDate,
address: {
contactId: contactId,
name: booking.customerName,
street: booking.customerAddress || undefined,
zip: booking.customerZip || undefined,
city: booking.customerCity || undefined,
countryCode: 'DE',
},
lineItems,
totalPrice: {
currency: 'EUR',
},
taxConditions: {
taxType: 'net',
},
title: `Angebot Fotobox-Vermietung - ${booking.bookingNumber}`,
introduction: 'Vielen Dank für Ihre Anfrage! Gerne erstellen wir Ihnen folgendes Angebot:',
remark: 'Wir freuen uns auf Ihre Bestellung!',
};
const result = await this.createQuotation(quotation);
console.log('📤 Sending to LexOffice:', JSON.stringify(quotation, null, 2));
// Schritt 1: Erstelle Quotation als Draft
const result = await this.createQuotation(quotation, false);
console.log('✅ Quotation created (draft):', result.id);
// Schritt 2: Finalisiere Quotation sofort
try {
console.log('🔄 Finalizing quotation...');
await this.finalizeQuotation(result.id);
console.log('✅ Quotation finalized to OPEN status');
} catch (error: any) {
console.error('⚠️ Quotation finalization failed:', error.message);
console.log(' Quotation bleibt im DRAFT status');
}
return result.id;
}
async createConfirmationFromBooking(booking: any, contactId: string): Promise<string> {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const isDST = (date: Date) => {
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
return Math.max(jan, jul) !== date.getTimezoneOffset();
};
const timezone = isDST(now) ? '+02:00' : '+01:00';
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
const invoice: LexOfficeInvoice = {
voucherDate: new Date().toISOString().split('T')[0],
voucherDate,
address: {
contactId: contactId,
name: booking.customerName,
countryCode: 'DE',
},
lineItems: [
@@ -276,7 +492,7 @@ export class LexOfficeService {
taxConditions: {
taxType: 'net',
},
title: `Auftragsbestätigung - ${booking.bookingNumber}`,
title: `AB ${booking.bookingNumber}`,
introduction: 'Vielen Dank für Ihre Bestellung! Hiermit bestätigen wir Ihren Auftrag:',
remark: 'Wir freuen uns auf Ihre Veranstaltung!',
shippingConditions: {