Erstellen von Einzahlungsscheinen mit einem QR Code CH welches eine bestehende Rechnung PDF ausliest und aus diesen Informationen einen Einzahlungsschein inkl QR Code erstellt – Excel → QR (Node.js, Poppler, Watch-OperaQR, Aufgabenplanung, ClawPDF)
0) VORAUSSETZUNGEN
- Windows 11 / Windows Server 2022
- Netzwerkzugriff & Schreibrechte auf:
- \192.168.01.20\data\Rechnungen ohne Einzahlungsschein
- \192.168.01.20\data\Vorauszahlungen
- (Empfohlen) Eigenes Dienst-/Aufgaben-Konto für die geplante Aufgabe mit minimal nötigen Rechten
1) DOWNLOADS (OFFIZIELLE QUELLEN)
Node.js (Windows x64 Installer / .msi)
https://nodejs.org/en/download
Poppler für Windows (ZIP-Pakete, Releases)
https://github.com/oschwartz10612/poppler-windows/releases/
ClawPDF 0.8.4 (Releases – Setup-EXE als Asset)
https://github.com/clawsoftware/clawPDF/releases/tag/0.8.4
Hinweise:
- Für Poppler die Version „24.08.0“ als ZIP laden (z. B. poppler-24.08.0-…-win64.zip).
- Für ClawPDF die Setup-EXE der Version 0.8.4 verwenden.
2) NODE.JS INSTALLIEREN & PRÜFEN
- node-v22.19.0-x64.msi (oder aktuelle x64 .msi) installieren (Standard-Optionen).
- Neue PowerShell öffnen und prüfen:
npm -v
node -v
3) POPPLER 24.08.0 ENTPACKEN & PRÜFEN
- Poppler-ZIP nach
C:\Program Files (x86)\poppler-24.08.0
entpacken (Struktur: …\poppler-24.08.0\Library\bin\pdftoppm.exe). - In derselben PowerShell:
cd ‚C:\Program Files (x86)\poppler-24.08.0\Library\bin‘
.\pdftoppm.exe -v
setx PATH «$env:Path;C:\Program Files (x86)\poppler-24.08.0\Library\bin»
Tipp: setx wirkt erst in neu geöffneten Fenstern. Das Watch-OperaQR-Skript ergänzt PATH zusätzlich zur Laufzeit.
4) QR-PROJEKT ABLEGEN (DOKUMENTE) & RECHTE
- ZIP (z. B. OperaQR_Auto_Kit_v16_CH28.zip) nach
C:\Users\\Documents\
kopieren und entpacken nach:
C:\Users\\Documents\qr-scan-append - PowerShell-Berechtigungen:
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned -Force
Unblock-File «$env:USERPROFILE\Documents\qr-scan-append*.ps1»
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned - Abhängigkeiten installieren:
cd ‚C:\Users\username\Documents\qr-scan-append‘
cd «$env:USERPROFILE\Documents\qr-scan-append»
npm install
- cd «$env:USERPROFILE\Documents\qr-scan-append»
- Get-Item .\package.json
- npm install
- Minimal sicherstellen, dass die Kernpakete da sind npm i pdf-lib swissqrbill pdfjs-dist @pdf-lib/fontkit yargs
- npm i pdf-lib swissqrbill pdfjs-dist @pdf-lib/fontkit yargs
- # Pfad zu deiner Poppler-Installation (bitte anpassen, falls anders)
$pop = ‚C:\Program Files (x86)\poppler-24.08.0\Library\bin‘
if (Test-Path «$pop\pdftoppm.exe») {
$env:PATH = «$pop;$env:PATH»
Write-Host «Poppler in PATH aufgenommen: $pop» -ForegroundColor Green
pdftoppm -v # sollte jetzt die Version zeigen
} else {
Write-Host «pdftoppm.exe nicht gefunden unter: $pop» -ForegroundColor Red
}
Danach den Job erneut starten:
C:\Users\username\Documents\qr-scan-append\Watch-OperaQR.ps1
- Optional zusätzlich dauerhaft per Umgebungsvariable (neues Fenster) ‚Path‘,
$env:Path + ‚;C:\Program Files (x86)\poppler-24.08.0\Library\bin‘,
‚User‘
)
5) WATCH-OPERAQR EINSTELLEN (IBAN-Umschaltung „Vorauszahlung“)
- Watch-OperaQR.ps1 beobachtet standardmäßig:
- \192.168.01.20\data\Rechnungen ohne Einzahlungsschein → Profil: default
- \192.168.01.20\data\Vorauszahlungen → Profil: prepay
- profiles.json:
- default → IBAN: CH72 0000 0001 0002 0003 4
- prepay → IBAN: CH72 0000 0001 0002 0005
(WICHTIG: index.js MUSS das Flag –profile auswerten und anhand profiles.json die IBAN setzen.) - Optional anpassen (Dateinamen, Tages-Unterordner etc.) in Watch-OperaQR.ps1:
- $FileNamePattern (z. B. ‚{Invoice}‘ oder ‚{Invoice} – {Debtor}‘)
- $DailySubfolders = $true/$false
6) OPERA (PMS) – DRUCKERZUWEISUNG „RECHNUNGEN“
- In OPERA an der Position „Rechnungen“ den Drucker ClawPDF hinterlegen,
damit neu erzeugte Rechnungen automatisch als PDF im Hotfolder landen
(z. B. J:\\Vorauszahlungen).
7) CLAWPDF 0.8.4 – INSTALLATION & INI-IMPORT
- ClawPDF 0.8.4 installieren (Setup-EXE).
- ClawPDF einmal starten (damit %AppData%\clawPDF\ existiert).
- Eigene INI nach
%AppData%\clawPDF\settings.ini
kopieren/überschreiben (z. B. Profil „Druck nach dem Speichern“ (PrintGuid) mit AutoSave nach J:\Rechnungen ohne Einzahlungsschein).
8) GEPLANTE AUFGABE (TASK SCHEDULER)
WICHTIGER HINWEIS (Lock-Screen / Abmeldung):
- Standardmäßig laufen Aufgaben nicht weiter, wenn der Benutzer abgemeldet ist
oder der Bildschirm gesperrt ist. Stelle daher in der Aufgabenplanung ein:
„Unabhängig von der Benutzeranmeldung ausführen“ und hinterlege Anmeldedaten. - Verwende nach Möglichkeit ein dediziertes Dienst-/Aufgaben-Konto (mit minimal nötigen Rechten
und Zugriff auf die UNC-Shares).
Schnell-Setup in PowerShell (1-Minuten-Intervall):
$taskName = ‚OperaQR Auto Append‘
$psFile = «$env:USERPROFILE\Documents\qr-scan-append\Watch-OperaQR.ps1»
$action = New-ScheduledTaskAction -Execute ‚powershell.exe‘ -Argument «-ExecutionPolicy Bypass -NoProfile -File "$psFile«»
$trigger = New-ScheduledTaskTrigger -Once (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1) -RepetitionDuration (New-TimeSpan -Days 99999)
$principal= New-ScheduledTaskPrincipal -UserId «$env:USERNAME» -RunLevel Highest -LogonType InteractiveOrPassword
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Description ‚Scan Opera-Folders (incl. Vorauszahlung) and append Swiss-QR‘ -Force
9) SCHNELLBEFEHLE (REFERENCE)
Policies & Unblock
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned -Force
Unblock-File «$env:USERPROFILE\Documents\qr-scan-append*.ps1»
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
Versionen prüfen
npm -v
node -v
Poppler
cd ‚C:\Program Files (x86)\poppler-24.08.0\Library\bin‘
.\pdftoppm.exe -v
setx PATH «$env:Path;C:\Program Files (x86)\poppler-24.08.0\Library\bin»
Dependencies
cd «$env:USERPROFILE\Documents\qr-scan-append»
npm install
10) TROUBLESHOOTING (KURZ)
- Poppler nicht gefunden → Terminal neu öffnen (wegen setx) oder PATH im Skript prüfen.
- IBAN in „Vorauszahlung“ ist trotzdem CH72 → index.js prüft evtl. –profile nicht. Profil‑fähige index.js verwenden.
- Datei gesperrt/noch im Druck → $WaitStableSec / $MaxWaitSec in Watch-OperaQR.ps1 erhöhen.
- Aufgabe startet nicht → „Unabhängig von der Benutzeranmeldung ausführen“, Konto/Passwort prüfen, Rechte auf UNC.
11) Testlauf
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
cd «$env:USERPROFILE\Documents\qr-scan-append»
.\Watch-OperaQR.ps1 -SourceDir '\\192.168.01.20\data\Rechnungen ohne Einzahlungsschein'
-ProjectDir «$env:USERPROFILE\Documents\qr-scan-append» `
-OutputDir ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘
12) Testlauf
-ExecutionPolicy Bypass -NoProfile -File «C:\Users\\Documents\qr-scan-append\Watch-OperaQR.ps1»
Hinweis: Swiss-QR ist offiziell nur mit IBAN CH zulässig. Für Vorauszahlung ist im Profil beispielhaft LI28… hinterlegt (passt also).
13) PowerShell Watch-OperaQR.PS1
param(
[string]$SourceDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$SourcePattern = ‚*.pdf‘, # alle PDFs
[string]$ProjectDir = «$env:USERPROFILE\Documents\qr-scan-append»,
[string]$OutputDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$ProcessedDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein\processed‘,
[string]$CreatedDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein\erstellte Rechnungen‘,
[string]$ManualAmount = », # z.B. 6336.50 (leer = auto)
[string]$FileNamePattern = ‚{Invoice}‘, # Standard: Rechnungsnummer
[bool]$DailySubfolders = $true, # Unterordner pro Tag
[int]$WaitStableSec = 3,
[int]$MaxWaitSec = 60
)
function Write-Log($msg, [ConsoleColor]$Color = [ConsoleColor]::Gray) {
$stamp = (Get-Date).ToString(‚yyyy-MM-dd HH:mm:ss‘)
$line = «[{0}] {1}» -f $stamp, $msg
Write-Host $line -ForegroundColor $Color
if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir | Out-Null }
if (-not (Test-Path $ProcessedDir)) { New-Item -ItemType Directory -Path $ProcessedDir | Out-Null }
if (-not (Test-Path $CreatedDir)) { New-Item -ItemType Directory -Path $CreatedDir | Out-Null }
Add-Content -Path (Join-Path $OutputDir ‚operaqr.log‘) -Value $line
}
function Ensure-NodeDeps {
if (-not (Test-Path $ProjectDir)) { throw «ProjectDir nicht gefunden: $ProjectDir» }
Push-Location $ProjectDir
if (-not (Test-Path ’node_modules‘)) {
Write-Log «npm install (einmalig)…» DarkGray
& npm install –silent | Out-Null
}
Pop-Location
}
function Get-FileStable($path, $waitSec, $maxWait) {
$elapsed = 0
$lastLen = -1
while ($elapsed -lt $maxWait) {
if (-not (Test-Path $path)) { Start-Sleep -Seconds 1; $elapsed++; continue }
try {
$len = (Get-Item $path).Length
if ($len -gt 0 -and $len -eq $lastLen) { return $true }
$lastLen = $len
Start-Sleep -Seconds $waitSec
$elapsed += $waitSec
} catch {
Start-Sleep -Seconds 1
$elapsed++
}
}
return $false
}
function Sanitize-Name($s) {
if (-not $s) { return «Unbekannt» }
$s = $s -replace ‚[^\w-. ]‘,»
$s = $s.Trim()
if ($s.Length -gt 120) { $s = $s.Substring(0,120) }
return $s
}
function Expand-Pattern($pattern, $debtor, $invoice, $ts) {
if (-not $pattern) { $pattern = ‚{Invoice}‘ }
if ([string]::IsNullOrWhiteSpace($invoice)) { $invoice = «RG-$ts» }
$map = @{
‚{Debtor}‘ = (Sanitize-Name($debtor))
‚{Invoice}‘ = (Sanitize-Name($invoice))
‚{Timestamp}‘ = $ts
}
$out = $pattern
foreach ($k in $map.Keys) { $out = $out -replace [regex]::Escape($k), [regex]::Escape($map[$k]).Replace(‚\‘,’\‘) }
if ([string]::IsNullOrWhiteSpace($out)) { $out = «RG-$ts» }
return (Sanitize-Name($out))
}
function Next-AvailableName($dir, $baseName) {
$name = «$baseName.pdf»
$path = Join-Path $dir $name
$i = 2
while (Test-Path $path) {
$name = («{0} ({1}).pdf» -f $baseName, $i)
$path = Join-Path $dir $name
$i++
if ($i -gt 99) { break }
}
return $path
}
function TryExtractInvoiceFromText($pdfPath) {
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = «pdftotext»
$psi.Arguments = «-nopgbrk -layout -q "$pdfPath» -«
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.WaitForExit(15000) | Out-Null
$txt = $p.StandardOutput.ReadToEnd()
if (-not $txt) { return «» }
# 1) STRIKTE Regel: Zahl auf derselben Zeile wie RECHNUNGSKOPIE oder RECHNUNG
$lines = $txt -split "`r?`n"
foreach ($ln in $lines) {
if ($ln -match '(?i)\b(RECHNUNGSKOPIE|RECHNUNG)\b') {
# Nimm die längste Ziffernfolge (4-12 Ziffern) auf derselben Zeile
$ms = [regex]::Matches($ln, '\b([0-9]{4,12})\b')
if ($ms.Count -gt 0) {
$cand = ($ms | Sort-Object { $_.Groups[1].Value.Length } -Descending | Select-Object -First 1).Groups[1].Value
if ($cand) { return $cand }
}
}
}
# 2) Weitere übliche Muster (nur wenn 1) nichts fand)
$patterns = @(
'(?im)Rechnungsnummer\s*[:#]?\s*([A-Za-z0-9\-/]{4,})',
'(?im)Rechnung\s*Nr\.?\s*[:#]?\s*([A-Za-z0-9\-/]{4,})',
'(?im)\bRG[_\-\s]?([0-9]{4,})\b',
'(?im)\bRechnung[^\r\n]{0,40}?([0-9]{5,})\b'
)
foreach ($pat in $patterns) {
$m = [regex]::Match($txt, $pat)
if ($m.Success -and $m.Groups.Count -gt 1) {
$val = $m.Groups[1].Value.Trim()
if ($val) { return $val }
}
}
return ""
} catch {
return «»
}
}
function Process-One($srcPath) {
try {
if (-not (Test-Path $srcPath)) { return }
$fi = Get-Item $srcPath
Write-Log ("--- Verarbeite: {0} (Größe: {1} Bytes)" -f $srcPath, $fi.Length) Cyan
if (-not (Get-FileStable $srcPath $WaitStableSec $MaxWaitSec)) {
Write-Log "Datei wurde nicht stabil innerhalb ${MaxWaitSec}s. Überspringe." Yellow
return
}
# Arbeitskopie
$work = Join-Path $ProjectDir 'work'
if (-not (Test-Path $work)) { New-Item -ItemType Directory -Path $work | Out-Null }
$ts = (Get-Date).ToString('yyyyMMdd_HHmmss')
$dateFolder = (Get-Date).ToString('yyyy-MM-dd')
$localIn = Join-Path $work (("{0}_{1}.pdf" -f [IO.Path]::GetFileNameWithoutExtension($fi.Name), $ts))
Copy-Item $srcPath $localIn -Force
Write-Log "Arbeitskopie: $localIn" DarkGray
# Vorab: Rechnungsnummer per Text-Fallback (damit Node sie für Referenz nutzen kann)
$preInvoice = TryExtractInvoiceFromText $localIn
if ($preInvoice) { Write-Log ("Vorab erkannte Rechnungsnummer: {0}" -f $preInvoice) DarkGray }
# Ausgabedatei temporär (lokal)
$tmpOut = Join-Path $work ("out_{0}.pdf" -f $ts)
# Node-Aufruf
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "node"
$psi.WorkingDirectory = $ProjectDir
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$args = @("index.js", $localIn, $tmpOut)
if ($preInvoice) { $args += $preInvoice } # <-- an Node übergeben, damit in Referenz einfließt
if ($ManualAmount -and $ManualAmount.Trim().Length -gt 0) {
if (-not $preInvoice) { $args += "" } # Platzhalter, falls Invoice leer wäre
$args += $ManualAmount.Trim()
}
$psi.Arguments = ($args | ForEach-Object { if ($_ -match '\s') { '"{0}"' -f $_ } else { $_ } }) -join ' '
Write-Log "Starte Node: $($psi.Arguments)" DarkGray
$p = [System.Diagnostics.Process]::Start($psi)
$p.WaitForExit()
$stdout = $p.StandardOutput.ReadToEnd()
$stderr = $p.StandardError.ReadToEnd()
if ($stdout) { Write-Log $stdout.Trim() DarkGray }
if ($stderr) { Write-Log $stderr.Trim() DarkYellow }
if ($p.ExitCode -ne 0 -or -not (Test-Path $tmpOut)) {
Write-Log "Fehler beim Erzeugen (ExitCode $($p.ExitCode)). Überspringe." Red
return
}
# Debitorname + Rechnungsnummer aus JSON
$jsonLine = ($stdout -split "`r?`n" | Where-Object { $_ -match '^\s*\{' } | Select-Object -Last 1)
$debtorName, $invoiceNo = "Unbekannt", ""
if ($jsonLine) {
try {
$obj = $jsonLine | ConvertFrom-Json
if ($obj.debtor -and $obj.debtor.name) { $debtorName = [string]$obj.debtor.name }
elseif ($obj.debtorName) { $debtorName = [string]$obj.debtorName }
if ($obj.invoiceNo) { $invoiceNo = [string]$obj.invoiceNo }
} catch { }
}
if (-not $invoiceNo) { $invoiceNo = $preInvoice } # falls Node keine geliefert hat
$baseName = Expand-Pattern $FileNamePattern $debtorName $invoiceNo $ts
# Zielordner (mit Tagesordnern)
$createdTarget = $CreatedDir
$processedTarget = $ProcessedDir
if ($DailySubfolders) {
$createdTarget = Join-Path $CreatedDir $dateFolder
$processedTarget = Join-Path $ProcessedDir $dateFolder
}
if (-not (Test-Path $createdTarget)) { New-Item -ItemType Directory -Path $createdTarget | Out-Null }
if (-not (Test-Path $processedTarget)) { New-Item -ItemType Directory -Path $processedTarget | Out-Null }
# Zuerst im OutputDir ablegen, dann in Created verschieben
$intermediate = Join-Path $OutputDir ("{0}.pdf" -f $baseName)
if (Test-Path $intermediate) {
$intermediate = (Join-Path $OutputDir ("{0} ({1}).pdf" -f $baseName, (Get-Random -Minimum 2 -Maximum 9999)))
}
Move-Item $tmpOut $intermediate -Force
$finalPath = Next-AvailableName $createdTarget $baseName
Move-Item $intermediate $finalPath -Force
Write-Log ("Ergebnis abgelegt: {0}" -f $finalPath) Green
# Original in processed verschieben (mit Originalname + ts)
try {
$procName = Join-Path $processedTarget (("{0}_{1}.pdf" -f [IO.Path]::GetFileNameWithoutExtension($fi.Name), $ts))
Move-Item $srcPath $procName -Force
Write-Log ("Original verschoben nach: {0}" -f $procName) DarkGray
} catch {
Write-Log ("Hinweis: Original konnte nicht verschoben werden: " + $_.Exception.Message) DarkYellow
}
} catch {
Write-Log («Unerwarteter Fehler bei Datei ‚{0}‘: {1}» -f $srcPath, $_.Exception.Message) Red
}
}
————— MAIN —————-
try {
Ensure-NodeDeps
if (-not (Test-Path $SourceDir)) {
Write-Log «SourceDir existiert nicht: $SourceDir» Yellow
exit 0
}
$files = Get-ChildItem -LiteralPath $SourceDir -Filter $SourcePattern -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc, Name
if (-not $files -or $files.Count -eq 0) {
Write-Log «Keine passenden Dateien gefunden für Pattern: $SourcePattern» DarkGray
exit 0
}
foreach ($f in $files) { Process-One $f.FullName }
Write-Log («Batch fertig. Verarbeitete Dateien: {0}» -f $files.Count) Gray
exit 0
} catch {
Write-Log («Unerwarteter Fehler (Batch): » + $_.Exception.Message) Red
exit 9
}
14) Install-OperaQRTask.ps1
param(
[int]$EveryMinutes = 1,
[string]$SourceDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$ProjectDir = «$env:USERPROFILE\Documents\qr-scan-append»,
[string]$OutputDir = ‚\192.168.01.20\data\Rechnungen ohne Einzahlungsschein‘,
[string]$ManualAmount = »
)
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$watch = Join-Path $here ‚Watch-OperaQR.ps1‘
Index.js Parameter für Englich Deutsch Rechnungsinhalt
// index.js — QR-Append (swissqrbill v4.x kompatibel) — v17.0 (DE/EN Parser)
// Node >=18 (getestet mit Node 22)
// ———- Imports ———-
import fs from ‚fs‘;
import path from ‚path‘;
import { PDFDocument } from ‚pdf-lib‘;
import PDFKit from ‚pdfkit‘;
import { SwissQRBill } from ’swissqrbill/pdf‘;
import yargs from ‚yargs‘;
import { hideBin } from ‚yargs/helpers‘;
// ———- pdfjs-dist dynamisch laden (Legacy zuerst) ———-
let pdfjsMod;
try {
pdfjsMod = await import(‚pdfjs-dist/legacy/build/pdf.js‘);
} catch {
pdfjsMod = await import(‚pdfjs-dist/build/pdf.mjs‘);
}
let pdfjs = pdfjsMod?.default?.getDocument ? pdfjsMod.default : pdfjsMod;
const getPdfDocument = (bytes) => pdfjs.getDocument({ data: bytes, disableWorker: true });
// ———- Utils ———-
const norm = (s) => String(s || »)
.replace(/\u00A0/g, ‚ ‚)
.replace(/\r/g, »)
.replace(/[ \t]+/g, ‚ ‚)
.trim();
const clamp = (s, n) => (s ?? »).toString().replace(/\s+/g, ‚ ‚).trim().slice(0, n);
const clamp70 = (s) => clamp(s, 70);
const digitsOnly = (s) => (s ?? »).toString().replace(/\D+/g, »);
function normalizeIBAN(iban) {
return (iban || »).replace(/[^A-Za-z0-9]/g, »).replace(/(.{4})/g, ‚$1 ‚).trim();
}
function isQRIBAN(iban) {
const c = (iban || »).replace(/\s+/g, »).toUpperCase();
const part = c.slice(4, 9);
const n = parseInt(part, 10);
return c.startsWith(‚CH‘) && Number.isFinite(n) && n >= 30000 && n <= 31999;
}
// QRR Referenz aus Rechnungsnummer (27-stellig inkl. Prüfziffer)
function buildQRRFromInvoiceNo(inv) {
const raw = digitsOnly(inv);
const body = raw.padStart(26, ‚0‘);
const table = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5];
let p = 0;
for (const ch of body) {
const d = ch.charCodeAt(0) – 48;
p = table[(p + d) % 10];
}
const cd = (10 – p) % 10;
return body + String(cd);
}
// SCOR Referenz aus Rechnungsnummer (RF..)
function buildSCORFromInvoiceNo(inv) {
const bban = String(inv).replace(/[^0-9A-Z]/gi, »).toUpperCase();
const toNum = (s) => s.replace(/[A-Z]/g, ch => String(ch.charCodeAt(0) – 55));
const numStr = toNum(bban + ‚RF00‘);
let rem = 0;
for (const ch of numStr) {
const d = ch.charCodeAt(0) – 48;
if (d < 0 || d > 9) continue;
rem = (rem * 10 + d) % 97;
}
const check = String(98 – rem).padStart(2, ‚0‘);
return ‚RF‘ + check + bban;
}
// ———- PDF-Text extrahieren ———-
async function extractFirstPageText(filePath) {
const data = new Uint8Array(fs.readFileSync(filePath));
const doc = await getPdfDocument(data).promise;
const page = await doc.getPage(1);
const content = await page.getTextContent();
const strings = content.items.map(i => i.str);
return strings.join(‚\n‘);
}
// ———- Parser (DE & EN) ———-
function parseMetaFromText(txt) {
const t = norm(txt);
const lines = String(txt || »).split(/\n+/).map(norm);
const meta = {
invoiceNo: undefined,
debtor: { name: undefined, address: undefined, buildingNumber: », zip: », city: », country: » },
amount: undefined
};
// Rechnungsnummer
let m = t.match(/RECHNUNG(?:SKOPIE)?\s+(\d{3,12})/i);
if (!m) m = t.match(/\bRechnungs?-?Nr.?\s[:#]?\s(\d{3,12})\b/i);
if (!m) m = t.match(/\bINVOICE\s+(\d{3,12})\b/i);
if (!m) m = t.match(/\bINFORMATION\s+INVOICE\b.*?\b(\d{3,12})\b/i);
if (m) meta.invoiceNo = m[1];
// Betrag
// EN & DE Varianten
let am =
t.match(/Total\s+CHF\s+([\d’’\s]+.\d{2})/i) ||
t.match(/Betrag\s+CHF\s+([\d’’\s]+.\d{2})/i) ||
t.match(/\bAmount\s+CHF\s+([\d’’\s]+.\d{2})/i);
if (am) {
meta.amount = parseFloat(am[1].replace(/[ ‚’]/g, »));
} else {
// Fallback: letzte 1–3 Zahlen mit Dezimalpunkt, nimm die größte
const nums = (t.match(/(\d{1,3}(?:[ ‚’]\d{3})*.\d{2})/g) || []).map(x => parseFloat(x.replace(/[ ‚’]/g, »)));
if (nums.length) meta.amount = Math.max(…nums);
}
// Debitorblock (Zeilen vor «RECHNUNG»/»INVOICE»)
const headRe = /(RECHNUNGSKOPIE|RECHNUNG\b|INFORMATION INVOICE|INVOICE\b)/i;
let idxHead = lines.findIndex(l => headRe.test(l));
if (idxHead < 0) idxHead = Math.min(lines.length, 25); // konservativ
const block = lines
.slice(0, idxHead)
.map(norm)
.filter(Boolean)
.slice(-6); // die letzten Zeilen direkt vor der Headline enthalten i.d.R. den Empfänger
if (block.length) {
// Heuristik: { name, street+no, zip+city, country? }
// Suche Country in der letzten/ vorletzten Zeile
const countryMap = {
Schweiz: ‚CH‘, Switzerland: ‚CH‘, ‚SWITZERLAND‘: ‚CH‘, CH: ‚CH‘,
Deutschland: ‚DE‘, Germany: ‚DE‘, DE: ‚DE‘,
Österreich: ‚AT‘, Oesterreich: ‚AT‘, Austria: ‚AT‘, AT: ‚AT‘,
France: ‚FR‘, Frankreich: ‚FR‘, FR: ‚FR‘,
Italia: ‚IT‘, Italien: ‚IT‘, Italy: ‚IT‘, IT: ‚IT‘
};
let country = '';
for (let i = block.length - 1; i >= Math.max(0, block.length - 2); i--) {
const w = block[i].toUpperCase();
for (const k of Object.keys(countryMap)) {
if (w === k.toUpperCase()) { country = countryMap[k]; break; }
}
if (country) { block.splice(i, 1); break; }
}
// Suche ZIP+City
let zip = '', city = '';
let street = '';
let name = '';
for (let i = block.length - 1; i >= 0; i--) {
const zc = block[i].match(/\b(\d{4,5})\s+(.+?)$/);
if (zc) {
zip = zc[1];
city = clamp70(zc[2]);
block.splice(i, 1);
break;
}
}
// Street+No (z.B. "Matt 754" / "Seedammstrasse 3")
for (let i = block.length - 1; i >= 0; i--) {
if (/\d+\s*[A-Za-z]?$/.test(block[i])) {
street = block[i];
block.splice(i, 1);
break;
}
}
// Name = erste verbleibende Zeile des Blocks
if (block.length) name = block[0];
// Split Street/HouseNo
let address = '', house = '';
if (street) {
const sm = street.match(/^(.*?)[ ,]+(\d+[A-Za-z]?)$/);
if (sm) { address = sm[1]; house = sm[2]; }
else { address = street; }
}
// Defaults / Aufbereitung
if (!country) country = 'CH';
if (name) meta.debtor.name = clamp70(name);
if (address) meta.debtor.address = clamp70(address);
if (house) meta.debtor.buildingNumber = clamp(house, 20);
if (zip) meta.debtor.zip = zip;
if (city) meta.debtor.city = clamp70(city);
meta.debtor.country = country;
// Falls wir nur den Namen sicher haben, setze minimale Felder,
// damit swissqrbill nicht mit "address undefined" abbricht.
if (meta.debtor.name && (!meta.debtor.address || !meta.debtor.zip || !meta.debtor.city)) {
// sehr defensiv: leere Pflichtfelder sanft auffüllen
if (!meta.debtor.address) meta.debtor.address = 'n/a';
if (!meta.debtor.zip) meta.debtor.zip = '0000';
if (!meta.debtor.city) meta.debtor.city = 'n/a';
if (!meta.debtor.country) meta.debtor.country = 'CH';
}
// Wenn trotzdem praktisch nichts da ist → Debitor weglassen
const haveMin =
!!meta.debtor.name && !!meta.debtor.address && !!meta.debtor.zip && !!meta.debtor.city && !!meta.debtor.country;
if (!haveMin) meta.debtor = undefined;
}
return meta;
}
// ———- QR Bill Render (PDFKit + SwissQRBill) ———-
function renderQRBillBuffer(data) {
return new Promise((resolve, reject) => {
const doc = new PDFKit({ size: ‚A4‘ });
const chunks = [];
doc.on(‚data‘, (c) => chunks.push(c));
doc.on(‚end‘, () => resolve(Buffer.concat(chunks)));
doc.on(‚error‘, reject);
const qr = new SwissQRBill(data);
qr.attachTo(doc);
doc.end();
});
}
// ———- Original + Zahlteil mergen ———-
async function appendPaymentPart(originalBuf, paymentBuf) {
const src = await PDFDocument.load(originalBuf);
const add = await PDFDocument.load(paymentBuf);
const [p0] = await src.copyPages(add, [0]);
src.addPage(p0);
return await src.save();
}
// ———- Profile laden ———-
function loadProfile(name) {
const p = path.join(process.cwd(), ‚profiles.json‘);
let profiles = {
default: {
creditor: { name: ‚Hotel Seedamm AG‘, address: ‚Seedammstrasse‘, buildingNumber: ‚3‘, zip: ‚8808‘, city: ‚Pfäffikon SZ‘, country: ‚CH‘ },
account: ‚CH72 3000 0001 8700 0978 6‘
},
deposit: {
creditor: { name: ‚Hotel Seedamm AG‘, address: ‚Seedammstrasse‘, buildingNumber: ‚3‘, zip: ‚8808‘, city: ‚Pfäffikon SZ‘, country: ‚CH‘ },
account: ‚CH28 3250 0024 2547 1276‘
}
};
try {
if (fs.existsSync(p)) {
const js = JSON.parse(fs.readFileSync(p, ‚utf8‘));
profiles = { …profiles, …js };
}
} catch { /* ignore */ }
const prof = profiles[name] || profiles.default;
const account = prof.account || prof.iban;
const creditor = { …prof.creditor };
if (!creditor.address && prof.creditor?.street) creditor.address = prof.creditor.street;
if (!creditor.buildingNumber && prof.creditor?.houseNo) creditor.buildingNumber = prof.creditor.houseNo;
const refType = prof.refType || (isQRIBAN(account) ? ‚QRR‘ : ‚SCOR‘);
return { account: normalizeIBAN(account), creditor, refType };
}
// ———- CLI ———-
const argv = yargs(hideBin(process.argv))
.usage(’node index.js [invoiceNo] [amount] [–profile default|deposit]‘)
.option(‚profile‘, { type: ’string‘, default: ‚default‘ })
.demandCommand(2)
.help()
.parse();
const inPath = path.resolve(argv.[0]); const outPath = path.resolve(argv.[1]);
const profileName = argv.profile;
const profile = loadProfile(profileName);
const manualInvoice = argv.[2] ? String(argv.[2]) : undefined;
const manualAmount = argv.[3] ? Number(String(argv.[3]).replace(‚,‘, ‚.‘)) : undefined;
let meta = { invoiceNo: manualInvoice, amount: manualAmount, debtor: undefined };
try {
const txt = await extractFirstPageText(inPath);
const auto = parseMetaFromText(txt);
if (!meta.invoiceNo && auto.invoiceNo) meta.invoiceNo = auto.invoiceNo;
if (!meta.amount && auto.amount) meta.amount = auto.amount;
if (!meta.debtor && auto.debtor) meta.debtor = auto.debtor;
} catch (e) {
console.warn(‚[Warn] Text-Extraktion fehlgeschlagen:‘, e?.message || e);
}
if (!meta.invoiceNo) meta.invoiceNo = ‚0‘;
// Referenz
const reference = (profile.refType === ‚QRR‘)
? buildQRRFromInvoiceNo(meta.invoiceNo)
: buildSCORFromInvoiceNo(meta.invoiceNo);
// swissqrbill Daten (Achtung: bei v4.x liegt account IM creditor-Objekt)
const billData = {
currency: ‚CHF‘,
creditor: { …profile.creditor, account: profile.account },
reference,
additionalInformation: Rechnung ${meta.invoiceNo}
};
if (Number.isFinite(meta.amount) && meta.amount > 0) billData.amount = Number(meta.amount);
if (meta.debtor) billData.debtor = meta.debtor;
console.log([Info] Referenztyp: ${profile.refType}, Konto: ${profile.account}, Referenz: ${reference});
if (meta.debtor?.name) {
const extra = Number.isFinite(meta.amount) ? | Betrag: ${meta.amount} CHF : »;
console.log([Info] Debitor: ${meta.debtor.name}${extra});
}
try {
const zahlteil = await renderQRBillBuffer(billData);
let merged = false;
try {
const original = fs.readFileSync(inPath);
const outBuf = await appendPaymentPart(original, zahlteil);
fs.writeFileSync(outPath, outBuf);
merged = true;
} catch (mergeErr) {
console.warn(‚[Hinweis] Mergen fehlgeschlagen, schreibe nur Zahlteil. Grund:‘, mergeErr?.message || mergeErr);
fs.writeFileSync(outPath, zahlteil);
}
console.log(JSON.stringify({
ok: true,
invoiceNo: meta.invoiceNo ?? null,
amount: meta.amount ?? null,
debtor: meta.debtor ?? null,
reference,
merged,
onlyPaymentPart: !merged
}));
} catch (err) {
console.error(‚[Fehler] Zahlteil-Erzeugung: ‚, err?.message || err);
process.exit(1);
}
TaskAction (PowerShell, bypass policy)
$argList = «-NoProfile -ExecutionPolicy Bypass -File "$watch» -SourceDir "$SourceDir» -ProjectDir "$ProjectDir» -OutputDir "$OutputDir» -ManualAmount "$ManualAmount«»
$action = New-ScheduledTaskAction -Execute ‚powershell.exe‘ -Argument $argList
Trigger: Start in 1 min, dann Wiederholung alle N Minuten (10 Jahre Dauer)
$start = (Get-Date).AddMinutes(1)
$interval = New-TimeSpan -Minutes $EveryMinutes
$duration = New-TimeSpan -Days 3650
$trigger = New-ScheduledTaskTrigger -Once -At $start -RepetitionInterval $interval -RepetitionDuration $duration
Principal: aktueller User, sichtbar
$user = «$env:USERDOMAIN\$env:USERNAME»
$principal = New-ScheduledTaskPrincipal -UserId $user -RunLevel Highest -LogonType Interactive
Vorhandene Task ersetzen
if (Get-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -Confirm:$false
}
Register-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -Action $action -Trigger $trigger -Principal $principal -Description ‚Alle PDFs (inkl. RECHNUNG/KOPIE) verarbeiten; Invoice-Fallback aus Text auf gleicher Zeile‘ -Force
Write-Host («Task ‚OperaQR Auto Append‘ registriert. Intervall: {0} Minute(n).» -f $EveryMinutes)
15) Uninstall-OperaQRTask.ps1
if (Get-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName ‚OperaQR Auto Append‘ -Confirm:$false
Write-Host «Task entfernt: OperaQR Auto Append»
} else {
Write-Host «Task nicht gefunden.»
}
Das Archiv ist mit einem Password geschützt und kann via Mail info@ wissensbank.ch erfragt werden. Bitte mit Winrar auspacken.