Open Changes

This commit is contained in:
2025-12-05 11:49:33 +01:00
parent d6badafeb9
commit 407592680a
10 changed files with 1493 additions and 28 deletions

1145
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,12 +24,15 @@
"@fastify/swagger": "^9.5.1",
"@fastify/swagger-ui": "^5.2.3",
"@infisical/sdk": "^4.0.6",
"@mmote/niimbluelib": "^0.0.1-alpha.29",
"@prisma/client": "^6.15.0",
"@supabase/supabase-js": "^2.56.1",
"@zip.js/zip.js": "^2.7.73",
"archiver": "^7.0.1",
"axios": "^1.12.1",
"bcrypt": "^6.0.0",
"bwip-js": "^4.8.0",
"canvas": "^3.2.0",
"crypto": "^1.0.1",
"dayjs": "^1.11.18",
"fastify": "^5.5.0",
@@ -38,7 +41,11 @@
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.6",
"pdf-lib": "^1.17.1",
"xmlbuilder": "^15.1.1"
"pngjs": "^7.0.0",
"sharp": "^0.34.5",
"xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0",
"zpl-renderer-js": "^2.0.2"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",

View File

@@ -72,8 +72,8 @@ export async function generateTimesEvaluation(
for (const t of times) {
const minutes = calcMinutes(t.started_at, t.stopped_at)
if(["submitted","approved"].includes(t.state))sumWorkingMinutesEingereicht += minutes
if (t.state === "approved") sumWorkingMinutesApproved += minutes
if(["submitted","approved"].includes(t.state) && t.type === "work")sumWorkingMinutesEingereicht += minutes
if (t.state === "approved" && t.type === "work") sumWorkingMinutesApproved += minutes
}
// 🎉 Feiertagsausgleich
@@ -90,16 +90,37 @@ export async function generateTimesEvaluation(
}
// 🏖️ Urlaub & Krankheit (über Typ)
const sumWorkingMinutesVacationDays = times
.filter((t) => t.type === "vacation")
.reduce((sum, t) => sum + calcMinutes(t.started_at, t.stopped_at), 0)
let sumWorkingMinutesVacationDays = 0
let sumVacationDays = 0
times
.filter((t) => t.type === "vacation" && t.state === "approved")
.forEach((time) => {
const days = server.dayjs(time.stopped_at).diff(server.dayjs(time.startet_at), "day") + 1;
const sumWorkingMinutesSickDays = times
.filter((t) => t.type === "sick")
.reduce((sum, t) => sum + calcMinutes(t.started_at, t.stopped_at), 0)
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
})
const sumVacationDays = times.filter((t) => t.type === "vacation").length
const sumSickDays = times.filter((t) => t.type === "sick").length
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(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
})
// 💰 Salden
const saldo =

View File

@@ -6,7 +6,10 @@ export default fp(async (server: FastifyInstance) => {
await server.register(cors, {
origin: [
"http://localhost:3000", // dein Nuxt-Frontend
"http://localhost:3001", // dein Nuxt-Frontend
"http://127.0.0.1:3000", // dein Nuxt-Frontend
"http://192.168.1.227:3001", // dein Nuxt-Frontend
"http://192.168.1.227:3000", // dein Nuxt-Frontend
"https://beta.fedeo.de", // dein Nuxt-Frontend
"https://app.fedeo.de", // dein Nuxt-Frontend
"capacitor://localhost", // dein Nuxt-Frontend

View File

@@ -97,7 +97,7 @@ export default async function authRoutes(server: FastifyInstance) {
const { data, error } = await server.supabase
.from("auth_users")
.select("*, tenants!auth_tenant_users(*)")
.eq("email", body.email)
.eq("email", body.email.toLowerCase())
// @ts-ignore
user = (data || []).find(i => i.tenants.find(x => x.id === req.tenant.id))
@@ -130,7 +130,7 @@ export default async function authRoutes(server: FastifyInstance) {
return reply.code(401).send({ error: "Invalid credentials" });
} else {
const token = jwt.sign(
{ user_id: user.id, email: user.email, tenant_id: req.tenant?.id ? req.tenant.id : null },
{ user_id: user.id, email: user.email, tenant_id: null },
secrets.JWT_SECRET!,
{ expiresIn: "6h" }
);

View File

@@ -8,13 +8,15 @@ import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
import dayjs from "dayjs";
import {randomUUID} from "node:crypto";
import {secrets} from "../utils/secrets";
import {createSEPAExport} from "../utils/export/sepa";
const createExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
console.log(startDate,endDate,beraternr,mandantennr)
// 1) ZIP erzeugen
const buffer = await buildExportZip(server,req.user.tenant_id, startDate, endDate, beraternr, mandantennr)
console.log("ZIP created")
console.log(buffer)
// 2) Dateiname & Key festlegen
const fileKey = `${req.user.tenant_id}/exports/Export_${dayjs(startDate).format("YYYY-MM-DD")}_${dayjs(endDate).format("YYYY-MM-DD")}_${randomUUID()}.zip`
@@ -80,7 +82,27 @@ export default async function exportRoutes(server: FastifyInstance) {
setImmediate(async () => {
try {
await createExport(server,req,start_date,end_date,beraternr,mandantennr)
await createDatevExport(server,req,start_date,end_date,beraternr,mandantennr)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)
}
})
})
server.post("/exports/sepa", async (req, reply) => {
const { idsToExport } = req.body as {
idsToExport: Array<number>
}
reply.send({success:true})
setImmediate(async () => {
try {
await createSEPAExport(server, idsToExport, req.user.tenant_id)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)

View File

@@ -1,7 +1,9 @@
import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {useNextNumberRangeNumber} from "../utils/functions";
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
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";
@@ -48,8 +50,6 @@ export default async function functionRoutes(server: FastifyInstance) {
)
}
console.log(pdf)
return pdf // Fastify wandelt automatisch in JSON
} catch (err) {
console.log(err)
@@ -149,4 +149,43 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
server.post('/print/zpl/preview', async (req, reply) => {
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
console.log(widthMm,heightMm,dpmm)
if (!zpl) {
return reply.code(400).send({ error: 'Missing ZPL string' })
}
try {
// 1⃣ Renderer initialisieren
const { api } = await zplReady
// 2⃣ Rendern (liefert base64-encoded PNG)
const base64Png = await api.zplToBase64Async(zpl, widthMm, heightMm, dpmm)
return await encodeBase64ToNiimbot(base64Png, 'top')
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})
server.post('/print/label', async (req, reply) => {
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number}
try {
const base64 = await generateLabel(context,width,heigth)
return {
encoded: await encodeBase64ToNiimbot(base64, 'top'),
base64: base64
}
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})
}

View File

@@ -4,16 +4,24 @@ import { StaffTimeEntry } from '../../types/staff'
export default async function staffTimeRoutes(server: FastifyInstance) {
// ▶ Neue Zeit starten
server.post<{ Body: Pick<StaffTimeEntry, 'started_at' | 'stopped_at' | 'type' | 'description'> }>(
server.post(
'/staff/time',
async (req, reply) => {
const { started_at, stopped_at, type = 'work', description } = req.body
const { started_at, stopped_at, type = 'work', description, user_id } = req.body
const userId = req.user.user_id
const tenantId = req.user.tenant_id
let dataToInsert = {
tenant_id: tenantId,
user_id: user_id ? user_id : userId,
// @ts-ignore
...req.body
}
const { data, error } = await server.supabase
.from('staff_time_entries')
.insert([{ tenant_id: tenantId, user_id: userId, started_at, stopped_at, type, description }])
.insert([dataToInsert])
.select()
.maybeSingle()

114
src/utils/export/sepa.ts Normal file
View File

@@ -0,0 +1,114 @@
import xmlbuilder from "xmlbuilder";
import {randomUUID} from "node:crypto";
import dayjs from "dayjs";
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
const {data,error} = await server.supabase.from("createddocuments").select().eq("tenant", tenant_id).in("id", idsToExport)
const {data:tenantData,error:tenantError} = await server.supabase.from("tenants").select().eq("id", tenant_id).single()
console.log(tenantData)
console.log(tenantError)
console.log(data)
let transactions = []
let obj = {
Document: {
'@xmlns':"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
'CstmrDrctDbtInitn': {
'GrpHdr': {
'MsgId': randomUUID(),
'CredDtTm': dayjs().format("YYYY-MM-DDTHH:mm:ss"),
'NbOfTxs': transactions.length,
'CtrlSum': 0, // TODO: Total Sum
'InitgPty': {
'Nm': tenantData.name
}
},
'PmtInf': {
'PmtInfId': "", // TODO: Mandatsreferenz,
'PmtMtd': "DD",
'BtchBookg': "true", // TODO: BatchBooking,
'NbOfTxs': transactions.length,
'CtrlSum': 0, //TODO: Total Sum
'PmtTpInf': {
'SvcLvl': {
'Cd': "SEPA"
},
'LclInstrm': {
'Cd': "CORE" // Core für BASIS / B2B für Firmen
},
'SeqTp': "RCUR" // TODO: Unterscheidung Einmalig / Wiederkehrend
},
'ReqdColltnDt': dayjs().add(3, "days").format("YYYY-MM-DDTHH:mm:ss"),
'Cdtr': {
'Nm': tenantData.name
},
'CdtrAcct': {
'Id': {
'IBAN': "DE" // TODO: IBAN Gläubiger EINSETZEN
}
},
'CdtrAgt': {
'FinInstnId': {
'BIC': "BIC" // TODO: BIC des Gläubigers einsetzen
}
},
'ChrgBr': "SLEV",
'CdtrSchmeId': {
'Id': {
'PrvtId': {
'Othr': {
'Id': tenantData.creditorId,
'SchmeNm': {
'Prty': "SEPA"
}
}
}
}
},
//TODO ITERATE ALL INVOICES HERE
'DrctDbtTxInf': {
'PmtId': {
'EndToEndId': "" // TODO: EINDEUTIGE ReFERENZ wahrscheinlich RE Nummer
},
'InstdAmt': {
'@Ccy':"EUR",
'#text':100 //TODO: Rechnungssumme zwei NK mit Punkt
},
'DrctDbtTx': {
'MndtRltdInf': {
'MndtId': "", // TODO: Mandatsref,
'DtOfSgntr': "", //TODO: Unterschrieben am,
'AmdmntInd': "" //TODO: Mandat geändert
}
},
'DbtrAgt': {
'FinInstnId': {
'BIC': "", //TODO: BIC Debtor
}
},
'Dbtr': {
'Nm': "" // TODO NAME Debtor
},
'DbtrAcct': {
'Id': {
'IBAN': "DE" // TODO IBAN Debtor
}
},
'RmtInf': {
'Ustrd': "" //TODO Verwendungszweck Rechnungsnummer
}
}
}
}
}
}
let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true})
console.log(doc.end({pretty:true}))
}

View File

@@ -1,4 +1,11 @@
import {FastifyInstance} from "fastify";
import { PNG } from 'pngjs'
import { ready as zplReady } from 'zpl-renderer-js'
import { Utils } from '@mmote/niimbluelib'
import { createCanvas } from 'canvas'
import bwipjs from 'bwip-js'
import Sharp from 'sharp'
import fs from 'fs'
export const useNextNumberRangeNumber = async (server:FastifyInstance, tenantId:number,numberRange)=> {
const {data:tenant} = await server.supabase.from("tenants").select().eq("id",tenantId).single()
@@ -20,4 +27,117 @@ export const useNextNumberRangeNumber = async (server:FastifyInstance, tenantId:
usedNumber
}
}
}
export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
// 1⃣ PNG dekodieren
const buffer = Buffer.from(base64Png, 'base64')
const png = PNG.sync.read(buffer) // liefert {width, height, data: Uint8Array(RGBA)}
const { width, height, data } = png
console.log(width, height, data)
const cols = printDirection === 'left' ? height : width
const rows = printDirection === 'left' ? width : height
const rowsData = []
console.log(cols)
if (cols % 8 !== 0) throw new Error('Column count must be multiple of 8')
// 2⃣ Zeilenweise durchgehen und Bits bilden
for (let row = 0; row < rows; row++) {
let isVoid = true
let blackPixelsCount = 0
const rowData = new Uint8Array(cols / 8)
for (let colOct = 0; colOct < cols / 8; colOct++) {
let pixelsOctet = 0
for (let colBit = 0; colBit < 8; colBit++) {
const x = printDirection === 'left' ? row : colOct * 8 + colBit
const y = printDirection === 'left' ? height - 1 - (colOct * 8 + colBit) : row
const idx = (y * width + x) * 4
const lum = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]
const isBlack = lum < 128
if (isBlack) {
pixelsOctet |= 1 << (7 - colBit)
isVoid = false
blackPixelsCount++
}
}
rowData[colOct] = pixelsOctet
}
const newPart = {
dataType: isVoid ? 'void' : 'pixels',
rowNumber: row,
repeat: 1,
rowData: isVoid ? undefined : rowData,
blackPixelsCount,
}
if (rowsData.length === 0) {
rowsData.push(newPart)
} else {
const last = rowsData[rowsData.length - 1]
let same = newPart.dataType === last.dataType
if (same && newPart.dataType === 'pixels') {
same = Utils.u8ArraysEqual(newPart.rowData, last.rowData)
}
if (same) last.repeat++
else rowsData.push(newPart)
if (row % 200 === 199) {
rowsData.push({
dataType: 'check',
rowNumber: row,
repeat: 0,
rowData: undefined,
blackPixelsCount: 0,
})
}
}
}
return { cols, rows, rowsData }
}
export async function generateLabel(context,width,height) {
// Canvas für Hintergrund & Text
const canvas = createCanvas(width, height)
const ctx = canvas.getContext('2d')
// Hintergrund weiß
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, width, height)
// Überschrift
ctx.fillStyle = '#000000'
ctx.font = '32px Arial'
ctx.fillText(context.text, 20, 40)
// 3) DataMatrix
const dataMatrixPng = await bwipjs.toBuffer({
bcid: 'datamatrix',
text: context.datamatrix,
scale: 6,
})
// Basisbild aus Canvas
const base = await Sharp(canvas.toBuffer())
.png()
.toBuffer()
// Alles zusammen compositen
const final = await Sharp(base)
.composite([
{ input: dataMatrixPng, top: 60, left: 20 },
])
.png()
.toBuffer()
fs.writeFileSync('label.png', final)
// Optional: Base64 zurückgeben (z.B. für API)
const base64 = final.toString('base64')
return base64
}