@@ -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
|
||||
|
||||
@@ -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"
|
||||
21
db/schema/serialexecutions.ts
Normal file
21
db/schema/serialexecutions.ts
Normal 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
40
db/schema/serialtypes.ts
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
247
src/modules/cron/bankstatementsync.service.ts
Normal file
247
src/modules/cron/bankstatementsync.service.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
703
src/modules/serialexecution.service.ts
Normal file
703
src/modules/serialexecution.service.ts
Normal 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
24
src/plugins/services.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
95
src/utils/files.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user