Added Backend

This commit is contained in:
2026-01-06 12:07:43 +01:00
parent b013ef8f4b
commit 6f3d4c0bff
165 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
// /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)),
]
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
for (const accId of affectedAccounts) {
await server.db
.update(bankaccounts)
//@ts-ignore
.set({syncedAt: normalizeDate(dayjs())})
.where(eq(bankaccounts.id, accId))
}
}
console.log("Bank statement sync completed.")
} catch (error) {
console.error(error)
}
}
return {
run: async (tenant) => {
await getToken()
await syncAccounts(tenant)
console.log("Service: Bankstatement sync finished")
}
}
}

View File

@@ -0,0 +1,259 @@
import axios from "axios"
import dayjs from "dayjs"
import { ImapFlow } from "imapflow"
import { simpleParser } from "mailparser"
import { FastifyInstance } from "fastify"
import {saveFile} from "../../utils/files";
import { secrets } from "../../utils/secrets"
// Drizzle Imports
import {
tenants,
folders,
filetags,
} from "../../../db/schema"
import {
eq,
and,
} from "drizzle-orm"
let badMessageDetected = false
let badMessageMessageSent = false
let client: ImapFlow | null = null
// -------------------------------------------------------------
// IMAP CLIENT INITIALIZEN
// -------------------------------------------------------------
export async function initDokuboxClient() {
client = new ImapFlow({
host: secrets.DOKUBOX_IMAP_HOST,
port: secrets.DOKUBOX_IMAP_PORT,
secure: secrets.DOKUBOX_IMAP_SECURE,
auth: {
user: secrets.DOKUBOX_IMAP_USER,
pass: secrets.DOKUBOX_IMAP_PASSWORD
},
logger: false
})
console.log("Dokubox E-Mail Client Initialized")
await client.connect()
}
// -------------------------------------------------------------
// MAIN SYNC FUNCTION (DRIZZLE VERSION)
// -------------------------------------------------------------
export const syncDokubox = (server: FastifyInstance) =>
async () => {
console.log("Perform Dokubox Sync")
await initDokuboxClient()
if (!client?.usable) {
throw new Error("E-Mail Client not usable")
}
// -------------------------------
// TENANTS LADEN (DRIZZLE)
// -------------------------------
const tenantList = await server.db
.select({
id: tenants.id,
name: tenants.name,
emailAddresses: tenants.dokuboxEmailAddresses,
key: tenants.dokuboxkey
})
.from(tenants)
const lock = await client.getMailboxLock("INBOX")
try {
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
const parsed = await simpleParser(msg.source)
const message = {
id: msg.uid,
subject: parsed.subject,
to: parsed.to?.value || [],
cc: parsed.cc?.value || [],
attachments: parsed.attachments || []
}
// -------------------------------------------------
// MAPPING / FIND TENANT
// -------------------------------------------------
const config = await getMessageConfigDrizzle(server, message, tenantList)
if (!config) {
badMessageDetected = true
if (!badMessageMessageSent) {
badMessageMessageSent = true
}
return
}
if (message.attachments.length > 0) {
for (const attachment of message.attachments) {
await saveFile(
server,
config.tenant,
message.id,
attachment,
config.folder,
config.filetype
)
}
}
}
if (!badMessageDetected) {
badMessageDetected = false
badMessageMessageSent = false
}
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
await client.messageDelete({ seen: true })
} finally {
lock.release()
client.close()
}
}
// -------------------------------------------------------------
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
// -------------------------------------------------------------
const getMessageConfigDrizzle = async (
server: FastifyInstance,
message,
tenantsList: any[]
) => {
let possibleKeys: string[] = []
if (message.to) {
message.to.forEach((item) =>
possibleKeys.push(item.address.split("@")[0].toLowerCase())
)
}
if (message.cc) {
message.cc.forEach((item) =>
possibleKeys.push(item.address.split("@")[0].toLowerCase())
)
}
// -------------------------------------------
// TENANT IDENTIFY
// -------------------------------------------
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
if (!tenant && message.to?.length) {
const address = message.to[0].address.toLowerCase()
tenant = tenantsList.find((t) =>
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
)
}
if (!tenant) return null
// -------------------------------------------
// FOLDER + FILETYPE VIA SUBJECT
// -------------------------------------------
let folderId = null
let filetypeId = null
// -------------------------------------------
// Rechnung / Invoice
// -------------------------------------------
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
const folder = await server.db
.select({ id: folders.id })
.from(folders)
.where(
and(
eq(folders.tenant, tenant.id),
and(
eq(folders.function, "incomingInvoices"),
//@ts-ignore
eq(folders.year, dayjs().format("YYYY"))
)
)
)
.limit(1)
folderId = folder[0]?.id ?? null
const tag = await server.db
.select({ id: filetags.id })
.from(filetags)
.where(
and(
eq(filetags.tenant, tenant.id),
eq(filetags.incomingDocumentType, "invoices")
)
)
.limit(1)
filetypeId = tag[0]?.id ?? null
}
// -------------------------------------------
// Mahnung
// -------------------------------------------
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
const tag = await server.db
.select({ id: filetags.id })
.from(filetags)
.where(
and(
eq(filetags.tenant, tenant.id),
eq(filetags.incomingDocumentType, "reminders")
)
)
.limit(1)
filetypeId = tag[0]?.id ?? null
}
// -------------------------------------------
// Sonstige Dokumente → Deposit Folder
// -------------------------------------------
else {
const folder = await server.db
.select({ id: folders.id })
.from(folders)
.where(
and(
eq(folders.tenant, tenant.id),
eq(folders.function, "deposit")
)
)
.limit(1)
folderId = folder[0]?.id ?? null
}
return {
tenant: tenant.id,
folder: folderId,
filetype: filetypeId
}
}

View File

@@ -0,0 +1,175 @@
import { FastifyInstance } from "fastify"
import dayjs from "dayjs"
import { getInvoiceDataFromGPT } from "../../utils/gpt"
// Drizzle schema
import {
tenants,
files,
filetags,
incominginvoices,
} from "../../../db/schema"
import { eq, and, isNull, not } from "drizzle-orm"
export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => {
console.log("▶ Starting Incoming Invoice Preparation")
const tenantsRes = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, tenantId))
.orderBy(tenants.id)
if (!tenantsRes.length) {
console.log("No tenants with autoPrepareIncomingInvoices = true")
return
}
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
// -------------------------------------------------------------
// 2⃣ Jeden Tenant einzeln verarbeiten
// -------------------------------------------------------------
for (const tenant of tenantsRes) {
const tenantId = tenant.id
// 2.1 Datei-Tags holen für incoming invoices
const tagRes = await server.db
.select()
.from(filetags)
.where(
and(
eq(filetags.tenant, tenantId),
eq(filetags.incomingDocumentType, "invoices")
)
)
.limit(1)
const invoiceFileTag = tagRes?.[0]?.id
if (!invoiceFileTag) {
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
continue
}
// 2.2 Alle Dateien laden, die als Invoice markiert sind aber NOCH keine incominginvoice haben
const filesRes = await server.db
.select()
.from(files)
.where(
and(
eq(files.tenant, tenantId),
eq(files.type, invoiceFileTag),
isNull(files.incominginvoice),
eq(files.archived, false),
not(isNull(files.path))
)
)
if (!filesRes.length) {
console.log(`No invoice files for tenant ${tenantId}`)
continue
}
// -------------------------------------------------------------
// 3⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
// -------------------------------------------------------------
for (const file of filesRes) {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
const data = await getInvoiceDataFromGPT(server,file, tenantId)
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
continue
}
// ---------------------------------------------------------
// 3.1 IncomingInvoice-Objekt vorbereiten
// ---------------------------------------------------------
let itemInfo: any = {
tenant: tenantId,
state: "Vorbereitet"
}
if (data.invoice_number) itemInfo.reference = data.invoice_number
if (data.invoice_date) itemInfo.date = dayjs(data.invoice_date).toISOString()
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
if (data.invoice_duedate) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
// Payment terms mapping
const mapPayment: any = {
"Direct Debit": "Einzug",
"Transfer": "Überweisung",
"Credit Card": "Kreditkarte",
"Other": "Sonstiges",
}
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
// 3.2 Positionszeilen konvertieren
if (data.invoice_items?.length > 0) {
itemInfo.accounts = data.invoice_items.map(item => ({
account: item.account_id,
description: item.description,
amountNet: item.total_without_tax,
amountTax: Number((item.total - item.total_without_tax).toFixed(2)),
taxType: String(item.tax_rate),
amountGross: item.total,
costCentre: null,
quantity: item.quantity,
}))
}
// 3.3 Beschreibung generieren
let description = ""
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
if (data.reference) description += `Referenz: ${data.reference}\n`
if (data.invoice_items) {
for (const item of data.invoice_items) {
description += `${item.description} - ${item.quantity} ${item.unit} - ${item.total}\n`
}
}
itemInfo.description = description.trim()
// ---------------------------------------------------------
// 4⃣ IncomingInvoice erstellen
// ---------------------------------------------------------
const inserted = await server.db
.insert(incominginvoices)
.values(itemInfo)
.returning()
const newInvoice = inserted?.[0]
if (!newInvoice) {
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
continue
}
// ---------------------------------------------------------
// 5⃣ Datei mit incominginvoice-ID verbinden
// ---------------------------------------------------------
await server.db
.update(files)
.set({ incominginvoice: newInvoice.id })
.where(eq(files.id, file.id))
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
}
}
return
}
return {
run: async (tenant:number) => {
await processInvoices(tenant)
console.log("Incoming Invoice Preparation Completed.")
}
}
}

View File

@@ -0,0 +1,38 @@
// modules/helpdesk/helpdesk.contact.service.ts
import { FastifyInstance } from 'fastify'
export async function getOrCreateContact(
server: FastifyInstance,
tenant_id: number,
{ email, phone, display_name, customer_id, contact_id }: { email?: string; phone?: string; display_name?: string; customer_id?: number; contact_id?: number }
) {
if (!email && !phone) throw new Error('Contact must have at least an email or phone')
// Bestehenden Kontakt prüfen
const { data: existing, error: findError } = await server.supabase
.from('helpdesk_contacts')
.select('*')
.eq('tenant_id', tenant_id)
.or(`email.eq.${email || ''},phone.eq.${phone || ''}`)
.maybeSingle()
if (findError) throw findError
if (existing) return existing
// Anlegen
const { data: created, error: insertError } = await server.supabase
.from('helpdesk_contacts')
.insert({
tenant_id,
email,
phone,
display_name,
customer_id,
contact_id
})
.select()
.single()
if (insertError) throw insertError
return created
}

View File

@@ -0,0 +1,90 @@
// modules/helpdesk/helpdesk.conversation.service.ts
import { FastifyInstance } from 'fastify'
import { getOrCreateContact } from './helpdesk.contact.service.js'
import {useNextNumberRangeNumber} from "../../utils/functions";
export async function createConversation(
server: FastifyInstance,
{
tenant_id,
contact,
channel_instance_id,
subject,
customer_id = null,
contact_person_id = null,
}: {
tenant_id: number
contact: { email?: string; phone?: string; display_name?: string }
channel_instance_id: string
subject?: string,
customer_id?: number,
contact_person_id?: number
}
) {
const contactRecord = await getOrCreateContact(server, tenant_id, contact)
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.insert({
tenant_id,
contact_id: contactRecord.id,
channel_instance_id,
subject: subject || null,
status: 'open',
created_at: new Date().toISOString(),
customer_id,
contact_person_id,
ticket_number: usedNumber
})
.select()
.single()
if (error) throw error
return data
}
export async function getConversations(
server: FastifyInstance,
tenant_id: number,
opts?: { status?: string; limit?: number }
) {
const { status, limit = 50 } = opts || {}
let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id)
if (status) query = query.eq('status', status)
query = query.order('last_message_at', { ascending: false }).limit(limit)
const { data, error } = await query
if (error) throw error
const mappedData = data.map(entry => {
return {
...entry,
customer: entry.customer_id
}
})
return mappedData
}
export async function updateConversationStatus(
server: FastifyInstance,
conversation_id: string,
status: string
) {
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
if (!valid.includes(status)) throw new Error('Invalid status')
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.update({ status })
.eq('id', conversation_id)
.select()
.single()
if (error) throw error
return data
}

View File

@@ -0,0 +1,60 @@
// modules/helpdesk/helpdesk.message.service.ts
import { FastifyInstance } from 'fastify'
export async function addMessage(
server: FastifyInstance,
{
tenant_id,
conversation_id,
author_user_id = null,
direction = 'incoming',
payload,
raw_meta = null,
external_message_id = null,
}: {
tenant_id: number
conversation_id: string
author_user_id?: string | null
direction?: 'incoming' | 'outgoing' | 'internal' | 'system'
payload: any
raw_meta?: any
external_message_id?: string
}
) {
if (!payload?.text) throw new Error('Message payload requires text content')
const { data: message, error } = await server.supabase
.from('helpdesk_messages')
.insert({
tenant_id,
conversation_id,
author_user_id,
direction,
payload,
raw_meta,
created_at: new Date().toISOString(),
})
.select()
.single()
if (error) throw error
// Letzte Nachricht aktualisieren
await server.supabase
.from('helpdesk_conversations')
.update({ last_message_at: new Date().toISOString() })
.eq('id', conversation_id)
return message
}
export async function getMessages(server: FastifyInstance, conversation_id: string) {
const { data, error } = await server.supabase
.from('helpdesk_messages')
.select('*')
.eq('conversation_id', conversation_id)
.order('created_at', { ascending: true })
if (error) throw error
return data
}

View File

@@ -0,0 +1,148 @@
// services/notification.service.ts
import type { FastifyInstance } from 'fastify';
import {secrets} from "../utils/secrets";
export type NotificationStatus = 'queued' | 'sent' | 'failed';
export interface TriggerInput {
tenantId: number;
userId: string; // muss auf public.auth_users.id zeigen
eventType: string; // muss in notifications_event_types existieren
title: string; // Betreff/Title
message: string; // Klartext-Inhalt
payload?: Record<string, unknown>;
}
export interface UserDirectoryInfo {
email?: string;
}
export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise<UserDirectoryInfo | null>;
export class NotificationService {
constructor(
private server: FastifyInstance,
private getUser: UserDirectory
) {}
/**
* Löst eine E-Mail-Benachrichtigung aus:
* - Validiert den Event-Typ
* - Legt einen Datensatz in notifications_items an (status: queued)
* - Versendet E-Mail (FEDEO Branding)
* - Aktualisiert status/sent_at bzw. error
*/
async trigger(input: TriggerInput) {
const { tenantId, userId, eventType, title, message, payload } = input;
const supabase = this.server.supabase;
// 1) Event-Typ prüfen (aktiv?)
const { data: eventTypeRow, error: etErr } = await supabase
.from('notifications_event_types')
.select('event_key,is_active')
.eq('event_key', eventType)
.maybeSingle();
if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
}
// 2) Zieladresse beschaffen
const user = await this.getUser(this.server, userId, tenantId);
if (!user?.email) {
throw new Error(`Nutzer ${userId} hat keine E-Mail-Adresse`);
}
// 3) Notification anlegen (status: queued)
const { data: inserted, error: insErr } = await supabase
.from('notifications_items')
.insert({
tenant_id: tenantId,
user_id: userId,
event_type: eventType,
title,
message,
payload: payload ?? null,
channel: 'email',
status: 'queued'
})
.select('id')
.single();
if (insErr || !inserted) {
throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`);
}
// 4) E-Mail versenden
try {
await this.sendEmail(user.email, title, message);
await supabase
.from('notifications_items')
.update({ status: 'sent', sent_at: new Date().toISOString() })
.eq('id', inserted.id);
return { success: true, id: inserted.id };
} catch (err: any) {
await supabase
.from('notifications_items')
.update({ status: 'failed', error: String(err?.message || err) })
.eq('id', inserted.id);
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
}
}
// ---- private helpers ------------------------------------------------------
private async sendEmail(to: string, subject: string, message: string) {
const nodemailer = await import('nodemailer');
const transporter = nodemailer.createTransport({
host: secrets.MAILER_SMTP_HOST,
port: Number(secrets.MAILER_SMTP_PORT),
secure: secrets.MAILER_SMTP_SSL === 'true',
auth: {
user: secrets.MAILER_SMTP_USER,
pass: secrets.MAILER_SMTP_PASS
}
});
const html = this.renderFedeoHtml(subject, message);
await transporter.sendMail({
from: secrets.MAILER_FROM,
to,
subject,
text: message,
html
});
}
private renderFedeoHtml(title: string, message: string) {
return `
<html><body style="font-family:sans-serif;color:#222">
<div style="border:1px solid #ddd;border-radius:8px;padding:16px;max-width:600px;margin:auto">
<h2 style="color:#0f62fe;margin:0 0 12px">FEDEO</h2>
<h3 style="margin:0 0 8px">${this.escapeHtml(title)}</h3>
<p>${this.nl2br(this.escapeHtml(message))}</p>
<hr style="margin:16px 0;border:none;border-top:1px solid #eee"/>
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
</div>
</body></html>
`;
}
// simple escaping (ausreichend für unser Template)
private escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private nl2br(s: string) {
return s.replace(/\n/g, '<br/>');
}
}

View File

@@ -0,0 +1,406 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import {and, eq, inArray, not} from 'drizzle-orm';
import * as schema from '../../db/schema';
import {useNextNumberRangeNumber} from "../utils/functions"; // Pfad anpassen
export const publicLinkService = {
/**
* Erstellt einen neuen Public Link
*/
async createLink(server: FastifyInstance, tenantId: number,
name: string,
isProtected?: boolean,
pin?: string,
customToken?: string,
config?: Record<string, any>,
defaultProfileId?: string) {
let pinHash: string | null = null;
// 1. PIN Hashen, falls Schutz aktiviert ist
if (isProtected && pin) {
pinHash = await bcrypt.hash(pin, 10);
} else if (isProtected && !pin) {
throw new Error("Für geschützte Links muss eine PIN angegeben werden.");
}
// 2. Token generieren oder Custom Token verwenden
let token = customToken;
if (!token) {
// Generiere einen zufälligen Token (z.B. hex string)
// Alternativ: nanoid nutzen, falls installiert
token = crypto.randomBytes(12).toString('hex');
}
// Prüfen, ob Token schon existiert (nur bei Custom Token wichtig)
if (customToken) {
const existing = await server.db.query.publicLinks.findFirst({
where: eq(schema.publicLinks.token, token)
});
if (existing) {
throw new Error(`Der Token '${token}' ist bereits vergeben.`);
}
}
// 3. DB Insert
const [newLink] = await server.db.insert(schema.publicLinks).values({
tenant: tenantId,
name: name,
token: token,
isProtected: isProtected || false,
pinHash: pinHash,
config: config || {},
defaultProfile: defaultProfileId || null,
active: true
}).returning();
return newLink;
},
/**
* Listet alle Links für einen Tenant auf (für die Verwaltungs-UI später)
*/
async getLinksByTenant(server: FastifyInstance, tenantId: number) {
return server.db.select()
.from(schema.publicLinks)
.where(eq(schema.publicLinks.tenant, tenantId));
},
async getLinkContext(server: FastifyInstance, token: string, providedPin?: string) {
// 1. Link laden & Checks
const linkConfig = await server.db.query.publicLinks.findFirst({
where: eq(schema.publicLinks.token, token)
});
if (!linkConfig || !linkConfig.active) throw new Error("Link_NotFound");
// 2. PIN Check
if (linkConfig.isProtected) {
if (!providedPin) throw new Error("Pin_Required");
const isValid = await bcrypt.compare(providedPin, linkConfig.pinHash || "");
if (!isValid) throw new Error("Pin_Invalid");
}
const tenantId = linkConfig.tenant;
const config = linkConfig.config as any;
// --- RESSOURCEN & FILTER ---
// Standardmäßig alles laden, wenn 'resources' nicht definiert ist
const requestedResources: string[] = config.resources || ['profiles', 'projects', 'services', 'units'];
const filters = config.filters || {}; // Erwartet jetzt: { projects: [1,2], services: [3,4] }
const queryPromises: Record<string, Promise<any[]>> = {};
// ---------------------------------------------------------
// 1. PROFILES (Mitarbeiter)
// ---------------------------------------------------------
if (requestedResources.includes('profiles')) {
let profileCondition = and(
eq(schema.authProfiles.tenant_id, tenantId),
eq(schema.authProfiles.active, true)
);
// Sicherheits-Feature: Default Profil erzwingen
if (linkConfig.defaultProfile) {
profileCondition = and(profileCondition, eq(schema.authProfiles.id, linkConfig.defaultProfile));
}
// Optional: Auch hier Filter ermöglichen (falls man z.B. nur 3 bestimmte MA zur Auswahl geben will)
if (filters.profiles && Array.isArray(filters.profiles) && filters.profiles.length > 0) {
profileCondition = and(profileCondition, inArray(schema.authProfiles.id, filters.profiles));
}
queryPromises.profiles = server.db.select({
id: schema.authProfiles.id,
fullName: schema.authProfiles.full_name
})
.from(schema.authProfiles)
.where(profileCondition);
}
// ---------------------------------------------------------
// 2. PROJECTS (Aufträge)
// ---------------------------------------------------------
if (requestedResources.includes('projects')) {
let projectCondition = and(
eq(schema.projects.tenant, tenantId),
not(eq(schema.projects.active_phase, 'Abgeschlossen'))
);
// NEU: Zugriff direkt auf filters.projects
if (filters.projects && Array.isArray(filters.projects) && filters.projects.length > 0) {
projectCondition = and(projectCondition, inArray(schema.projects.id, filters.projects));
}
queryPromises.projects = server.db.select({
id: schema.projects.id,
name: schema.projects.name
})
.from(schema.projects)
.where(projectCondition);
}
// ---------------------------------------------------------
// 3. SERVICES (Tätigkeiten)
// ---------------------------------------------------------
if (requestedResources.includes('services')) {
let serviceCondition = eq(schema.services.tenant, tenantId);
// NEU: Zugriff direkt auf filters.services
if (filters.services && Array.isArray(filters.services) && filters.services.length > 0) {
serviceCondition = and(serviceCondition, inArray(schema.services.id, filters.services));
}
queryPromises.services = server.db.select({
id: schema.services.id,
name: schema.services.name,
unit: schema.services.unit
})
.from(schema.services)
.where(serviceCondition);
}
// ---------------------------------------------------------
// 4. UNITS (Einheiten)
// ---------------------------------------------------------
if (requestedResources.includes('units')) {
// Units werden meist global geladen, könnten aber auch gefiltert werden
queryPromises.units = server.db.select().from(schema.units);
}
// --- QUERY AUSFÜHRUNG ---
const results = await Promise.all(Object.values(queryPromises));
const keys = Object.keys(queryPromises);
const dataResponse: Record<string, any[]> = {
profiles: [],
projects: [],
services: [],
units: []
};
keys.forEach((key, index) => {
dataResponse[key] = results[index];
});
return {
config: linkConfig.config,
meta: {
formName: linkConfig.name,
defaultProfileId: linkConfig.defaultProfile
},
data: dataResponse
};
},
async submitFormData(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
// 1. Validierung (Token & PIN)
const linkConfig = await server.db.query.publicLinks.findFirst({
where: eq(schema.publicLinks.token, token)
});
if (!linkConfig || !linkConfig.active) throw new Error("Link_NotFound");
if (linkConfig.isProtected) {
if (!providedPin) throw new Error("Pin_Required");
const isValid = await bcrypt.compare(providedPin, linkConfig.pinHash || "");
if (!isValid) throw new Error("Pin_Invalid");
}
const tenantId = linkConfig.tenant;
const config = linkConfig.config as any;
// 2. USER ID AUFLÖSEN
// Wir holen die profileId aus dem Link (Default) oder dem Payload (User-Auswahl)
const rawProfileId = linkConfig.defaultProfile || payload.profile;
if (!rawProfileId) throw new Error("Profile_Missing");
// Profil laden, um die user_id zu bekommen
const authProfile = await server.db.query.authProfiles.findFirst({
where: eq(schema.authProfiles.id, rawProfileId)
});
if (!authProfile) throw new Error("Profile_Not_Found");
// Da du sagtest, es gibt immer einen User, verlassen wir uns darauf.
// Falls null, werfen wir einen Fehler, da die DB sonst beim Insert knallt.
const userId = authProfile.user_id;
if (!userId) throw new Error("Profile_Has_No_User_Assigned");
// Helper für Datum
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
// =================================================================
// SCHRITT A: Stammdaten laden
// =================================================================
const project = await server.db.query.projects.findFirst({
where: eq(schema.projects.id, payload.project)
});
if (!project) throw new Error("Project not found");
const customer = await server.db.query.customers.findFirst({
where: eq(schema.customers.id, project.customer)
});
const service = await server.db.query.services.findFirst({
where: eq(schema.services.id, payload.service)
});
if (!service) throw new Error("Service not found");
// Texttemplates & Letterhead laden
const texttemplates = await server.db.query.texttemplates.findMany({
where: (t, {and, eq}) => and(
eq(t.tenant, tenantId),
eq(t.documentType, 'deliveryNotes')
)
});
const letterhead = await server.db.query.letterheads.findFirst({
where: eq(schema.letterheads.tenant, tenantId)
});
// =================================================================
// SCHRITT B: Nummernkreis generieren
// =================================================================
const {usedNumber} = await useNextNumberRangeNumber(server, tenantId, "deliveryNotes");
// =================================================================
// SCHRITT C: Berechnungen
// =================================================================
const startDate = normalizeDate(payload.startDate) || new Date();
let endDate = normalizeDate(payload.endDate);
// Fallback Endzeit (+1h)
if (!endDate) {
endDate = server.dayjs(startDate).add(1, 'hour').toDate();
}
// Menge berechnen
let quantity = payload.quantity;
if (!quantity && payload.totalHours) quantity = payload.totalHours;
if (!quantity) {
const diffMin = server.dayjs(endDate).diff(server.dayjs(startDate), 'minute');
quantity = Number((diffMin / 60).toFixed(2));
}
// =================================================================
// SCHRITT D: Lieferschein erstellen
// =================================================================
const createDocData = {
tenant: tenantId,
type: "deliveryNotes",
state: "Entwurf",
customer: project.customer,
//@ts-ignore
address: customer ? {zip: customer.infoData.zip, city: customer.infoData.city, street: customer.infoData.street,} : {},
project: project.id,
documentNumber: usedNumber,
documentDate: String(new Date()), // Schema sagt 'text', evtl toISOString() besser?
deliveryDate: String(startDate), // Schema sagt 'text'
deliveryDateType: "Leistungsdatum",
createdBy: userId, // WICHTIG: Hier die User ID
created_by: userId, // WICHTIG: Hier die User ID
title: "Lieferschein",
description: "",
startText: texttemplates.find((i: any) => i.default && i.pos === "startText")?.text || "",
endText: texttemplates.find((i: any) => i.default && i.pos === "endText")?.text || "",
rows: [
{
pos: "1",
mode: "service",
service: service.id,
text: service.name,
unit: service.unit,
quantity: quantity,
description: service.description || null,
descriptionText: service.description || null,
agriculture: {
dieselUsage: payload.dieselUsage || null,
}
}
],
contactPerson: userId, // WICHTIG: Hier die User ID
letterhead: letterhead?.id,
};
const [createdDoc] = await server.db.insert(schema.createddocuments)
//@ts-ignore
.values(createDocData)
.returning();
// =================================================================
// SCHRITT E: Zeiterfassung Events
// =================================================================
if (config.features?.timeTracking) {
// Metadaten für das Event
const eventMetadata = {
project: project.id,
service: service.id,
description: payload.description || "",
generatedDocumentId: createdDoc.id
};
// 1. START EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId, // WICHTIG: User ID
actortype: "user",
actoruser_id: userId, // WICHTIG: User ID
eventtime: startDate,
eventtype: "START",
source: "PUBLIC_LINK",
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
});
// 2. STOP EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: endDate,
eventtype: "STOP",
source: "PUBLIC_LINK",
metadata: eventMetadata
});
}
// =================================================================
// SCHRITT F: History Items
// =================================================================
const historyItemsToCreate = [];
if (payload.description) {
historyItemsToCreate.push({
tenant: tenantId,
createdBy: userId, // WICHTIG: User ID
text: `Notiz aus Webformular Lieferschein ${createdDoc.documentNumber}: ${payload.description}`,
project: project.id,
createdDocument: createdDoc.id
});
}
historyItemsToCreate.push({
tenant: tenantId,
createdBy: userId, // WICHTIG: User ID
text: `Webformular abgeschickt. Lieferschein ${createdDoc.documentNumber} erstellt. Zeit gebucht (Start/Stop).`,
project: project.id,
createdDocument: createdDoc.id
});
await server.db.insert(schema.historyitems).values(historyItemsToCreate);
return {success: true, documentNumber: createdDoc.documentNumber};
}
}

View File

@@ -0,0 +1,725 @@
import dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
import Handlebars from "handlebars";
import axios from "axios";
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
// DEINE IMPORTS
import * as schema from "../../db/schema"; // Importiere dein Drizzle Schema
import { saveFile } from "../utils/files";
import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
dayjs.extend(quarterOfYear);
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => {
try {
console.log(executedBy)
const executionDayjs = dayjs(executionDate);
console.log(`Starte manuelle Generierung für Tenant ${tenantId} am ${executionDate}`);
// 1. Tenant laden (Drizzle)
// Wir nehmen an, dass 'tenants' im Schema definiert ist
const [tenant] = await server.db
.select()
.from(schema.tenants)
.where(eq(schema.tenants.id, tenantId))
.limit(1);
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
// 2. Templates laden
const templates = await server.db
.select()
.from(schema.createddocuments)
.where(
and(
eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.type, "serialInvoices"),
inArray(schema.createddocuments.id, templateIds)
)
);
if (templates.length === 0) {
console.warn("Keine passenden Vorlagen gefunden.");
return [];
}
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
const folderId = await getFolderId(server,tenantId);
const fileTypeId = await getFileTypeId(server,tenantId);
const results = [];
const [executionRecord] = await server.db
.insert(schema.serialExecutions)
.values({
tenant: tenantId,
executionDate: executionDayjs.toDate(),
status: "draft",
createdBy: executedBy,
summary: `${templateIds.length} Vorlagen verarbeitet`
})
.returning();
console.log(executionRecord);
// 4. Loop durch die Templates
for (const template of templates) {
try {
const resultId = await processSingleTemplate(
server,
template,
tenant,
executionDayjs,
folderId,
fileTypeId,
executedBy,
executionRecord.id
);
results.push({ id: template.id, status: "success", newDocumentId: resultId });
} catch (e: any) {
console.error(`Fehler bei Template ${template.id}:`, e);
results.push({ id: template.id, status: "error", error: e.message });
}
}
return results;
} catch (error) {
console.log(error);
}
}
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => {
try {
console.log(`Beende Ausführung ${executionId}...`);
// 1. Execution und Tenant laden
const [executionRecord] = await server.db
.select()
.from(schema.serialExecutions)// @ts-ignore
.where(eq(schema.serialExecutions.id, executionId))
.limit(1);
if (!executionRecord) throw new Error("Execution nicht gefunden");
console.log(executionRecord);
const tenantId = executionRecord.tenant;
console.log(tenantId)
// Tenant laden (für Settings etc.)
const [tenant] = await server.db
.select()
.from(schema.tenants)
.where(eq(schema.tenants.id, tenantId))
.limit(1);
// 2. Status auf "processing" setzen (optional, damit UI feedback hat)
/*await server.db
.update(schema.serialExecutions)
.set({ status: "processing" })// @ts-ignore
.where(eq(schema.serialExecutions.id, executionId));*/
// 3. Alle erstellten Dokumente dieser Execution laden
const documents = await server.db
.select()
.from(schema.createddocuments)
.where(eq(schema.createddocuments.serialexecution, executionId));
console.log(`${documents.length} Dokumente werden finalisiert...`);
// 4. IDs für File-System laden (nur einmalig nötig)
const folderId = await getFolderId(server, tenantId);
const fileTypeId = await getFileTypeId(server, tenantId);
// Globale Daten laden, die für alle gleich sind (Optimierung)
const [units, products, services] = await Promise.all([
server.db.select().from(schema.units),
server.db.select().from(schema.products).where(eq(schema.products.tenant, tenantId)),
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
]);
let successCount = 0;
let errorCount = 0;
// 5. Loop durch Dokumente
for (const doc of documents) {
try {
const [letterhead] = await Promise.all([
/*fetchById(server, schema.contacts, doc.contact),
fetchById(server, schema.customers, doc.customer),
fetchById(server, schema.authProfiles, doc.contactPerson), // oder createdBy, je nach Logik
fetchById(server, schema.projects, doc.project),
fetchById(server, schema.contracts, doc.contract),*/
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
]);
const pdfData = await getCloseData(
server,
doc,
tenant,
units,
products,
services,
);
console.log(pdfData);
// D. PDF Generieren
const pdfBase64 = await createInvoicePDF(server,"base64",pdfData, letterhead?.path);
console.log(pdfBase64);
// E. Datei speichern
// @ts-ignore
const fileBuffer = Buffer.from(pdfBase64.base64, "base64");
const filename = `${pdfData.documentNumber}.pdf`;
await saveFile(
server,
tenantId,
null, // User ID (hier ggf. executionRecord.createdBy nutzen wenn verfügbar)
fileBuffer,
folderId,
fileTypeId,
{
createddocument: doc.id,
filename: filename,
filesize: fileBuffer.length // Falls saveFile das braucht
}
);
// F. Dokument in DB final updaten
await server.db
.update(schema.createddocuments)
.set({
state: "Gebucht",
documentNumber: pdfData.documentNumber,
title: pdfData.title,
pdf_path: filename // Optional, falls du den Pfad direkt am Doc speicherst
})
.where(eq(schema.createddocuments.id, doc.id));
successCount++;
} catch (innerErr) {
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
errorCount++;
// Optional: Status des einzelnen Dokuments auf Error setzen
}
}
// 6. Execution abschließen
const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge
await server.db
.update(schema.serialExecutions)
.set({
status: finalStatus,
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
})// @ts-ignore
.where(eq(schema.serialExecutions.id, executionId));
return { success: true, processed: successCount, errors: errorCount };
} catch (error) {
console.error("Critical Error in finishManualGeneration:", error);
// Execution auf Error setzen
// @ts-ignore
await server.db
.update(schema.serialExecutions)
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
//@ts-ignore
.where(eq(schema.serialExecutions.id, executionId));
throw error;
}
}
/**
* Verarbeitet eine einzelne Vorlage
*/
async function processSingleTemplate(server: FastifyInstance, template: any, tenant: any,executionDate: dayjs.Dayjs,folderId: string,fileTypeId: string,executedBy: string,executionId: string) {
// A. Zugehörige Daten parallel laden
const [contact, customer, profile, project, contract, units, products, services, letterhead] = await Promise.all([
fetchById(server, schema.contacts, template.contact),
fetchById(server, schema.customers, template.customer),
fetchById(server, schema.authProfiles, template.contactPerson),
fetchById(server, schema.projects, template.project),
fetchById(server, schema.contracts, template.contract),
server.db.select().from(schema.units),
server.db.select().from(schema.products).where(eq(schema.products.tenant, tenant.id)),
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenant.id)),
template.letterhead ? fetchById(server, schema.letterheads, template.letterhead) : null
]);
// B. Datumsberechnung (Logik aus dem Original)
const { firstDate, lastDate } = calculateDateRange(template.serialConfig, executionDate);
// C. Rechnungsnummer & Save Data
const savePayload = await getSaveData(
template,
tenant,
firstDate,
lastDate,
executionDate.toISOString(),
executedBy
);
const payloadWithRelation = {
...savePayload,
serialexecution: executionId
};
// D. Dokument in DB anlegen (Drizzle Insert)
const [createdDoc] = await server.db
.insert(schema.createddocuments)
.values(payloadWithRelation)
.returning(); // Wichtig für Postgres: returning() gibt das erstellte Objekt zurück
return createdDoc.id;
}
// --- Drizzle Helper ---
async function fetchById(server: FastifyInstance, table: any, id: number | null) {
if (!id) return null;
const [result] = await server.db.select().from(table).where(eq(table.id, id)).limit(1);
return result || null;
}
async function getFolderId(server:FastifyInstance, tenantId: number) {
const [folder] = await server.db
.select({ id: schema.folders.id })
.from(schema.folders)
.where(
and(
eq(schema.folders.tenant, tenantId),
eq(schema.folders.function, "invoices"), // oder 'invoices', check deine DB
eq(schema.folders.year, dayjs().format("YYYY"))
)
)
.limit(1);
return folder?.id;
}
async function getFileTypeId(server: FastifyInstance,tenantId: number) {
const [tag] = await server.db
.select({ id: schema.filetags.id })
.from(schema.filetags)
.where(
and(
eq(schema.filetags.tenant, tenantId),
eq(schema.filetags.createdDocumentType, "invoices")
)
)
.limit(1);
return tag?.id;
}
// --- Logik Helper (Unverändert zur Business Logik) ---
function calculateDateRange(config: any, executionDate: dayjs.Dayjs) {
// Basis nehmen
let baseDate = executionDate;
let firstDate = baseDate;
let lastDate = baseDate;
if (config.intervall === "monatlich" && config.dateDirection === "Rückwirkend") {
// 1. Monat abziehen
// 2. Start/Ende des Monats berechnen
// 3. WICHTIG: Zeit hart auf 12:00:00 setzen, damit Zeitzonen das Datum nicht kippen
firstDate = baseDate.subtract(1, "month").startOf("month").hour(12).minute(0).second(0).millisecond(0);
lastDate = baseDate.subtract(1, "month").endOf("month").hour(12).minute(0).second(0).millisecond(0);
} else if (config.intervall === "vierteljährlich" && config.dateDirection === "Rückwirkend") {
firstDate = baseDate.subtract(1, "quarter").startOf("quarter").hour(12).minute(0).second(0).millisecond(0);
lastDate = baseDate.subtract(1, "quarter").endOf("quarter").hour(12).minute(0).second(0).millisecond(0);
}
// Das Ergebnis ist nun z.B.:
// firstDate: '2025-12-01T12:00:00.000Z' (Eindeutig der 1. Dezember)
// lastDate: '2025-12-31T12:00:00.000Z' (Eindeutig der 31. Dezember)
return {
firstDate: firstDate.toISOString(),
lastDate: lastDate.toISOString()
};
}
async function getSaveData(item: any, tenant: any, firstDate: string, lastDate: string, executionDate: string, executedBy: string) {
const cleanRows = item.rows.map((row: any) => ({
...row,
descriptionText: row.description || null,
}));
//const documentNumber = await this.useNextInvoicesNumber(item.tenant);
return {
tenant: item.tenant,
type: "invoices",
state: "Entwurf",
customer: item.customer,
contact: item.contact,
contract: item.contract,
address: item.address,
project: item.project,
documentDate: executionDate,
deliveryDate: firstDate,
deliveryDateEnd: lastDate,
paymentDays: item.paymentDays,
payment_type: item.payment_type,
deliveryDateType: item.deliveryDateType,
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
createdBy: item.createdBy,
created_by: item.created_by,
title: `Rechnung-Nr. XXX`,
description: item.description,
startText: item.startText,
endText: item.endText,
rows: cleanRows, // JSON Array
contactPerson: item.contactPerson,
linkedDocument: item.linkedDocument,
letterhead: item.letterhead,
taxType: item.taxType,
};
}
async function getCloseData(server:FastifyInstance,item: any, tenant: any, units, products,services) {
const documentNumber = await useNextNumberRangeNumber(server,tenant.id,"invoices");
console.log(item);
const [contact, customer, project, contract] = await Promise.all([
fetchById(server, schema.contacts, item.contact),
fetchById(server, schema.customers, item.customer),
fetchById(server, schema.projects, item.project),
fetchById(server, schema.contracts, item.contract),
item.letterhead ? fetchById(server, schema.letterheads, item.letterhead) : null
]);
const profile = (await server.db.select().from(schema.authProfiles).where(and(eq(schema.authProfiles.user_id, item.created_by),eq(schema.authProfiles.tenant_id,tenant.id))).limit(1))[0];
console.log(profile)
const pdfData = getDocumentDataBackend(
{
...item,
state: "Gebucht",
documentNumber: documentNumber.usedNumber,
title: `Rechnung-Nr. ${documentNumber.usedNumber}`,
}, // Das Dokument (mit neuer Nummer)
tenant, // Tenant Object
customer, // Customer Object
contact, // Contact Object (kann null sein)
profile, // User Profile (Contact Person)
project, // Project Object
contract, // Contract Object
units, // Units Array
products, // Products Array
services // Services Array
);
return pdfData;
}
// Formatiert Zahlen zu deutscher Währung
function renderCurrency(value: any, currency = "€") {
if (value === undefined || value === null) return "0,00 " + currency;
return Number(value).toFixed(2).replace(".", ",") + " " + currency;
}
// Berechnet den Zeilenpreis (Menge * Preis * Rabatt)
function getRowAmount(row: any) {
const price = Number(row.price || 0);
const quantity = Number(row.quantity || 0);
const discount = Number(row.discountPercent || 0);
return quantity * price * (1 - discount / 100);
}
// Berechnet alle Summen (Netto, Brutto, Steuern, Titel-Summen)
// Dies ersetzt 'documentTotal.value' aus dem Frontend
function calculateDocumentTotals(rows: any[], taxType: string) {
console.log(rows);
let totalNet = 0;
let totalNet19 = 0;
let totalNet7 = 0;
let totalNet0 = 0;
let titleSums: Record<string, number> = {};
// Aktueller Titel für Gruppierung
let currentTitle = "";
rows.forEach(row => {
if (row.mode === 'title') {
currentTitle = row.text || row.description || "Titel";
if (!titleSums[currentTitle]) titleSums[currentTitle] = 0;
return;
}
if (['normal', 'service', 'free'].includes(row.mode)) {
const amount = getRowAmount(row);
totalNet += amount;
// Summen pro Titel addieren
//if (!titleSums[currentTitle]) titleSums[currentTitle] = 0;
if(currentTitle.length > 0) titleSums[currentTitle] += amount;
// Steuer-Logik
const tax = taxType === "19 UStG" || taxType === "13b UStG" ? 0 : Number(row.taxPercent);
if (tax === 19) totalNet19 += amount;
else if (tax === 7) totalNet7 += amount;
else totalNet0 += amount;
}
});
const isTaxFree = ["13b UStG", "19 UStG"].includes(taxType);
const tax19 = isTaxFree ? 0 : totalNet19 * 0.19;
const tax7 = isTaxFree ? 0 : totalNet7 * 0.07;
const totalGross = totalNet + tax19 + tax7;
return {
totalNet,
totalNet19,
totalNet7,
totalNet0,
total19: tax19,
total7: tax7,
total0: 0,
totalGross,
titleSums // Gibt ein Objekt zurück: { "Titel A": 150.00, "Titel B": 200.00 }
};
}
export function getDocumentDataBackend(
itemInfo: any, // Das Dokument objekt (createddocument)
tenant: any, // Tenant Infos (auth.activeTenantData)
customerData: any, // Geladener Kunde
contactData: any, // Geladener Kontakt (optional)
contactPerson: any, // Geladenes User-Profil (ersetzt den API Call)
projectData: any, // Projekt
contractData: any, // Vertrag
units: any[], // Array aller Einheiten
products: any[], // Array aller Produkte
services: any[] // Array aller Services
) {
const businessInfo = tenant.businessInfo || {}; // Fallback falls leer
// --- 1. Agriculture Logic ---
// Prüfen ob 'extraModules' existiert, sonst leeres Array annehmen
const modules = tenant.extraModules || [];
if (modules.includes("agriculture")) {
itemInfo.rows.forEach((row: any) => {
if (row.agriculture && row.agriculture.dieselUsage) {
row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}`;
}
});
}
// --- 2. Tax Override Logic ---
let rows = JSON.parse(JSON.stringify(itemInfo.rows)); // Deep Clone um Original nicht zu mutieren
if (itemInfo.taxType === "13b UStG" || itemInfo.taxType === "19 UStG") {
rows = rows.map((row: any) => ({ ...row, taxPercent: 0 }));
}
// --- 4. Berechnungen (Ersetzt Vue computed props) ---
const totals = calculateDocumentTotals(rows, itemInfo.taxType);
console.log(totals);
// --- 3. Rows Mapping & Processing ---
rows = rows.map((row: any) => {
const unit = units.find(i => i.id === row.unit) || { short: "" };
// Description Text Logic
if (!['pagebreak', 'title'].includes(row.mode)) {
if (row.agriculture && row.agriculture.description) {
row.descriptionText = row.agriculture.description;
} else if (row.description) {
row.descriptionText = row.description;
} else {
delete row.descriptionText;
}
}
// Product/Service Name Resolution
if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
if (row.mode === 'normal') {
const prod = products.find(i => i.id === row.product);
if (prod) row.text = prod.name;
}
if (row.mode === 'service') {
const serv = services.find(i => i.id === row.service);
if (serv) row.text = serv.name;
}
const rowAmount = getRowAmount(row);
return {
...row,
rowAmount: renderCurrency(rowAmount),
quantity: String(row.quantity).replace(".", ","),
unit: unit.short,
pos: String(row.pos),
price: renderCurrency(row.price),
discountText: row.discountPercent > 0 ? `(Rabatt: ${row.discountPercent} %)` : ""
};
} else {
return row;
}
});
// --- 5. Handlebars Context ---
const generateContext = () => {
return {
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
anrede: (contactData && contactData.salutation) || (customerData && customerData.salutation),
titel: (contactData && contactData.title) || (customerData && customerData.title),
vorname: (contactData && contactData.firstName) || (customerData && customerData.firstname), // Achte auf casing (firstName vs firstname) in deiner DB
nachname: (contactData && contactData.lastName) || (customerData && customerData.lastname),
kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null
};
};
const templateStartText = Handlebars.compile(itemInfo.startText || "");
const templateEndText = Handlebars.compile(itemInfo.endText || "");
// --- 6. Title Sums Formatting ---
let returnTitleSums: Record<string, string> = {};
Object.keys(totals.titleSums).forEach(key => {
returnTitleSums[key] = renderCurrency(totals.titleSums[key]);
});
// Transfer logic (Falls nötig, hier vereinfacht)
let returnTitleSumsTransfer = { ...returnTitleSums };
// --- 7. Construct Final Object ---
// Adresse aufbereiten
const recipientArray = [
customerData.name,
...(customerData.nameAddition ? [customerData.nameAddition] : []),
...(contactData ? [`${contactData.firstName} ${contactData.lastName}`] : []),
itemInfo.address?.street || customerData.street || "",
...(itemInfo.address?.special ? [itemInfo.address.special] : []),
`${itemInfo.address?.zip || customerData.zip} ${itemInfo.address?.city || customerData.city}`,
].filter(Boolean); // Leere Einträge entfernen
console.log(contactPerson)
// Info Block aufbereiten
const infoBlock = [
{
label: itemInfo.documentNumberTitle || "Rechnungsnummer",
content: itemInfo.documentNumber || "ENTWURF",
}, {
label: "Kundennummer",
content: customerData.customerNumber,
}, {
label: "Belegdatum",
content: itemInfo.documentDate ? dayjs(itemInfo.documentDate).format("DD.MM.YYYY") : dayjs().format("DD.MM.YYYY"),
},
// Lieferdatum Logik
...(itemInfo.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{
label: itemInfo.deliveryDateType || "Lieferdatum",
content: !['Lieferzeitraum', 'Leistungszeitraum'].includes(itemInfo.deliveryDateType)
? (itemInfo.deliveryDate ? dayjs(itemInfo.deliveryDate).format("DD.MM.YYYY") : "")
: `${itemInfo.deliveryDate ? dayjs(itemInfo.deliveryDate).format("DD.MM.YYYY") : ""} - ${itemInfo.deliveryDateEnd ? dayjs(itemInfo.deliveryDateEnd).format("DD.MM.YYYY") : ""}`,
}] : []),
{
label: "Ansprechpartner",
content: contactPerson ? (contactPerson.name || contactPerson.full_name || contactPerson.email) : "-",
},
// Kontakt Infos
...((itemInfo.contactTel || contactPerson?.fixed_tel || contactPerson?.mobile_tel) ? [{
label: "Telefon",
content: itemInfo.contactTel || contactPerson?.fixed_tel || contactPerson?.mobile_tel,
}] : []),
...(contactPerson?.email ? [{
label: "E-Mail",
content: contactPerson.email,
}] : []),
// Objekt / Projekt / Vertrag
...(itemInfo.plant ? [{ label: "Objekt", content: "Objekt Name" }] : []), // Hier müsstest du Plant Data übergeben wenn nötig
...(projectData ? [{ label: "Projekt", content: projectData.name }] : []),
...(contractData ? [{ label: "Vertrag", content: contractData.contractNumber }] : [])
];
// Total Array für PDF Footer
const totalArray = [
{
label: "Nettobetrag",
content: renderCurrency(totals.totalNet),
},
...(totals.totalNet19 > 0 && !["13b UStG"].includes(itemInfo.taxType) ? [{
label: `zzgl. 19% USt auf ${renderCurrency(totals.totalNet19)}`,
content: renderCurrency(totals.total19),
}] : []),
...(totals.totalNet7 > 0 && !["13b UStG"].includes(itemInfo.taxType) ? [{
label: `zzgl. 7% USt auf ${renderCurrency(totals.totalNet7)}`,
content: renderCurrency(totals.total7),
}] : []),
{
label: "Gesamtbetrag",
content: renderCurrency(totals.totalGross),
},
];
return {
...itemInfo,
type: itemInfo.type,
taxType: itemInfo.taxType,
adressLine: `${businessInfo.name || ''}, ${businessInfo.street || ''}, ${businessInfo.zip || ''} ${businessInfo.city || ''}`,
recipient: recipientArray,
info: infoBlock,
title: itemInfo.title,
description: itemInfo.description,
// Handlebars Compilation ausführen
endText: templateEndText(generateContext()),
startText: templateStartText(generateContext()),
rows: rows,
totalArray: totalArray,
total: {
totalNet: renderCurrency(totals.totalNet),
total19: renderCurrency(totals.total19),
total0: renderCurrency(totals.total0), // 0% USt Zeilen
totalGross: renderCurrency(totals.totalGross),
// Diese Werte existieren im einfachen Backend-Kontext oft nicht (Zahlungen checken), daher 0 oder Logik bauen
totalGrossAlreadyPaid: renderCurrency(0),
totalSumToPay: renderCurrency(totals.totalGross),
titleSums: returnTitleSums,
titleSumsTransfer: returnTitleSumsTransfer
},
agriculture: itemInfo.agriculture,
// Falls du AdvanceInvoices brauchst, musst du die Objekte hier übergeben oder leer lassen
usedAdvanceInvoices: []
};
}

View File

@@ -0,0 +1,229 @@
// src/services/buildTimeEvaluationFromSpans.ts
import { FastifyInstance } from "fastify";
import { and, eq, gte, lte, inArray } from "drizzle-orm";
import { authProfiles, holidays } from "../../../db/schema";
import { DerivedSpan } from "./derivetimespans.service"; // Importiert den angereicherten Span-Typ
// Definiert das erwartete Rückgabeformat
export type TimeEvaluationResult = {
user_id: string;
tenant_id: number;
from: string;
to: string;
// Sollzeit
timeSpanWorkingMinutes: number;
// Arbeitszeit Salden
sumWorkingMinutesSubmitted: number;
sumWorkingMinutesApproved: number;
// Abwesenheiten (minuten und Tage)
sumWorkingMinutesRecreationDays: number;
sumRecreationDays: number;
sumWorkingMinutesVacationDays: number;
sumVacationDays: number;
sumWorkingMinutesSickDays: number;
sumSickDays: number;
// Endsalden
saldoApproved: number; // Saldo basierend auf genehmigter Zeit
saldoSubmitted: number; // Saldo basierend auf eingereichter/genehmigter Zeit
spans: DerivedSpan[];
};
// Hilfsfunktion zur Berechnung der Minuten (nur für geschlossene Spannen)
const calcMinutes = (start: Date, end: Date | null): number => {
if (!end) return 0;
return (end.getTime() - start.getTime()) / 60000;
};
export async function buildTimeEvaluationFromSpans(
server: FastifyInstance,
user_id: string,
tenant_id: number,
startDateInput: string,
endDateInput: string,
// Der wichtigste Unterschied: Wir nehmen die angereicherten Spannen als Input
spans: DerivedSpan[]
): Promise<TimeEvaluationResult> {
const startDate = server.dayjs(startDateInput);
const endDate = server.dayjs(endDateInput);
// -------------------------------------------------------------
// 1⃣ Profil und Feiertage laden (WIE IM ALTEN SERVICE)
// -------------------------------------------------------------
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, user_id),
eq(authProfiles.tenant_id, tenant_id)
)
)
.limit(1);
const profile = profileRows[0];
if (!profile) throw new Error("Profil konnte nicht geladen werden.");
const holidaysRows = await server.db
.select({
date: holidays.date,
})
.from(holidays)
.where(
and(
inArray(holidays.state_code, [profile.state_code, "DE"]),
gte(holidays.date, startDate.format("YYYY-MM-DD")),
lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD"))
)
);
// -------------------------------------------------------------
// 2⃣ Sollzeit berechnen (WIE IM ALTEN SERVICE)
// -------------------------------------------------------------
let timeSpanWorkingMinutes = 0;
const totalDays = endDate.add(1, "day").diff(startDate, "days");
for (let i = 0; i < totalDays; i++) {
const date = startDate.add(i, "days");
const weekday = date.day();
timeSpanWorkingMinutes +=
(profile.weekly_regular_working_hours?.[weekday] || 0) * 60;
}
// -------------------------------------------------------------
// 3⃣ Arbeits- und Abwesenheitszeiten berechnen (NEUE LOGIK)
// -------------------------------------------------------------
let sumWorkingMinutesSubmitted = 0;
let sumWorkingMinutesApproved = 0;
let sumWorkingMinutesVacationDays = 0;
let sumVacationDays = 0;
let sumWorkingMinutesSickDays = 0;
let sumSickDays = 0;
// Akkumulieren der Zeiten basierend auf dem abgeleiteten Typ und Status
for (const span of spans) {
// **A. Arbeitszeiten (WORK)**
if (span.type === "work") {
const minutes = calcMinutes(span.startedAt, span.endedAt);
// Zähle zur eingereichten Summe, wenn der Status submitted oder approved ist
if (span.status === "submitted" || span.status === "approved") {
sumWorkingMinutesSubmitted += minutes;
}
// Zähle zur genehmigten Summe, wenn der Status approved ist
if (span.status === "approved") {
sumWorkingMinutesApproved += minutes;
}
}
// **B. Abwesenheiten (VACATION, SICK)**
// Wir verwenden die Logik aus dem alten Service: Berechnung der Sollzeit
// basierend auf den Tagen der Span (Voraussetzung: Spannen sind Volltages-Spannen)
if (span.type === "vacation" || span.type === "sick") {
// Behandle nur genehmigte Abwesenheiten für die Saldenberechnung
if (span.status !== "approved") {
continue;
}
const startDay = server.dayjs(span.startedAt).startOf('day');
// Wenn endedAt null ist (offene Span), nehmen wir das Ende des Zeitraums
const endDay = span.endedAt ? server.dayjs(span.endedAt).startOf('day') : endDate.startOf('day');
// Berechnung der Tage der Span
const days = endDay.diff(startDay, "day") + 1;
for (let i = 0; i < days; i++) {
const day = startDay.add(i, "day");
const weekday = day.day();
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
if (span.type === "vacation") {
sumWorkingMinutesVacationDays += hours * 60;
} else if (span.type === "sick") {
sumWorkingMinutesSickDays += hours * 60;
}
}
if (span.type === "vacation") {
sumVacationDays += days;
} else if (span.type === "sick") {
sumSickDays += days;
}
}
// PAUSE Spannen werden ignoriert, da sie in der faktischen Ableitung bereits von WORK abgezogen wurden.
}
// -------------------------------------------------------------
// 4⃣ Feiertagsausgleich (WIE IM ALTEN SERVICE)
// -------------------------------------------------------------
let sumWorkingMinutesRecreationDays = 0;
let sumRecreationDays = 0;
if (profile.recreation_days_compensation && holidaysRows?.length) {
holidaysRows.forEach(({ date }) => {
const weekday = server.dayjs(date).day();
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
sumWorkingMinutesRecreationDays += hours * 60;
sumRecreationDays++;
});
}
// -------------------------------------------------------------
// 5⃣ Salden berechnen (NEUE LOGIK)
// -------------------------------------------------------------
const totalCompensatedMinutes =
sumWorkingMinutesRecreationDays +
sumWorkingMinutesVacationDays +
sumWorkingMinutesSickDays;
// Saldo basierend auf GENEHMIGTER Arbeitszeit
const totalApprovedMinutes = sumWorkingMinutesApproved + totalCompensatedMinutes;
const saldoApproved = totalApprovedMinutes - timeSpanWorkingMinutes;
// Saldo basierend auf EINGEREICHTER und GENEHMIGTER Arbeitszeit
const totalSubmittedMinutes = sumWorkingMinutesSubmitted + totalCompensatedMinutes;
const saldoSubmitted = totalSubmittedMinutes - timeSpanWorkingMinutes;
// -------------------------------------------------------------
// 6⃣ Rückgabe
// -------------------------------------------------------------
return {
user_id,
tenant_id,
from: startDate.format("YYYY-MM-DD"),
to: endDate.format("YYYY-MM-DD"),
timeSpanWorkingMinutes,
sumWorkingMinutesSubmitted,
sumWorkingMinutesApproved,
sumWorkingMinutesRecreationDays,
sumRecreationDays,
sumWorkingMinutesVacationDays,
sumVacationDays,
sumWorkingMinutesSickDays,
sumSickDays,
saldoApproved,
saldoSubmitted,
spans,
};
}

View File

@@ -0,0 +1,165 @@
type State = "IDLE" | "WORKING" | "PAUSED" | "ABSENT";
export type SpanStatus = "factual" | "submitted" | "approved" | "rejected";
export type DerivedSpan = {
type: "work" | "pause" | "vacation" | "sick" | "overtime_compensation";
startedAt: Date;
endedAt: Date | null;
sourceEventIds: string[];
status: SpanStatus;
statusActorId?: string;
};
type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
};
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
const FACTUAL_EVENT_TYPES = new Set([
"work_start",
"pause_start",
"pause_end",
"work_end",
"auto_stop", // Wird als work_end behandelt
"vacation_start",
"vacation_end",
"sick_start",
"sick_end",
"overtime_compensation_start",
"overtime_compensation_end",
]);
export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
// 1. FILTERN: Nur faktische Events verarbeiten
const events = allValidEvents.filter(event =>
FACTUAL_EVENT_TYPES.has(event.eventtype)
);
const spans: DerivedSpan[] = [];
let state: State = "IDLE";
let currentStart: Date | null = null;
let currentType: DerivedSpan["type"] | null = null;
let sourceEventIds: string[] = [];
const closeSpan = (end: Date) => {
if (!currentStart || !currentType) return;
spans.push({
type: currentType,
startedAt: currentStart,
endedAt: end,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
});
currentStart = null;
currentType = null;
sourceEventIds = [];
};
const closeOpenSpanAsRunning = () => {
if (!currentStart || !currentType) return;
spans.push({
type: currentType,
startedAt: currentStart,
endedAt: null,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
});
currentStart = null;
currentType = null;
sourceEventIds = [];
};
for (const event of events) {
sourceEventIds.push(event.id);
switch (event.eventtype) {
/* =========================
ARBEITSZEIT
========================= */
case "work_start":
if (state === "WORKING" || state === "PAUSED" || state === "ABSENT") {
// Schließt die vorherige Spanne (falls z.B. work_start nach sick_start kommt)
closeSpan(event.eventtime);
}
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
break;
case "pause_start":
if (state === "WORKING") {
closeSpan(event.eventtime);
state = "PAUSED";
currentStart = event.eventtime;
currentType = "pause";
}
break;
case "pause_end":
if (state === "PAUSED") {
closeSpan(event.eventtime);
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
}
break;
case "work_end":
case "auto_stop":
if (state === "WORKING" || state === "PAUSED") {
closeSpan(event.eventtime);
}
state = "IDLE";
break;
/* =========================
ABWESENHEITEN
========================= */
case "vacation_start":
case "sick_start":
case "overtime_compensation_start":
// Mappt den Event-Typ direkt auf den Span-Typ
const newType = event.eventtype.split('_')[0] as DerivedSpan["type"];
if (state !== "IDLE") {
closeSpan(event.eventtime);
}
state = "ABSENT";
currentStart = event.eventtime;
currentType = newType;
break;
case "vacation_end":
case "sick_end":
case "overtime_compensation_end":
// Extrahiert den Typ der zu beendenden Spanne
const endedType = event.eventtype.split('_')[0] as DerivedSpan["type"];
if (state === "ABSENT" && currentType === endedType) {
closeSpan(event.eventtime);
}
state = "IDLE";
break;
}
}
// 🔴 WICHTIG: Offene Spannen als laufend zurückgeben
if (state !== "IDLE") {
closeOpenSpanAsRunning();
}
return spans;
}

View File

@@ -0,0 +1,91 @@
// src/services/enrichSpansWithStatus.ts (Korrigierte Version)
import { DerivedSpan, SpanStatus } from "./derivetimespans.service";
import { TimeEvent } from "./loadvalidevents.service"; // Jetzt mit related_event_id und actoruser_id
// ... (Rest der Imports)
export function enrichSpansWithStatus(
factualSpans: DerivedSpan[],
allValidEvents: TimeEvent[]
): DerivedSpan[] {
// 1. Map der administrativen Aktionen erstellen
const eventStatusMap = new Map<string, { status: SpanStatus, actorId: string }>();
const administrativeEvents = allValidEvents.filter(e =>
e.eventtype === 'submitted' || e.eventtype === 'approved' || e.eventtype === 'rejected'
);
// allValidEvents ist nach Zeit sortiert
for (const event of administrativeEvents) {
// **Verwendung des expliziten Feldes**
const relatedId = event.related_event_id;
const actorId = event.actoruser_id;
if (relatedId) { // Nur fortfahren, wenn ein Bezugs-Event existiert
let status: SpanStatus = "factual";
// Wir überschreiben den Status des relatedId basierend auf der Event-Historie
if (event.eventtype === 'submitted') status = 'submitted';
else if (event.eventtype === 'approved') status = 'approved';
else if (event.eventtype === 'rejected') status = 'rejected';
eventStatusMap.set(relatedId, { status, actorId });
}
}
// 2. Status der Spannen bestimmen und anreichern
return factualSpans.map(span => {
let approvedCount = 0;
let rejectedCount = 0;
let submittedCount = 0;
let isFactualCount = 0;
for (const sourceId of span.sourceEventIds) {
const statusInfo = eventStatusMap.get(sourceId);
if (statusInfo) {
// Ein faktisches Event kann durch mehrere administrative Events betroffen sein
// Wir speichern im Map nur den letzten Status (z.B. approved überschreibt submitted)
if (statusInfo.status === 'approved') approvedCount++;
else if (statusInfo.status === 'rejected') rejectedCount++;
else if (statusInfo.status === 'submitted') submittedCount++;
} else {
// Wenn kein administratives Event existiert
isFactualCount++;
}
}
// Regel zur Bestimmung des Span-Status:
const totalSourceEvents = span.sourceEventIds.length;
let finalStatus: SpanStatus = "factual";
if (totalSourceEvents > 0) {
// Priorität 1: Rejection
if (rejectedCount > 0) {
finalStatus = "rejected";
}
// Priorität 2: Full Approval
else if (approvedCount === totalSourceEvents) {
finalStatus = "approved";
}
// Priorität 3: Submitted (wenn nicht fully approved oder rejected, aber mindestens eines submitted ist)
else if (submittedCount > 0 || approvedCount > 0) {
finalStatus = "submitted";
// Ein Span ist submitted, wenn es zumindest teilweise eingereicht (oder genehmigt) ist,
// aber nicht alle Events den finalen Status "approved" haben.
}
// Ansonsten bleibt es "factual" (wenn z.B. nur work_start aber nicht work_end eingereicht wurde, oder nichts)
}
// Rückgabe der angereicherten Span
return {
...span,
status: finalStatus,
};
});
}

View File

@@ -0,0 +1,232 @@
import { FastifyInstance } from "fastify";
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
import {
authProfiles,
stafftimeentries,
holidays,
} from "../../../db/schema";
export async function generateTimesEvaluation(
server: FastifyInstance,
user_id: string,
tenant_id: number,
startDateInput: string,
endDateInput: string
) {
const startDate = server.dayjs(startDateInput);
const endDate = server.dayjs(endDateInput);
console.log(startDate.format("YYYY-MM-DD HH:mm:ss"));
console.log(endDate.format("YYYY-MM-DD HH:mm:ss"));
// -------------------------------------------------------------
// 1⃣ Profil laden
// -------------------------------------------------------------
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, user_id),
eq(authProfiles.tenant_id, tenant_id)
)
)
.limit(1);
const profile = profileRows[0];
if (!profile) throw new Error("Profil konnte nicht geladen werden.");
// -------------------------------------------------------------
// 2⃣ Arbeitszeiten laden
// -------------------------------------------------------------
const timesRaw = await server.db
.select()
.from(stafftimeentries)
.where(
and(
eq(stafftimeentries.tenant_id, tenant_id),
eq(stafftimeentries.user_id, user_id)
)
)
.orderBy(asc(stafftimeentries.started_at));
const isBetween = (spanStartDate, spanEndDate, startDate, endDate) => {
return (
server
.dayjs(startDate)
.isBetween(spanStartDate, spanEndDate, "day", "[]") &&
server
.dayjs(endDate)
.isBetween(spanStartDate, spanEndDate, "day", "[]")
);
};
const times = timesRaw.filter((i) =>
isBetween(startDate, endDate, i.started_at, i.stopped_at)
);
console.log(times);
// -------------------------------------------------------------
// 3⃣ Feiertage laden
// -------------------------------------------------------------
const holidaysRows = await server.db
.select({
date: holidays.date,
})
.from(holidays)
.where(
and(
inArray(holidays.state_code, [profile.state_code, "DE"]),
gte(holidays.date, startDate.format("YYYY-MM-DD")),
lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD"))
)
);
// -------------------------------------------------------------
// 4⃣ Sollzeit berechnen
// -------------------------------------------------------------
let timeSpanWorkingMinutes = 0;
const totalDays = endDate.add(1, "day").diff(startDate, "days");
for (let i = 0; i < totalDays; i++) {
const date = startDate.add(i, "days");
const weekday = date.day();
timeSpanWorkingMinutes +=
(profile.weekly_regular_working_hours?.[weekday] || 0) * 60;
}
// -------------------------------------------------------------
// 5⃣ Eingereicht/genehmigt
// -------------------------------------------------------------
const calcMinutes = (start: string, end: string | null) =>
server.dayjs(end || new Date()).diff(server.dayjs(start), "minutes");
let sumWorkingMinutesEingereicht = 0;
let sumWorkingMinutesApproved = 0;
for (const t of times) {
// @ts-ignore
const minutes = calcMinutes(t.started_at, t.stopped_at);
if (["submitted", "approved"].includes(t.state) && t.type === "work") {
sumWorkingMinutesEingereicht += minutes;
}
if (t.state === "approved" && t.type === "work") {
sumWorkingMinutesApproved += minutes;
}
}
// -------------------------------------------------------------
// 6⃣ Feiertagsausgleich
// -------------------------------------------------------------
let sumWorkingMinutesRecreationDays = 0;
let sumRecreationDays = 0;
if (profile.recreation_days_compensation && holidaysRows?.length) {
holidaysRows.forEach(({ date }) => {
const weekday = server.dayjs(date).day();
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
sumWorkingMinutesRecreationDays += hours * 60;
sumRecreationDays++;
});
}
// -------------------------------------------------------------
// 7⃣ Urlaub
// -------------------------------------------------------------
let sumWorkingMinutesVacationDays = 0;
let sumVacationDays = 0;
times
.filter((t) => t.type === "vacation" && t.state === "approved")
.forEach((time) => {
// Tippfehler aus Original: startet_at vs started_at → NICHT korrigiert
const days =
server.dayjs(time.stopped_at).diff(
//@ts-ignore
server.dayjs(time.startet_at),
"day"
) + 1;
for (let i = 0; i < days; i++) {
const weekday = server
.dayjs(time.started_at)
.add(i, "day")
.day();
const hours =
profile.weekly_regular_working_hours?.[weekday] || 0;
sumWorkingMinutesVacationDays += hours * 60;
}
sumVacationDays += days;
});
// -------------------------------------------------------------
// 8⃣ Krankheit
// -------------------------------------------------------------
let sumWorkingMinutesSickDays = 0;
let sumSickDays = 0;
times
.filter((t) => t.type === "sick" && t.state === "approved")
.forEach((time) => {
const days =
server.dayjs(time.stopped_at).diff(
//@ts-ignore
server.dayjs(time.startet_at),
"day"
) + 1;
for (let i = 0; i < days; i++) {
const weekday = server
.dayjs(time.started_at)
.add(i, "day")
.day();
const hours =
profile.weekly_regular_working_hours?.[weekday] || 0;
sumWorkingMinutesSickDays += hours * 60;
}
sumSickDays += days;
});
// -------------------------------------------------------------
// 9⃣ Salden
// -------------------------------------------------------------
const saldo =
sumWorkingMinutesApproved +
sumWorkingMinutesRecreationDays +
sumWorkingMinutesVacationDays +
sumWorkingMinutesSickDays -
timeSpanWorkingMinutes;
const saldoInOfficial =
sumWorkingMinutesEingereicht +
sumWorkingMinutesRecreationDays +
sumWorkingMinutesVacationDays +
sumWorkingMinutesSickDays -
timeSpanWorkingMinutes;
// -------------------------------------------------------------
// 🔟 Rückgabe identisch
// -------------------------------------------------------------
return {
user_id,
tenant_id,
from: startDate.format("YYYY-MM-DD"),
to: endDate.format("YYYY-MM-DD"),
timeSpanWorkingMinutes,
sumWorkingMinutesEingereicht,
sumWorkingMinutesApproved,
sumWorkingMinutesRecreationDays,
sumRecreationDays,
sumWorkingMinutesVacationDays,
sumVacationDays,
sumWorkingMinutesSickDays,
sumSickDays,
saldo,
saldoInOfficial,
times,
};
}

View File

@@ -0,0 +1,105 @@
// src/services/loadValidEvents.ts
import { stafftimeevents } from "../../../db/schema";
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
import { FastifyInstance } from "fastify";
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
// Die Definition des TimeEvent Typs, der zurückgegeben wird (muss mit dem tatsächlichen Typ übereinstimmen)
export type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
actoruser_id: string;
related_event_id: string | null;
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
};
export async function loadValidEvents(
server: FastifyInstance,
tenantId: number,
userId: string,
from: Date,
to: Date
): Promise<TimeEvent[]> {
// Definieren Sie einen Alias für die stafftimeevents Tabelle in der äußeren Abfrage
const baseEvents = stafftimeevents;
// Die Subquery, um alle IDs zu finden, die ungültig gemacht wurden
// Wir nennen die innere Tabelle 'invalidatingEvents'
const invalidatingEvents = server.db
.select({
invalidatedId: baseEvents.invalidates_event_id
})
.from(baseEvents)
.as('invalidating_events');
// Die Hauptabfrage
const result = await server.db
.select()
.from(baseEvents)
.where(
and(
// 1. Tenant und User filtern
eq(baseEvents.tenant_id, tenantId),
eq(baseEvents.user_id, userId),
// 2. Zeitbereich filtern (Typensicher)
gte(baseEvents.eventtime, from),
lte(baseEvents.eventtime, to),
// 3. WICHTIG: Korrekturen ausschließen (NOT EXISTS)
// Schließe jedes Event aus, dessen ID in der Liste der invalidates_event_id erscheint.
sql`
not exists (
select 1
from ${stafftimeevents} i
where i.invalidates_event_id = ${baseEvents.id}
)
`
)
)
.orderBy(
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
baseEvents.eventtime,
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
baseEvents.id
);
// Mapping auf den sauberen TimeEvent Typ
return result.map(e => ({
id: e.id,
eventtype: e.eventtype,
eventtime: e.eventtime,
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
// ...
})) as TimeEvent[];
}
export async function loadRelatedAdminEvents(server, eventIds) {
if (eventIds.length === 0) return [];
// Lädt alle administrativen Events, die sich auf die faktischen Event-IDs beziehen
const adminEvents = await server.db
.select()
.from(stafftimeevents)
.where(
and(
inArray(stafftimeevents.related_event_id, eventIds),
// Wir müssen hier die Entkräftung prüfen, um z.B. einen abgelehnten submitted-Event auszuschließen
sql`
not exists (
select 1
from ${stafftimeevents} i
where i.invalidates_event_id = ${stafftimeevents}.id
)
`,
)
)
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
.orderBy(stafftimeevents.eventtime);
return adminEvents;
}