Merge branch 'orm' into 'main'

Orm

See merge request fedeo/backend!16
This commit is contained in:
2026-01-01 15:39:55 +00:00
13 changed files with 1201 additions and 7 deletions

View File

@@ -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

View File

@@ -68,4 +68,6 @@ export * from "./units"
export * from "./user_credentials"
export * from "./vehicles"
export * from "./vendors"
export * from "./staff_time_events"
export * from "./staff_time_events"
export * from "./serialtypes"
export * from "./serialexecutions"

View File

@@ -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"
});

40
db/schema/serialtypes.ts Normal file
View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -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<any | false> => {
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<TransactionsResponse>(
`${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")
}
}
}

View File

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

24
src/plugins/services.ts Normal file
View File

@@ -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<typeof bankStatementService>;
//dokuboxSync: ReturnType<typeof syncDokubox>;
//prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>;
};
}
}
export default fp(async function servicePlugin(server: FastifyInstance) {
server.decorate("services", {
bankStatements: bankStatementService(server),
//dokuboxSync: syncDokubox(server),
//prepareIncomingInvoices: prepareIncomingInvoices(server),
});
});

View File

@@ -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}

View File

@@ -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)

95
src/utils/files.ts Normal file
View File

@@ -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<string, any> = {}
) => {
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
}
}

View File

@@ -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
}
}