Added Backend
This commit is contained in:
253
backend/src/modules/cron/bankstatementsync.service.ts
Normal file
253
backend/src/modules/cron/bankstatementsync.service.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
259
backend/src/modules/cron/dokuboximport.service.ts
Normal file
259
backend/src/modules/cron/dokuboximport.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
175
backend/src/modules/cron/prepareIncomingInvoices.ts
Normal file
175
backend/src/modules/cron/prepareIncomingInvoices.ts
Normal 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.")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
38
backend/src/modules/helpdesk/helpdesk.contact.service.ts
Normal file
38
backend/src/modules/helpdesk/helpdesk.contact.service.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
60
backend/src/modules/helpdesk/helpdesk.message.service.ts
Normal file
60
backend/src/modules/helpdesk/helpdesk.message.service.ts
Normal 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
|
||||
}
|
||||
148
backend/src/modules/notification.service.ts
Normal file
148
backend/src/modules/notification.service.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private nl2br(s: string) {
|
||||
return s.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
406
backend/src/modules/publiclinks.service.ts
Normal file
406
backend/src/modules/publiclinks.service.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
725
backend/src/modules/serialexecution.service.ts
Normal file
725
backend/src/modules/serialexecution.service.ts
Normal 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: []
|
||||
};
|
||||
}
|
||||
229
backend/src/modules/time/buildtimeevaluation.service.ts
Normal file
229
backend/src/modules/time/buildtimeevaluation.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
165
backend/src/modules/time/derivetimespans.service.ts
Normal file
165
backend/src/modules/time/derivetimespans.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
232
backend/src/modules/time/evaluation.service.ts
Normal file
232
backend/src/modules/time/evaluation.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
105
backend/src/modules/time/loadvalidevents.service.ts
Normal file
105
backend/src/modules/time/loadvalidevents.service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user