import dayjs from "dayjs"; import quarterOfYear from "dayjs/plugin/quarterOfYear"; import Handlebars from "handlebars"; import axios from "axios"; import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren // DEINE IMPORTS import * as schema from "../../db/schema"; // Importiere dein Drizzle Schema import { saveFile } from "../utils/files"; import {FastifyInstance} from "fastify"; import {useNextNumberRangeNumber} from "../utils/functions"; import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen! dayjs.extend(quarterOfYear); export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => { try { console.log(executedBy) const executionDayjs = dayjs(executionDate); console.log(`Starte manuelle Generierung für Tenant ${tenantId} am ${executionDate}`); // 1. Tenant laden (Drizzle) // Wir nehmen an, dass 'tenants' im Schema definiert ist const [tenant] = await server.db .select() .from(schema.tenants) .where(eq(schema.tenants.id, tenantId)) .limit(1); if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`); // 2. Templates laden const templates = await server.db .select() .from(schema.createddocuments) .where( and( eq(schema.createddocuments.tenant, tenantId), eq(schema.createddocuments.type, "serialInvoices"), inArray(schema.createddocuments.id, templateIds) ) ); if (templates.length === 0) { console.warn("Keine passenden Vorlagen gefunden."); return []; } // 3. Folder & FileType IDs holen (Hilfsfunktionen unten) const folderId = await getFolderId(server,tenantId); const fileTypeId = await getFileTypeId(server,tenantId); const results = []; const [executionRecord] = await server.db .insert(schema.serialExecutions) .values({ tenant: tenantId, executionDate: executionDayjs.toDate(), status: "draft", createdBy: executedBy, summary: `${templateIds.length} Vorlagen verarbeitet` }) .returning(); console.log(executionRecord); // 4. Loop durch die Templates for (const template of templates) { try { const resultId = await processSingleTemplate( server, template, tenant, executionDayjs, folderId, fileTypeId, executedBy, executionRecord.id ); results.push({ id: template.id, status: "success", newDocumentId: resultId }); } catch (e: any) { console.error(`Fehler bei Template ${template.id}:`, e); results.push({ id: template.id, status: "error", error: e.message }); } } return results; } catch (error) { console.log(error); } } export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => { try { console.log(`Beende Ausführung ${executionId}...`); // 1. Execution und Tenant laden const [executionRecord] = await server.db .select() .from(schema.serialExecutions)// @ts-ignore .where(eq(schema.serialExecutions.id, executionId)) .limit(1); if (!executionRecord) throw new Error("Execution nicht gefunden"); console.log(executionRecord); const tenantId = executionRecord.tenant; console.log(tenantId) // Tenant laden (für Settings etc.) const [tenant] = await server.db .select() .from(schema.tenants) .where(eq(schema.tenants.id, tenantId)) .limit(1); // 2. Status auf "processing" setzen (optional, damit UI feedback hat) /*await server.db .update(schema.serialExecutions) .set({ status: "processing" })// @ts-ignore .where(eq(schema.serialExecutions.id, executionId));*/ // 3. Alle erstellten Dokumente dieser Execution laden const documents = await server.db .select() .from(schema.createddocuments) .where(eq(schema.createddocuments.serialexecution, executionId)); console.log(`${documents.length} Dokumente werden finalisiert...`); // 4. IDs für File-System laden (nur einmalig nötig) const folderId = await getFolderId(server, tenantId); const fileTypeId = await getFileTypeId(server, tenantId); // Globale Daten laden, die für alle gleich sind (Optimierung) const [units, products, services] = await Promise.all([ server.db.select().from(schema.units), server.db.select().from(schema.products).where(eq(schema.products.tenant, tenantId)), server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)), ]); let successCount = 0; let errorCount = 0; // 5. Loop durch Dokumente for (const doc of documents) { try { const [letterhead] = await Promise.all([ /*fetchById(server, schema.contacts, doc.contact), fetchById(server, schema.customers, doc.customer), fetchById(server, schema.authProfiles, doc.contactPerson), // oder createdBy, je nach Logik fetchById(server, schema.projects, doc.project), fetchById(server, schema.contracts, doc.contract),*/ doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null ]); const pdfData = await getCloseData( server, doc, tenant, units, products, services, ); console.log(pdfData); // D. PDF Generieren const pdfBase64 = await createInvoicePDF(server,"base64",pdfData, letterhead?.path); console.log(pdfBase64); // E. Datei speichern // @ts-ignore const fileBuffer = Buffer.from(pdfBase64.base64, "base64"); const filename = `${pdfData.documentNumber}.pdf`; await saveFile( server, tenantId, null, // User ID (hier ggf. executionRecord.createdBy nutzen wenn verfügbar) fileBuffer, folderId, fileTypeId, { createddocument: doc.id, filename: filename, filesize: fileBuffer.length // Falls saveFile das braucht } ); // F. Dokument in DB final updaten await server.db .update(schema.createddocuments) .set({ state: "Gebucht", documentNumber: pdfData.documentNumber, title: pdfData.title, pdf_path: filename // Optional, falls du den Pfad direkt am Doc speicherst }) .where(eq(schema.createddocuments.id, doc.id)); successCount++; } catch (innerErr) { console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr); errorCount++; // Optional: Status des einzelnen Dokuments auf Error setzen } } // 6. Execution abschließen const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge await server.db .update(schema.serialExecutions) .set({ status: finalStatus, summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.` })// @ts-ignore .where(eq(schema.serialExecutions.id, executionId)); return { success: true, processed: successCount, errors: errorCount }; } catch (error) { console.error("Critical Error in finishManualGeneration:", error); // Execution auf Error setzen // @ts-ignore await server.db .update(schema.serialExecutions) .set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." }) //@ts-ignore .where(eq(schema.serialExecutions.id, executionId)); throw error; } } /** * Verarbeitet eine einzelne Vorlage */ async function processSingleTemplate(server: FastifyInstance, template: any, tenant: any,executionDate: dayjs.Dayjs,folderId: string,fileTypeId: string,executedBy: string,executionId: string) { // A. Zugehörige Daten parallel laden const [contact, customer, profile, project, contract, units, products, services, letterhead] = await Promise.all([ fetchById(server, schema.contacts, template.contact), fetchById(server, schema.customers, template.customer), fetchById(server, schema.authProfiles, template.contactPerson), fetchById(server, schema.projects, template.project), fetchById(server, schema.contracts, template.contract), server.db.select().from(schema.units), server.db.select().from(schema.products).where(eq(schema.products.tenant, tenant.id)), server.db.select().from(schema.services).where(eq(schema.services.tenant, tenant.id)), template.letterhead ? fetchById(server, schema.letterheads, template.letterhead) : null ]); // B. Datumsberechnung (Logik aus dem Original) const { firstDate, lastDate } = calculateDateRange(template.serialConfig, executionDate); // C. Rechnungsnummer & Save Data const savePayload = await getSaveData( template, tenant, firstDate, lastDate, executionDate.toISOString(), executedBy ); const payloadWithRelation = { ...savePayload, serialexecution: executionId }; // D. Dokument in DB anlegen (Drizzle Insert) const [createdDoc] = await server.db .insert(schema.createddocuments) .values(payloadWithRelation) .returning(); // Wichtig für Postgres: returning() gibt das erstellte Objekt zurück return createdDoc.id; } // --- Drizzle Helper --- async function fetchById(server: FastifyInstance, table: any, id: number | null) { if (!id) return null; const [result] = await server.db.select().from(table).where(eq(table.id, id)).limit(1); return result || null; } async function getFolderId(server:FastifyInstance, tenantId: number) { const [folder] = await server.db .select({ id: schema.folders.id }) .from(schema.folders) .where( and( eq(schema.folders.tenant, tenantId), eq(schema.folders.function, "invoices"), // oder 'invoices', check deine DB eq(schema.folders.year, dayjs().format("YYYY")) ) ) .limit(1); return folder?.id; } async function getFileTypeId(server: FastifyInstance,tenantId: number) { const [tag] = await server.db .select({ id: schema.filetags.id }) .from(schema.filetags) .where( and( eq(schema.filetags.tenant, tenantId), eq(schema.filetags.createdDocumentType, "invoices") ) ) .limit(1); return tag?.id; } // --- Logik Helper (Unverändert zur Business Logik) --- function calculateDateRange(config: any, executionDate: dayjs.Dayjs) { // Basis nehmen let baseDate = executionDate; let firstDate = baseDate; let lastDate = baseDate; if (config.intervall === "monatlich" && config.dateDirection === "Rückwirkend") { // 1. Monat abziehen // 2. Start/Ende des Monats berechnen // 3. WICHTIG: Zeit hart auf 12:00:00 setzen, damit Zeitzonen das Datum nicht kippen firstDate = baseDate.subtract(1, "month").startOf("month").hour(12).minute(0).second(0).millisecond(0); lastDate = baseDate.subtract(1, "month").endOf("month").hour(12).minute(0).second(0).millisecond(0); } else if (config.intervall === "vierteljährlich" && config.dateDirection === "Rückwirkend") { firstDate = baseDate.subtract(1, "quarter").startOf("quarter").hour(12).minute(0).second(0).millisecond(0); lastDate = baseDate.subtract(1, "quarter").endOf("quarter").hour(12).minute(0).second(0).millisecond(0); } // Das Ergebnis ist nun z.B.: // firstDate: '2025-12-01T12:00:00.000Z' (Eindeutig der 1. Dezember) // lastDate: '2025-12-31T12:00:00.000Z' (Eindeutig der 31. Dezember) return { firstDate: firstDate.toISOString(), lastDate: lastDate.toISOString() }; } async function getSaveData(item: any, tenant: any, firstDate: string, lastDate: string, executionDate: string, executedBy: string) { const cleanRows = item.rows.map((row: any) => ({ ...row, descriptionText: row.description || null, })); //const documentNumber = await this.useNextInvoicesNumber(item.tenant); return { tenant: item.tenant, type: "invoices", state: "Entwurf", customer: item.customer, contact: item.contact, contract: item.contract, address: item.address, project: item.project, documentDate: executionDate, deliveryDate: firstDate, deliveryDateEnd: lastDate, paymentDays: item.paymentDays, payment_type: item.payment_type, deliveryDateType: item.deliveryDateType, info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt createdBy: item.createdBy, created_by: item.created_by, title: `Rechnung-Nr. XXX`, description: item.description, startText: item.startText, endText: item.endText, rows: cleanRows, // JSON Array contactPerson: item.contactPerson, linkedDocument: item.linkedDocument, letterhead: item.letterhead, taxType: item.taxType, }; } async function getCloseData(server:FastifyInstance,item: any, tenant: any, units, products,services) { const documentNumber = await useNextNumberRangeNumber(server,tenant.id,"invoices"); console.log(item); const [contact, customer, project, contract] = await Promise.all([ fetchById(server, schema.contacts, item.contact), fetchById(server, schema.customers, item.customer), fetchById(server, schema.projects, item.project), fetchById(server, schema.contracts, item.contract), item.letterhead ? fetchById(server, schema.letterheads, item.letterhead) : null ]); const profile = (await server.db.select().from(schema.authProfiles).where(and(eq(schema.authProfiles.user_id, item.created_by),eq(schema.authProfiles.tenant_id,tenant.id))).limit(1))[0]; console.log(profile) const pdfData = getDocumentDataBackend( { ...item, state: "Gebucht", documentNumber: documentNumber.usedNumber, title: `Rechnung-Nr. ${documentNumber.usedNumber}`, }, // Das Dokument (mit neuer Nummer) tenant, // Tenant Object customer, // Customer Object contact, // Contact Object (kann null sein) profile, // User Profile (Contact Person) project, // Project Object contract, // Contract Object units, // Units Array products, // Products Array services // Services Array ); return pdfData; } // Formatiert Zahlen zu deutscher Währung function renderCurrency(value: any, currency = "€") { if (value === undefined || value === null) return "0,00 " + currency; return Number(value).toFixed(2).replace(".", ",") + " " + currency; } // Berechnet den Zeilenpreis (Menge * Preis * Rabatt) function getRowAmount(row: any) { const price = Number(row.price || 0); const quantity = Number(row.quantity || 0); const discount = Number(row.discountPercent || 0); return quantity * price * (1 - discount / 100); } // Berechnet alle Summen (Netto, Brutto, Steuern, Titel-Summen) // Dies ersetzt 'documentTotal.value' aus dem Frontend function calculateDocumentTotals(rows: any[], taxType: string) { console.log(rows); let totalNet = 0; let totalNet19 = 0; let totalNet7 = 0; let totalNet0 = 0; let titleSums: Record = {}; // Aktueller Titel für Gruppierung let currentTitle = ""; rows.forEach(row => { if (row.mode === 'title') { currentTitle = row.text || row.description || "Titel"; if (!titleSums[currentTitle]) titleSums[currentTitle] = 0; return; } if (['normal', 'service', 'free'].includes(row.mode)) { const amount = getRowAmount(row); totalNet += amount; // Summen pro Titel addieren //if (!titleSums[currentTitle]) titleSums[currentTitle] = 0; if(currentTitle.length > 0) titleSums[currentTitle] += amount; // Steuer-Logik const tax = taxType === "19 UStG" || taxType === "13b UStG" ? 0 : Number(row.taxPercent); if (tax === 19) totalNet19 += amount; else if (tax === 7) totalNet7 += amount; else totalNet0 += amount; } }); const isTaxFree = ["13b UStG", "19 UStG"].includes(taxType); const tax19 = isTaxFree ? 0 : totalNet19 * 0.19; const tax7 = isTaxFree ? 0 : totalNet7 * 0.07; const totalGross = totalNet + tax19 + tax7; return { totalNet, totalNet19, totalNet7, totalNet0, total19: tax19, total7: tax7, total0: 0, totalGross, titleSums // Gibt ein Objekt zurück: { "Titel A": 150.00, "Titel B": 200.00 } }; } export function getDocumentDataBackend( itemInfo: any, // Das Dokument objekt (createddocument) tenant: any, // Tenant Infos (auth.activeTenantData) customerData: any, // Geladener Kunde contactData: any, // Geladener Kontakt (optional) contactPerson: any, // Geladenes User-Profil (ersetzt den API Call) projectData: any, // Projekt contractData: any, // Vertrag units: any[], // Array aller Einheiten products: any[], // Array aller Produkte services: any[] // Array aller Services ) { const businessInfo = tenant.businessInfo || {}; // Fallback falls leer // --- 1. Agriculture Logic --- // Prüfen ob 'extraModules' existiert, sonst leeres Array annehmen const modules = tenant.extraModules || []; if (modules.includes("agriculture")) { itemInfo.rows.forEach((row: any) => { if (row.agriculture && row.agriculture.dieselUsage) { row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}`; } }); } // --- 2. Tax Override Logic --- let rows = JSON.parse(JSON.stringify(itemInfo.rows)); // Deep Clone um Original nicht zu mutieren if (itemInfo.taxType === "13b UStG" || itemInfo.taxType === "19 UStG") { rows = rows.map((row: any) => ({ ...row, taxPercent: 0 })); } // --- 4. Berechnungen (Ersetzt Vue computed props) --- const totals = calculateDocumentTotals(rows, itemInfo.taxType); console.log(totals); // --- 3. Rows Mapping & Processing --- rows = rows.map((row: any) => { const unit = units.find(i => i.id === row.unit) || { short: "" }; // Description Text Logic if (!['pagebreak', 'title'].includes(row.mode)) { if (row.agriculture && row.agriculture.description) { row.descriptionText = row.agriculture.description; } else if (row.description) { row.descriptionText = row.description; } else { delete row.descriptionText; } } // Product/Service Name Resolution if (!['pagebreak', 'title', 'text'].includes(row.mode)) { if (row.mode === 'normal') { const prod = products.find(i => i.id === row.product); if (prod) row.text = prod.name; } if (row.mode === 'service') { const serv = services.find(i => i.id === row.service); if (serv) row.text = serv.name; } const rowAmount = getRowAmount(row); return { ...row, rowAmount: renderCurrency(rowAmount), quantity: String(row.quantity).replace(".", ","), unit: unit.short, pos: String(row.pos), price: renderCurrency(row.price), discountText: row.discountPercent > 0 ? `(Rabatt: ${row.discountPercent} %)` : "" }; } else { return row; } }); // --- 5. Handlebars Context --- const generateContext = () => { return { // lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen anrede: (contactData && contactData.salutation) || (customerData && customerData.salutation), titel: (contactData && contactData.title) || (customerData && customerData.title), vorname: (contactData && contactData.firstName) || (customerData && customerData.firstname), // Achte auf casing (firstName vs firstname) in deiner DB nachname: (contactData && contactData.lastName) || (customerData && customerData.lastname), kundenname: customerData && customerData.name, zahlungsziel_in_tagen: itemInfo.paymentDays, zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift", diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null }; }; const templateStartText = Handlebars.compile(itemInfo.startText || ""); const templateEndText = Handlebars.compile(itemInfo.endText || ""); // --- 6. Title Sums Formatting --- let returnTitleSums: Record = {}; Object.keys(totals.titleSums).forEach(key => { returnTitleSums[key] = renderCurrency(totals.titleSums[key]); }); // Transfer logic (Falls nötig, hier vereinfacht) let returnTitleSumsTransfer = { ...returnTitleSums }; // --- 7. Construct Final Object --- // Adresse aufbereiten const recipientArray = [ customerData.name, ...(customerData.nameAddition ? [customerData.nameAddition] : []), ...(contactData ? [`${contactData.firstName} ${contactData.lastName}`] : []), itemInfo.address?.street || customerData.street || "", ...(itemInfo.address?.special ? [itemInfo.address.special] : []), `${itemInfo.address?.zip || customerData.zip} ${itemInfo.address?.city || customerData.city}`, ].filter(Boolean); // Leere Einträge entfernen console.log(contactPerson) // Info Block aufbereiten const infoBlock = [ { label: itemInfo.documentNumberTitle || "Rechnungsnummer", content: itemInfo.documentNumber || "ENTWURF", }, { label: "Kundennummer", content: customerData.customerNumber, }, { label: "Belegdatum", content: itemInfo.documentDate ? dayjs(itemInfo.documentDate).format("DD.MM.YYYY") : dayjs().format("DD.MM.YYYY"), }, // Lieferdatum Logik ...(itemInfo.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{ label: itemInfo.deliveryDateType || "Lieferdatum", content: !['Lieferzeitraum', 'Leistungszeitraum'].includes(itemInfo.deliveryDateType) ? (itemInfo.deliveryDate ? dayjs(itemInfo.deliveryDate).format("DD.MM.YYYY") : "") : `${itemInfo.deliveryDate ? dayjs(itemInfo.deliveryDate).format("DD.MM.YYYY") : ""} - ${itemInfo.deliveryDateEnd ? dayjs(itemInfo.deliveryDateEnd).format("DD.MM.YYYY") : ""}`, }] : []), { label: "Ansprechpartner", content: contactPerson ? (contactPerson.name || contactPerson.full_name || contactPerson.email) : "-", }, // Kontakt Infos ...((itemInfo.contactTel || contactPerson?.fixed_tel || contactPerson?.mobile_tel) ? [{ label: "Telefon", content: itemInfo.contactTel || contactPerson?.fixed_tel || contactPerson?.mobile_tel, }] : []), ...(contactPerson?.email ? [{ label: "E-Mail", content: contactPerson.email, }] : []), // Objekt / Projekt / Vertrag ...(itemInfo.plant ? [{ label: "Objekt", content: "Objekt Name" }] : []), // Hier müsstest du Plant Data übergeben wenn nötig ...(projectData ? [{ label: "Projekt", content: projectData.name }] : []), ...(contractData ? [{ label: "Vertrag", content: contractData.contractNumber }] : []) ]; // Total Array für PDF Footer const totalArray = [ { label: "Nettobetrag", content: renderCurrency(totals.totalNet), }, ...(totals.totalNet19 > 0 && !["13b UStG"].includes(itemInfo.taxType) ? [{ label: `zzgl. 19% USt auf ${renderCurrency(totals.totalNet19)}`, content: renderCurrency(totals.total19), }] : []), ...(totals.totalNet7 > 0 && !["13b UStG"].includes(itemInfo.taxType) ? [{ label: `zzgl. 7% USt auf ${renderCurrency(totals.totalNet7)}`, content: renderCurrency(totals.total7), }] : []), { label: "Gesamtbetrag", content: renderCurrency(totals.totalGross), }, ]; return { ...itemInfo, type: itemInfo.type, taxType: itemInfo.taxType, adressLine: `${businessInfo.name || ''}, ${businessInfo.street || ''}, ${businessInfo.zip || ''} ${businessInfo.city || ''}`, recipient: recipientArray, info: infoBlock, title: itemInfo.title, description: itemInfo.description, // Handlebars Compilation ausführen endText: templateEndText(generateContext()), startText: templateStartText(generateContext()), rows: rows, totalArray: totalArray, total: { totalNet: renderCurrency(totals.totalNet), total19: renderCurrency(totals.total19), total0: renderCurrency(totals.total0), // 0% USt Zeilen totalGross: renderCurrency(totals.totalGross), // Diese Werte existieren im einfachen Backend-Kontext oft nicht (Zahlungen checken), daher 0 oder Logik bauen totalGrossAlreadyPaid: renderCurrency(0), totalSumToPay: renderCurrency(totals.totalGross), titleSums: returnTitleSums, titleSumsTransfer: returnTitleSumsTransfer }, agriculture: itemInfo.agriculture, // Falls du AdvanceInvoices brauchst, musst du die Objekte hier übergeben oder leer lassen usedAdvanceInvoices: [] }; }