@@ -17,6 +17,7 @@ import { letterheads } from "./letterheads"
|
|||||||
import { projects } from "./projects"
|
import { projects } from "./projects"
|
||||||
import { plants } from "./plants"
|
import { plants } from "./plants"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import {serialExecutions} from "./serialexecutions";
|
||||||
|
|
||||||
export const createddocuments = pgTable("createddocuments", {
|
export const createddocuments = pgTable("createddocuments", {
|
||||||
id: bigint("id", { mode: "number" })
|
id: bigint("id", { mode: "number" })
|
||||||
@@ -115,6 +116,8 @@ export const createddocuments = pgTable("createddocuments", {
|
|||||||
contract: bigint("contract", { mode: "number" }).references(
|
contract: bigint("contract", { mode: "number" }).references(
|
||||||
() => contracts.id
|
() => contracts.id
|
||||||
),
|
),
|
||||||
|
|
||||||
|
serialexecution: uuid("serialexecution").references(() => serialExecutions.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
export type CreatedDocument = typeof createddocuments.$inferSelect
|
export type CreatedDocument = typeof createddocuments.$inferSelect
|
||||||
|
|||||||
@@ -68,4 +68,6 @@ export * from "./units"
|
|||||||
export * from "./user_credentials"
|
export * from "./user_credentials"
|
||||||
export * from "./vehicles"
|
export * from "./vehicles"
|
||||||
export * from "./vendors"
|
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",
|
"drizzle-orm": "^0.45.0",
|
||||||
"fastify": "^5.5.0",
|
"fastify": "^5.5.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
"imapflow": "^1.1.1",
|
"imapflow": "^1.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ import {loadSecrets, secrets} from "./utils/secrets";
|
|||||||
import {initMailer} from "./utils/mailer"
|
import {initMailer} from "./utils/mailer"
|
||||||
import {initS3} from "./utils/s3";
|
import {initS3} from "./utils/s3";
|
||||||
|
|
||||||
|
//Services
|
||||||
|
import servicesPlugin from "./plugins/services";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
await loadSecrets();
|
await loadSecrets();
|
||||||
@@ -68,6 +71,7 @@ async function main() {
|
|||||||
await app.register(tenantPlugin);
|
await app.register(tenantPlugin);
|
||||||
await app.register(dayjsPlugin);
|
await app.register(dayjsPlugin);
|
||||||
await app.register(dbPlugin);
|
await app.register(dbPlugin);
|
||||||
|
await app.register(servicesPlugin);
|
||||||
|
|
||||||
app.addHook('preHandler', (req, reply, done) => {
|
app.addHook('preHandler', (req, reply, done) => {
|
||||||
console.log(req.method)
|
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 {citys} from "../../db/schema";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||||
|
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
dayjs.extend(customParseFormat)
|
dayjs.extend(customParseFormat)
|
||||||
dayjs.extend(isoWeek)
|
dayjs.extend(isoWeek)
|
||||||
dayjs.extend(isBetween)
|
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) => {
|
/*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}
|
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
|
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
|
const [created] = await server.db
|
||||||
.insert(table)
|
.insert(table)
|
||||||
.values(createData)
|
.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 {
|
import {
|
||||||
accounts, bankaccounts, bankrequisitions, bankstatements,
|
accounts,
|
||||||
|
bankaccounts,
|
||||||
|
bankrequisitions,
|
||||||
|
bankstatements,
|
||||||
contacts,
|
contacts,
|
||||||
contracts, costcentres, createddocuments,
|
contracts,
|
||||||
|
costcentres,
|
||||||
|
createddocuments,
|
||||||
customers,
|
customers,
|
||||||
files, filetags, folders, hourrates, incominginvoices, inventoryitemgroups,
|
files,
|
||||||
inventoryitems, letterheads, ownaccounts,
|
filetags,
|
||||||
plants, productcategories, products,
|
folders,
|
||||||
|
hourrates,
|
||||||
|
incominginvoices,
|
||||||
|
inventoryitemgroups,
|
||||||
|
inventoryitems,
|
||||||
|
letterheads,
|
||||||
|
ownaccounts,
|
||||||
|
plants,
|
||||||
|
productcategories,
|
||||||
|
products,
|
||||||
projects,
|
projects,
|
||||||
projecttypes, servicecategories, services, spaces, statementallocations, tasks, texttemplates, units, vehicles,
|
projecttypes,
|
||||||
|
serialExecutions,
|
||||||
|
servicecategories,
|
||||||
|
services,
|
||||||
|
spaces,
|
||||||
|
statementallocations,
|
||||||
|
tasks,
|
||||||
|
texttemplates,
|
||||||
|
units,
|
||||||
|
vehicles,
|
||||||
vendors
|
vendors
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
|
|
||||||
@@ -144,5 +167,8 @@ export const resourceConfig = {
|
|||||||
},
|
},
|
||||||
bankrequisitions: {
|
bankrequisitions: {
|
||||||
table: bankrequisitions,
|
table: bankrequisitions,
|
||||||
|
},
|
||||||
|
serialexecutions: {
|
||||||
|
table: serialExecutions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user