diff --git a/db/schema/createddocuments.ts b/db/schema/createddocuments.ts index 9f16acf..9fd881a 100644 --- a/db/schema/createddocuments.ts +++ b/db/schema/createddocuments.ts @@ -17,6 +17,7 @@ import { letterheads } from "./letterheads" import { projects } from "./projects" import { plants } from "./plants" import { authUsers } from "./auth_users" +import {serialExecutions} from "./serialexecutions"; export const createddocuments = pgTable("createddocuments", { id: bigint("id", { mode: "number" }) @@ -115,6 +116,8 @@ export const createddocuments = pgTable("createddocuments", { contract: bigint("contract", { mode: "number" }).references( () => contracts.id ), + + serialexecution: uuid("serialexecution").references(() => serialExecutions.id) }) export type CreatedDocument = typeof createddocuments.$inferSelect diff --git a/db/schema/index.ts b/db/schema/index.ts index 360363d..195de05 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -68,4 +68,6 @@ export * from "./units" export * from "./user_credentials" export * from "./vehicles" export * from "./vendors" -export * from "./staff_time_events" \ No newline at end of file +export * from "./staff_time_events" +export * from "./serialtypes" +export * from "./serialexecutions" \ No newline at end of file diff --git a/db/schema/serialexecutions.ts b/db/schema/serialexecutions.ts new file mode 100644 index 0000000..c63cfb2 --- /dev/null +++ b/db/schema/serialexecutions.ts @@ -0,0 +1,21 @@ +import { + pgTable, + bigint, + timestamp, + text, + jsonb, + boolean, + uuid, +} from "drizzle-orm/pg-core" +import {tenants} from "./tenants"; + +export const serialExecutions = pgTable("serial_executions", { + id: uuid("id").primaryKey().defaultRandom(), + tenant: bigint("tenant", { mode: "number" }) + .notNull() + .references(() => tenants.id), executionDate: timestamp("execution_date").notNull(), + status: text("status").default("draft"), // 'draft', 'completed' + createdBy: text("created_by"), // oder UUID, je nach Auth-System + createdAt: timestamp("created_at").defaultNow(), + summary: text("summary"), // z.B. "25 Rechnungen erstellt" +}); \ No newline at end of file diff --git a/db/schema/serialtypes.ts b/db/schema/serialtypes.ts new file mode 100644 index 0000000..6a32d3b --- /dev/null +++ b/db/schema/serialtypes.ts @@ -0,0 +1,40 @@ +import { + pgTable, + bigint, + timestamp, + text, + jsonb, + boolean, + uuid, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const serialtypes = pgTable("serialtypes", { + id: bigint("id", { mode: "number" }) + .primaryKey() + .generatedByDefaultAsIdentity(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + name: text("name").notNull(), + + intervall: text("intervall"), + + icon: text("icon"), + + tenant: bigint("tenant", { mode: "number" }) + .notNull() + .references(() => tenants.id), + + archived: boolean("archived").notNull().default(false), + + updatedAt: timestamp("updated_at", { withTimezone: true }), + updatedBy: uuid("updated_by").references(() => authUsers.id), +}) + +export type SerialType = typeof serialtypes.$inferSelect +export type NewSerialType = typeof serialtypes.$inferInsert diff --git a/package.json b/package.json index 984f697..f188720 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "drizzle-orm": "^0.45.0", "fastify": "^5.5.0", "fastify-plugin": "^5.0.1", + "handlebars": "^4.7.8", "imapflow": "^1.1.1", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.6", diff --git a/src/index.ts b/src/index.ts index 61b2ee1..a5b846f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,9 @@ import {loadSecrets, secrets} from "./utils/secrets"; import {initMailer} from "./utils/mailer" import {initS3} from "./utils/s3"; +//Services +import servicesPlugin from "./plugins/services"; + async function main() { const app = Fastify({ logger: false }); await loadSecrets(); @@ -68,6 +71,7 @@ async function main() { await app.register(tenantPlugin); await app.register(dayjsPlugin); await app.register(dbPlugin); + await app.register(servicesPlugin); app.addHook('preHandler', (req, reply, done) => { console.log(req.method) diff --git a/src/modules/cron/bankstatementsync.service.ts b/src/modules/cron/bankstatementsync.service.ts new file mode 100644 index 0000000..9773631 --- /dev/null +++ b/src/modules/cron/bankstatementsync.service.ts @@ -0,0 +1,247 @@ +// /services/bankStatementService.ts +import axios from "axios" +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc.js" +import {secrets} from "../../utils/secrets" +import {FastifyInstance} from "fastify" + +// Drizzle imports +import { + bankaccounts, + bankstatements, +} from "../../../db/schema" + +import { + eq, + and, + isNull, +} from "drizzle-orm" + +dayjs.extend(utc) + +interface BalanceAmount { + amount: string + currency: string +} + +interface BookedTransaction { + bookingDate: string + valueDate: string + internalTransactionId: string + transactionAmount: { amount: string; currency: string } + + creditorAccount?: { iban?: string } + creditorName?: string + + debtorAccount?: { iban?: string } + debtorName?: string + + remittanceInformationUnstructured?: string + remittanceInformationStructured?: string + remittanceInformationStructuredArray?: string[] + additionalInformation?: string +} + +interface TransactionsResponse { + transactions: { + booked: BookedTransaction[] + } +} + +const normalizeDate = (val: any) => { + if (!val) return null + const d = new Date(val) + return isNaN(d.getTime()) ? null : d +} + +export function bankStatementService(server: FastifyInstance) { + + let accessToken: string | null = null + + // ----------------------------------------------- + // ✔ TOKEN LADEN + // ----------------------------------------------- + const getToken = async () => { + console.log("Fetching GoCardless token…") + + const response = await axios.post( + `${secrets.GOCARDLESS_BASE_URL}/token/new/`, + { + secret_id: secrets.GOCARDLESS_SECRET_ID, + secret_key: secrets.GOCARDLESS_SECRET_KEY, + } + ) + + accessToken = response.data.access + } + + // ----------------------------------------------- + // ✔ Salden laden + // ----------------------------------------------- + const getBalanceData = async (accountId: string): Promise => { + try { + const {data} = await axios.get( + `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + } + ) + + return data + } catch (err: any) { + server.log.error(err.response?.data ?? err) + + const expired = + err.response?.data?.summary?.includes("expired") || + err.response?.data?.detail?.includes("expired") + + if (expired) { + await server.db + .update(bankaccounts) + .set({expired: true}) + .where(eq(bankaccounts.accountId, accountId)) + } + + return false + } + } + + // ----------------------------------------------- + // ✔ Transaktionen laden + // ----------------------------------------------- + const getTransactionData = async (accountId: string) => { + try { + const {data} = await axios.get( + `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + } + ) + + return data.transactions.booked + } catch (err: any) { + server.log.error(err.response?.data ?? err) + return null + } + } + + // ----------------------------------------------- + // ✔ Haupt-Sync-Prozess + // ----------------------------------------------- + const syncAccounts = async (tenantId:number) => { + try { + console.log("Starting account sync…") + + // 🟦 DB: Aktive Accounts + const accounts = await server.db + .select() + .from(bankaccounts) + .where(and(eq(bankaccounts.expired, false),eq(bankaccounts.tenant, tenantId))) + + if (!accounts.length) return + + const allNewTransactions: any[] = [] + + for (const account of accounts) { + + // --------------------------- + // 1. BALANCE SYNC + // --------------------------- + const balData = await getBalanceData(account.accountId) + + if (balData === false) break + + if (balData) { + const closing = balData.balances.find( + (i: any) => i.balanceType === "closingBooked" + ) + + const bookedBal = Number(closing.balanceAmount.amount) + + await server.db + .update(bankaccounts) + .set({balance: bookedBal}) + .where(eq(bankaccounts.id, account.id)) + } + + // --------------------------- + // 2. TRANSACTIONS + // --------------------------- + let transactions = await getTransactionData(account.accountId) + if (!transactions) continue + + //@ts-ignore + transactions = transactions.map((item) => ({ + account: account.id, + date: normalizeDate(item.bookingDate), + credIban: item.creditorAccount?.iban ?? null, + credName: item.creditorName ?? null, + text: ` + ${item.remittanceInformationUnstructured ?? ""} + ${item.remittanceInformationStructured ?? ""} + ${item.additionalInformation ?? ""} + ${item.remittanceInformationStructuredArray?.join("") ?? ""} + `.trim(), + amount: Number(item.transactionAmount.amount), + tenant: account.tenant, + debIban: item.debtorAccount?.iban ?? null, + debName: item.debtorName ?? null, + gocardlessId: item.internalTransactionId, + currency: item.transactionAmount.currency, + valueDate: normalizeDate(item.valueDate), + })) + + // Existierende Statements laden + const existing = await server.db + .select({gocardlessId: bankstatements.gocardlessId}) + .from(bankstatements) + .where(eq(bankstatements.tenant, account.tenant)) + + const filtered = transactions.filter( + //@ts-ignore + (tx) => !existing.some((x) => x.gocardlessId === tx.gocardlessId) + ) + + allNewTransactions.push(...filtered) + } + + // --------------------------- + // 3. NEW TRANSACTIONS → DB + // --------------------------- + if (allNewTransactions.length > 0) { + await server.db.insert(bankstatements).values(allNewTransactions) + + const affectedAccounts = [ + ...new Set(allNewTransactions.map((t) => t.account)), + ] + + for (const accId of affectedAccounts) { + await server.db + .update(bankaccounts) + //@ts-ignore + .set({syncedAt: dayjs().utc().toISOString()}) + .where(eq(bankaccounts.id, accId)) + } + } + + console.log("Bank statement sync completed.") + } catch (error) { + console.error(error) + } + + } + + return { + run: async (tenant) => { + await getToken() + await syncAccounts(tenant) + console.log("Service: Bankstatement sync finished") + } + } +} diff --git a/src/modules/serialexecution.service.ts b/src/modules/serialexecution.service.ts new file mode 100644 index 0000000..8f78b5d --- /dev/null +++ b/src/modules/serialexecution.service.ts @@ -0,0 +1,703 @@ +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) { + let firstDate = executionDate; + let lastDate = executionDate; + // Logik 1:1 übernommen + if (config.intervall === "monatlich" && config.dateDirection === "Rückwirkend") { + firstDate = executionDate.subtract(1, "month").date(1); + lastDate = executionDate.subtract(1, "month").endOf("month"); + } else if (config.intervall === "vierteljährlich" && config.dateDirection === "Rückwirkend") { + firstDate = executionDate.subtract(1, "quarter").startOf("quarter"); + lastDate = executionDate.subtract(1, "quarter").endOf("quarter"); + } + 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, profile, project, contract] = await Promise.all([ + fetchById(server, schema.contacts, item.contact), + fetchById(server, schema.customers, item.customer), + fetchById(server, schema.authProfiles, item.contactPerson), // oder createdBy, je nach Logik + fetchById(server, schema.projects, item.project), + fetchById(server, schema.contracts, item.contract), + item.letterhead ? fetchById(server, schema.letterheads, item.letterhead) : null + ]); + + 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 = "Ohne Titel"; + + 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; + 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 + + // 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.fullName || contactPerson.email) : "-", + }, + // Kontakt Infos + ...((itemInfo.contactTel || contactPerson?.fixedTel || contactPerson?.mobileTel) ? [{ + label: "Telefon", + content: itemInfo.contactTel || contactPerson?.fixedTel || contactPerson?.mobileTel, + }] : []), + ...(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: [] + }; +} diff --git a/src/plugins/services.ts b/src/plugins/services.ts new file mode 100644 index 0000000..1c9429c --- /dev/null +++ b/src/plugins/services.ts @@ -0,0 +1,24 @@ +// /plugins/services.ts +import fp from "fastify-plugin"; +import { bankStatementService } from "../modules/cron/bankstatementsync.service"; +//import {initDokuboxClient, syncDokubox} from "../modules/cron/dokuboximport.service"; +import { FastifyInstance } from "fastify"; +//import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices"; + +declare module "fastify" { + interface FastifyInstance { + services: { + bankStatements: ReturnType; + //dokuboxSync: ReturnType; + //prepareIncomingInvoices: ReturnType; + }; + } +} + +export default fp(async function servicePlugin(server: FastifyInstance) { + server.decorate("services", { + bankStatements: bankStatementService(server), + //dokuboxSync: syncDokubox(server), + //prepareIncomingInvoices: prepareIncomingInvoices(server), + }); +}); diff --git a/src/routes/functions.ts b/src/routes/functions.ts index 471ea1d..67a6582 100644 --- a/src/routes/functions.ts +++ b/src/routes/functions.ts @@ -16,6 +16,7 @@ import {generateTimesEvaluation} from "../modules/time/evaluation.service"; import {citys} from "../../db/schema"; import {eq} from "drizzle-orm"; import {useNextNumberRangeNumber} from "../utils/functions"; +import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; dayjs.extend(customParseFormat) dayjs.extend(isoWeek) dayjs.extend(isBetween) @@ -157,6 +158,23 @@ export default async function functionRoutes(server: FastifyInstance) { } }) + server.post('/functions/serial/start', async (req, reply) => { + console.log(req.body) + const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number} + await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id) + }) + + server.post('/functions/serial/finish/:execution_id', async (req, reply) => { + const {execution_id} = req.params as { execution_id: string } + //@ts-ignore + await finishManualGeneration(server,execution_id) + }) + + server.post('/functions/services/bankstatementsync', async (req, reply) => { + await server.services.bankStatements.run(req.user.tenant_id); + }) + + /*server.post('/print/zpl/preview', async (req, reply) => { const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string} diff --git a/src/routes/resources/main.ts b/src/routes/resources/main.ts index 9fc41dc..1c65d3d 100644 --- a/src/routes/resources/main.ts +++ b/src/routes/resources/main.ts @@ -455,6 +455,16 @@ export default async function resourceRoutes(server: FastifyInstance) { createData[resourceConfig[resource].numberRangeHolder] = result.usedNumber } + const normalizeDate = (val: any) => { + const d = new Date(val) + return isNaN(d.getTime()) ? null : d + } + + Object.keys(createData).forEach((key) => { + if(key.toLowerCase().includes("date")) createData[key] = normalizeDate(createData[key]) + }) + + const [created] = await server.db .insert(table) .values(createData) diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 0000000..0abe4cd --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,95 @@ +import { PutObjectCommand } from "@aws-sdk/client-s3" +import { s3 } from "./s3" +import { secrets } from "./secrets" + +// Drizzle schema +import { files } from "../../db/schema" +import { eq } from "drizzle-orm" +import { FastifyInstance } from "fastify" + +export const saveFile = async ( + server: FastifyInstance, + tenant: number, + messageId: string | number | null, // Typ angepasst (oft null bei manueller Gen) + attachment: any, // Kann File, Buffer oder Mailparser-Objekt sein + folder: string | null, + type: string | null, + other: Record = {} +) => { + try { + // --------------------------------------------------- + // 1️⃣ FILE ENTRY ANLEGEN + // --------------------------------------------------- + const insertRes = await server.db + .insert(files) + .values({ + tenant, + folder, + type, + ...other + }) + .returning() + + const created = insertRes?.[0] + if (!created) { + console.error("File creation failed (no row returned)") + return null + } + + // Name ermitteln (Fallback Logik) + // Wenn attachment ein Buffer ist, muss der Name in 'other' stehen oder generiert werden + const filename = attachment.filename || other.filename || `${created.id}.pdf` + + // --------------------------------------------------- + // 2️⃣ BODY & CONTENT TYPE ERMITTELN + // --------------------------------------------------- + let body: Buffer | Uint8Array | string + let contentType = type || "application/octet-stream" + + if (Buffer.isBuffer(attachment)) { + // FALL 1: RAW BUFFER (von finishManualGeneration) + body = attachment + // ContentType wurde oben schon über 'type' Parameter gesetzt (z.B. application/pdf) + } else if (typeof File !== "undefined" && attachment instanceof File) { + // FALL 2: BROWSER FILE + body = Buffer.from(await attachment.arrayBuffer()) + contentType = attachment.type || contentType + } else if (attachment.content) { + // FALL 3: MAILPARSER OBJECT + body = attachment.content + contentType = attachment.contentType || contentType + } else { + console.error("saveFile: Unknown attachment format") + return null + } + + // --------------------------------------------------- + // 3️⃣ S3 UPLOAD + // --------------------------------------------------- + const key = `${tenant}/filesbyid/${created.id}/${filename}` + + await s3.send( + new PutObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: key, + Body: body, + ContentType: contentType, + ContentLength: body.length // <--- WICHTIG: Behebt den AWS Fehler + }) + ) + + // --------------------------------------------------- + // 4️⃣ PATH IN DB SETZEN + // --------------------------------------------------- + await server.db + .update(files) + .set({ path: key }) + .where(eq(files.id, created.id)) + + console.log(`File saved: ${key}`) + return { id: created.id, key } + } catch (err) { + console.error("saveFile error:", err) + return null + } +} \ No newline at end of file diff --git a/src/utils/resource.config.ts b/src/utils/resource.config.ts index aacdfa2..99c9713 100644 --- a/src/utils/resource.config.ts +++ b/src/utils/resource.config.ts @@ -1,13 +1,36 @@ import { - accounts, bankaccounts, bankrequisitions, bankstatements, + accounts, + bankaccounts, + bankrequisitions, + bankstatements, contacts, - contracts, costcentres, createddocuments, + contracts, + costcentres, + createddocuments, customers, - files, filetags, folders, hourrates, incominginvoices, inventoryitemgroups, - inventoryitems, letterheads, ownaccounts, - plants, productcategories, products, + files, + filetags, + folders, + hourrates, + incominginvoices, + inventoryitemgroups, + inventoryitems, + letterheads, + ownaccounts, + plants, + productcategories, + products, projects, - projecttypes, servicecategories, services, spaces, statementallocations, tasks, texttemplates, units, vehicles, + projecttypes, + serialExecutions, + servicecategories, + services, + spaces, + statementallocations, + tasks, + texttemplates, + units, + vehicles, vendors } from "../../db/schema"; @@ -144,5 +167,8 @@ export const resourceConfig = { }, bankrequisitions: { table: bankrequisitions, + }, + serialexecutions: { + table: serialExecutions } } \ No newline at end of file