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:
260
lib/lexoffice.ts
260
lib/lexoffice.ts
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user