Ermöglicht das Öffnen von Belegen aus der Liquiditätsprognose und das Abschließen erkannter regelmäßiger Bankbewegungen, die anschließend aus der Prognose herausgerechnet werden.
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
|
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
|
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
import { execFile } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import dayjs from "dayjs";
|
|
//import { ready as zplReady } from 'zpl-renderer-js'
|
|
//import { renderZPL } from "zpl-image";
|
|
|
|
import customParseFormat from "dayjs/plugin/customParseFormat.js";
|
|
import isoWeek from "dayjs/plugin/isoWeek.js";
|
|
import isBetween from "dayjs/plugin/isBetween.js";
|
|
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"
|
|
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
|
import duration from "dayjs/plugin/duration.js";
|
|
import timezone from "dayjs/plugin/timezone.js";
|
|
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
|
import {citys, files} from "../../db/schema";
|
|
import {and, eq, isNull, not} from "drizzle-orm";
|
|
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
|
import { s3 } from "../utils/s3";
|
|
import { secrets } from "../utils/secrets";
|
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
|
import { generateLiquidityForecast } from "../utils/liquidityForecast";
|
|
dayjs.extend(customParseFormat)
|
|
dayjs.extend(isoWeek)
|
|
dayjs.extend(isBetween)
|
|
dayjs.extend(isSameOrAfter)
|
|
dayjs.extend(isSameOrBefore)
|
|
dayjs.extend(duration)
|
|
dayjs.extend(timezone)
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
function resolveGitRoot() {
|
|
const searchRoots = [
|
|
process.cwd(),
|
|
path.resolve(process.cwd(), ".."),
|
|
path.resolve(__dirname, "../../.."),
|
|
path.resolve(__dirname, "../../../.."),
|
|
]
|
|
|
|
for (const startDir of searchRoots) {
|
|
let currentDir = startDir
|
|
|
|
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
|
if (existsSync(path.join(currentDir, ".git"))) {
|
|
return currentDir
|
|
}
|
|
|
|
currentDir = path.dirname(currentDir)
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function getDeploymentChangelogFallback() {
|
|
const backendPackagePath = path.resolve(process.cwd(), "package.json")
|
|
let version = "unbekannt"
|
|
|
|
if (existsSync(backendPackagePath)) {
|
|
try {
|
|
const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8"))
|
|
version = packageJson?.version || version
|
|
} catch (err) {
|
|
console.error("Could not read backend package.json for changelog fallback", err)
|
|
}
|
|
}
|
|
|
|
const commitHash =
|
|
process.env.RAILWAY_GIT_COMMIT_SHA ||
|
|
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
process.env.GITHUB_SHA ||
|
|
process.env.COMMIT_SHA ||
|
|
process.env.SOURCE_COMMIT ||
|
|
null
|
|
|
|
const committedAt =
|
|
process.env.BUILD_DATE ||
|
|
process.env.RENDER_GIT_COMMIT_DATE ||
|
|
process.env.VERCEL_GIT_COMMIT_DATE ||
|
|
new Date().toISOString()
|
|
|
|
return [{
|
|
hash: commitHash || `version-${version}`,
|
|
shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`,
|
|
subject: `Bereitgestellte Version ${version}`,
|
|
authorName: "Deployment",
|
|
committedAt
|
|
}]
|
|
}
|
|
|
|
export default async function functionRoutes(server: FastifyInstance) {
|
|
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
|
new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
stream.on("error", reject);
|
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
|
|
server.post("/functions/pdf/:type", async (req, reply) => {
|
|
const body = req.body as {
|
|
data: any
|
|
backgroundPath?: string
|
|
}
|
|
const {type} = req.params as {type:string}
|
|
|
|
|
|
try {
|
|
|
|
let pdf = null
|
|
|
|
if(type === "createdDocument") {
|
|
pdf = await createInvoicePDF(
|
|
server,
|
|
"base64",
|
|
body.data,
|
|
body.backgroundPath
|
|
)
|
|
} else if(type === "timesheet") {
|
|
pdf = await createTimeSheetPDF(
|
|
server,
|
|
"base64",
|
|
body.data,
|
|
body.backgroundPath
|
|
)
|
|
}
|
|
|
|
return pdf // Fastify wandelt automatisch in JSON
|
|
} catch (err) {
|
|
console.log(err)
|
|
reply.code(500).send({ error: "Failed to create PDF" })
|
|
}
|
|
})
|
|
|
|
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
|
const { numberrange } = req.params as { numberrange: string };
|
|
const tenant = (req as any).user.tenant_id
|
|
|
|
try {
|
|
const result = await useNextNumberRangeNumber(server,tenant, numberrange)
|
|
reply.send(result) // JSON automatisch
|
|
} catch (err) {
|
|
req.log.error(err)
|
|
reply.code(500).send({ error: "Failed to generate next number" })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* @route GET /functions/workingtimeevaluation/:user_id
|
|
* @query start_date=YYYY-MM-DD
|
|
* @query end_date=YYYY-MM-DD
|
|
*/
|
|
server.get("/functions/timeevaluation/:user_id", async (req, reply) => {
|
|
const { user_id } = req.params as { user_id: string }
|
|
const { start_date, end_date } = req.query as { start_date: string; end_date: string }
|
|
const { tenant_id } = req.user
|
|
|
|
// 🔒 Sicherheitscheck: andere User nur bei Berechtigung
|
|
if (user_id !== req.user.user_id && !req.hasPermission("staff.time.read_all")) {
|
|
return reply.code(403).send({ error: "Not allowed to view other users." })
|
|
}
|
|
|
|
try {
|
|
const result = await generateTimesEvaluation(server, user_id, tenant_id, start_date, end_date)
|
|
reply.send(result)
|
|
} catch (error) {
|
|
console.error(error)
|
|
reply.code(500).send({ error: error.message })
|
|
}
|
|
})
|
|
|
|
server.get('/functions/check-zip/:zip', async (req, reply) => {
|
|
const { zip } = req.params as { zip: string }
|
|
const normalizedZip = String(zip || "").replace(/\D/g, "")
|
|
|
|
if (normalizedZip.length !== 5) {
|
|
return reply.code(400).send({ error: 'ZIP must contain exactly 5 digits' })
|
|
}
|
|
|
|
try {
|
|
const data = await server.db
|
|
.select()
|
|
.from(citys)
|
|
.where(eq(citys.zip, Number(normalizedZip)))
|
|
|
|
|
|
if (!data.length) {
|
|
return reply.code(404).send({ error: 'ZIP not found' })
|
|
}
|
|
|
|
const city = data[0]
|
|
|
|
//districtMap
|
|
const bundeslaender = [
|
|
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
|
{ code: 'DE-BY', name: 'Bayern' },
|
|
{ code: 'DE-BE', name: 'Berlin' },
|
|
{ code: 'DE-BB', name: 'Brandenburg' },
|
|
{ code: 'DE-HB', name: 'Bremen' },
|
|
{ code: 'DE-HH', name: 'Hamburg' },
|
|
{ code: 'DE-HE', name: 'Hessen' },
|
|
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
|
|
{ code: 'DE-NI', name: 'Niedersachsen' },
|
|
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
|
|
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
|
|
{ code: 'DE-SL', name: 'Saarland' },
|
|
{ code: 'DE-SN', name: 'Sachsen' },
|
|
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
|
|
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
|
|
{ code: 'DE-TH', name: 'Thüringen' }
|
|
]
|
|
|
|
|
|
|
|
return reply.send({
|
|
...city,
|
|
state_code: bundeslaender.find(i => i.name === city.countryName)?.code || null
|
|
})
|
|
} catch (err) {
|
|
console.log(err)
|
|
return reply.code(500).send({ error: 'Internal server error' })
|
|
}
|
|
})
|
|
|
|
server.get('/functions/changelog', async (req, reply) => {
|
|
const { limit } = req.query as { limit?: string | number }
|
|
const parsedLimit = Number(limit)
|
|
const safeLimit = Number.isFinite(parsedLimit)
|
|
? Math.min(Math.max(parsedLimit, 1), 50)
|
|
: 15
|
|
|
|
const gitRoot = resolveGitRoot()
|
|
|
|
if (!gitRoot) {
|
|
return reply.send({
|
|
repositoryRoot: null,
|
|
source: 'deployment',
|
|
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
|
})
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync('git', [
|
|
'-C',
|
|
gitRoot,
|
|
'log',
|
|
`--max-count=${safeLimit}`,
|
|
'--date=iso-strict',
|
|
'--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e'
|
|
])
|
|
|
|
const entries = stdout
|
|
.split('\x1e')
|
|
.map(entry => entry.trim())
|
|
.filter(Boolean)
|
|
.map(entry => {
|
|
const [hash, shortHash, subject, authorName, committedAt] = entry.split('\x1f')
|
|
|
|
return {
|
|
hash,
|
|
shortHash,
|
|
subject,
|
|
authorName,
|
|
committedAt
|
|
}
|
|
})
|
|
|
|
return reply.send({
|
|
repositoryRoot: gitRoot,
|
|
source: 'git',
|
|
entries
|
|
})
|
|
} catch (err) {
|
|
req.log.error(err)
|
|
return reply.send({
|
|
repositoryRoot: gitRoot,
|
|
source: 'deployment',
|
|
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
|
})
|
|
}
|
|
})
|
|
|
|
server.post('/functions/serial/start', async (req, reply) => {
|
|
console.log(req.body)
|
|
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
|
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
|
|
})
|
|
|
|
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
|
const {execution_id} = req.params as { execution_id: string }
|
|
//@ts-ignore
|
|
await finishManualGeneration(server,execution_id)
|
|
})
|
|
|
|
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
|
await server.services.bankStatements.run(req.user.tenant_id);
|
|
})
|
|
|
|
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
|
|
|
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
|
})
|
|
|
|
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
|
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
|
|
const ignoredKeys = String(ignoredRecurringKeys || "")
|
|
.split(",")
|
|
.map((key) => key.trim())
|
|
.filter(Boolean)
|
|
|
|
try {
|
|
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
|
|
} catch (err) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
|
|
}
|
|
})
|
|
|
|
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
|
const tenantId = req.user.tenant_id
|
|
|
|
const pendingFiles = await server.db
|
|
.select()
|
|
.from(files)
|
|
.where(
|
|
and(
|
|
eq(files.tenant, tenantId),
|
|
eq(files.archived, false),
|
|
not(isNull(files.path)),
|
|
isNull(files.extractedText)
|
|
)
|
|
)
|
|
|
|
let processed = 0
|
|
let withText = 0
|
|
let errors = 0
|
|
|
|
for (const file of pendingFiles) {
|
|
try {
|
|
const response: any = await s3.send(new GetObjectCommand({
|
|
Bucket: secrets.S3_BUCKET,
|
|
Key: file.path!
|
|
}))
|
|
|
|
const fileBuffer = await streamToBuffer(response.Body)
|
|
const result = await storeExtractedTextForFile(
|
|
server,
|
|
file.id,
|
|
fileBuffer,
|
|
file.mimeType,
|
|
file.name || file.path?.split("/").pop()
|
|
)
|
|
|
|
processed += 1
|
|
if (result.text) withText += 1
|
|
} catch (err) {
|
|
errors += 1
|
|
server.log.error(`Failed to backfill extracted text for file ${file.id}`)
|
|
server.log.error(err)
|
|
}
|
|
}
|
|
|
|
return {
|
|
pending: pendingFiles.length,
|
|
processed,
|
|
withText,
|
|
errors
|
|
}
|
|
})
|
|
|
|
server.post('/functions/services/syncdokubox', async (req, reply) => {
|
|
|
|
await server.services.dokuboxSync.run()
|
|
})
|
|
|
|
server.post('/print/label', async (req, reply) => {
|
|
const { context, width = 584, height = 354 } = req.body as {context:any,width:number,height:number}
|
|
|
|
try {
|
|
const base64 = await generateLabel(context,width,height)
|
|
|
|
return {
|
|
encoded: await encodeBase64ToNiimbot(base64, 'top'),
|
|
base64: base64
|
|
}
|
|
} catch (err) {
|
|
console.error('[Label Render Error]', err)
|
|
return reply.code(500).send({ error: err.message || 'Failed to render label' })
|
|
}
|
|
})
|
|
|
|
}
|