Files
FEDEO/backend/src/modules/serialexecution.service.ts
2026-01-06 12:07:43 +01:00

726 lines
27 KiB
TypeScript

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<string, number> = {};
// 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<string, string> = {};
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: []
};
}