Open Changes
This commit is contained in:
1145
package-lock.json
generated
1145
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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
114
src/utils/export/sepa.ts
Normal 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}))
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user