30% Eigenanteil für Telefon und Internet + Tests mit KI Beleg-Erkennung
This commit is contained in:
parent
b8ef04033c
commit
926d4a5ee0
12 changed files with 397 additions and 62 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
data/
|
||||
.env
|
||||
.git/
|
||||
*.md
|
||||
tests/
|
||||
uploads/
|
||||
45
Changes.md
45
Changes.md
|
|
@ -2,6 +2,51 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-03-30 – KI-gestützte Belegerkennung (experimentell) + 30 % Eigenanteil Telefon/Internet
|
||||
|
||||
### Neu
|
||||
- Eigenanteil von 30 % bei Buchungen auf Telefon- und Internetkosten-Konten (4920/4925) wählbar
|
||||
- Experimentelle KI-Beleganalyse: erkennt Buchungstyp, Betrag, Konto und Lieferant aus PDF/Bild-Belegen
|
||||
- Setzt lokales LLM (z. B. Ollama/Mistral) oder Remote-API-Schlüssel voraus
|
||||
- Hoher Ressourcenbedarf bei lokaler Ausführung; Ergebnisqualität mit Mistral bisher mäßig
|
||||
- Getestet mit `ollama run mistral`
|
||||
|
||||
### Betrifft
|
||||
- `src/Services/KontierungsvorschlagService.php`
|
||||
- `src/Services/LlmService.php`
|
||||
- `src/Views/belege/detail.php`, `list.php`
|
||||
- `Dockerfile`, `docker-compose.yml`
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-30 – Fix: Lieferant-Eingabe in Metadaten-Leiste speichert korrekt
|
||||
|
||||
### Geändert
|
||||
- `supplier-persistent-input`: Nach erfolgreichem AJAX-Speichern wird das Texteingabefeld durch den Inline-Trigger-Span ersetzt – visuelle Bestätigung, dass der Lieferant gespeichert wurde
|
||||
- Fehlerbehandlung (`try/catch`) hinzugefügt: bei fehlgeschlagenem Speichern bleibt das Feld editierbar
|
||||
- Das versteckte Metaformular (`supplier_name_input`) wird nach AJAX-Speichern synchronisiert, damit ein späteres Absenden des Metaformulars den Lieferanten nicht überschreibt
|
||||
|
||||
### Betrifft
|
||||
- `src/Views/belege/detail.php`
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-30 – Lieferant inline editierbar + KI-Lieferantenerkennung
|
||||
|
||||
### Geändert
|
||||
- Lieferant in der Beleg-Info-Bar ist jetzt direkt inline editierbar (Klick auf ✎), wie Belegdatum und Rechnungsnr.
|
||||
- KI-Kontierungsvorschlag extrahiert nun auch den Lieferantennamen aus dem Beleg
|
||||
- Nach KI-Analyse: bekannter Lieferant wird automatisch eingetragen; unbekannter Lieferant öffnet das Lieferantenfeld sofort mit Fokus
|
||||
- KI-Badge zeigt Lieferantenname an, unbekannte Lieferanten werden als „(neu – bitte prüfen)" markiert
|
||||
- Validierung in `KontierungsvorschlagService` prüft `zahlungskonto_id` und `kategorie_konto_id` jetzt gegen die korrekte Kontenart statt gegen alle Konten
|
||||
|
||||
### Betrifft
|
||||
- `src/Services/KontierungsvorschlagService.php`
|
||||
- `src/Views/belege/detail.php`
|
||||
- `public/static/js/app.js`
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-29 – EÜR: Spaltenauswahl nach Kontenart statt Buchungstyp
|
||||
|
||||
### Geändert
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y \
|
|||
unzip \
|
||||
curl \
|
||||
poppler-utils \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-deu \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install pdo pdo_mysql zip gd \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -109,7 +109,34 @@ Beim ersten Aufruf erscheint die **Ersteinrichtung**: dort den ersten Benutzer-A
|
|||
|
||||
Weitere Benutzer können nach dem Login unter **Benutzer** in der Seitenleiste verwaltet werden.
|
||||
|
||||
#### 5. Firmendaten hinterlegen
|
||||
#### 5. KI-Beleganalyse einrichten (optional)
|
||||
|
||||
Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist.
|
||||
|
||||
**Option A – Lokales Ollama** (kein API-Schlüssel nötig, aber hoher Ressourcenbedarf):
|
||||
|
||||
```bash
|
||||
ollama pull mistral # oder llama3.2 etc.
|
||||
```
|
||||
|
||||
In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden:
|
||||
|
||||
```dotenv
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=mistral
|
||||
```
|
||||
|
||||
**Option B – Remote-API** (OpenAI-kompatibel, z. B. OpenAI, OpenRouter, LM Studio):
|
||||
|
||||
```dotenv
|
||||
LLM_API_URL=https://api.openai.com/v1
|
||||
LLM_API_KEY=sk-...
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Tests mit `mistral` (lokal via Ollama) lieferten bisher mäßige Ergebnisse.
|
||||
|
||||
#### 6. Firmendaten hinterlegen
|
||||
|
||||
Nach dem ersten Login unter **Einstellungen** Firmendaten, Logo und ELSTER-Steuernummer eintragen. Diese Angaben erscheinen auf Ausgangsrechnungen und im EÜR-Export.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ services:
|
|||
DB_USER: steuerkram
|
||||
DB_PASS: steuerkram
|
||||
APP_SECRET: change-this-in-production
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3.2}
|
||||
extra_hosts:
|
||||
- "host-gateway:host-gateway"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
10
migrations/012_konten_1800_8922.sql
Normal file
10
migrations/012_konten_1800_8922.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- Konto 1800: Bezeichnung korrigieren
|
||||
UPDATE accounts SET bezeichnung = 'Privatentnahmen' WHERE kontonummer = '1800';
|
||||
|
||||
-- Konto 8922: Eigenverbrauch 19 % USt (neu)
|
||||
INSERT INTO accounts (kontonummer, bezeichnung, kontenart, aktiv)
|
||||
VALUES ('8922', 'Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens 19 % USt', 'Betriebseinnahme', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
bezeichnung = VALUES(bezeichnung),
|
||||
kontenart = VALUES(kontenart),
|
||||
aktiv = VALUES(aktiv);
|
||||
|
|
@ -346,7 +346,7 @@ td { padding: .65rem 1rem; vertical-align: middle; }
|
|||
/* ── Beleg Detail (Split-Pane) ───────────────────────────────── */
|
||||
.beleg-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 420px;
|
||||
grid-template-columns: 1fr 520px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
|
@ -362,7 +362,7 @@ td { padding: .65rem 1rem; vertical-align: middle; }
|
|||
.beleg-preview iframe,
|
||||
.beleg-preview img {
|
||||
width: 100%;
|
||||
min-height: 700px;
|
||||
min-height: 1165px;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,10 @@ document.addEventListener('click', (e) => {
|
|||
input.type = type;
|
||||
input.value = rawValue;
|
||||
input.style.cssText = 'font-size:inherit;padding:2px 5px;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;min-width:' + (type === 'date' ? '140px' : '180px');
|
||||
if (field === 'supplier_name') {
|
||||
input.setAttribute('list', 'supplier-datalist');
|
||||
input.setAttribute('autocomplete', 'off');
|
||||
}
|
||||
|
||||
const originalHTML = trigger.innerHTML;
|
||||
trigger.innerHTML = '';
|
||||
|
|
@ -289,6 +293,7 @@ document.addEventListener('click', (e) => {
|
|||
belegtyp: bar.dataset.belegtyp || '',
|
||||
beleg_datum: field === 'beleg_datum' ? newValue : bar.dataset.belegDatum || '',
|
||||
betrag_brutto: bar.dataset.betragBrutto || '',
|
||||
supplier_name: field === 'supplier_name' ? newValue : bar.dataset.supplierName || '',
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -309,6 +314,11 @@ document.addEventListener('click', (e) => {
|
|||
} else {
|
||||
trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
|
||||
}
|
||||
} else if (field === 'supplier_name') {
|
||||
bar.dataset.supplierName = newValue;
|
||||
trigger.dataset.raw = newValue;
|
||||
trigger.textContent = newValue || '';
|
||||
if (!newValue) trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
|
||||
} else {
|
||||
bar.dataset.rechnungsNr = newValue;
|
||||
trigger.dataset.raw = newValue;
|
||||
|
|
@ -353,6 +363,7 @@ document.addEventListener('keydown', (e) => {
|
|||
// ── Flash Message Auto-dismiss ────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.flash').forEach(el => {
|
||||
if (el.closest('#ki-badge')) return; // KI-Badge nie auto-dismissen
|
||||
setTimeout(() => {
|
||||
el.style.transition = 'opacity .4s';
|
||||
el.style.opacity = '0';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ namespace Steuerkram\Services;
|
|||
|
||||
use Steuerkram\Models\Account;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Models\Supplier;
|
||||
|
||||
class KontierungsvorschlagService
|
||||
{
|
||||
|
|
@ -28,28 +29,26 @@ class KontierungsvorschlagService
|
|||
if (!file_exists($absPath)) return null;
|
||||
|
||||
// Inhalt extrahieren
|
||||
$base64Image = null;
|
||||
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||
|
||||
if ($mime === 'application/pdf') {
|
||||
$text = $this->extractPdfText($absPath);
|
||||
} elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
||||
$base64Image = base64_encode(file_get_contents($absPath));
|
||||
$text = null;
|
||||
$text = $this->extractImageText($absPath);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Konten laden
|
||||
// Konten laden (max. 50 relevante Konten für kurzen Prompt)
|
||||
$allAccounts = Account::all(['aktiv' => 1]);
|
||||
$kategorieKonten = array_filter(
|
||||
$kategorieKonten = array_slice(array_values(array_filter(
|
||||
$allAccounts,
|
||||
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||
);
|
||||
$zahlungsKonten = array_filter(
|
||||
)), 0, 40);
|
||||
$zahlungsKonten = array_slice(array_values(array_filter(
|
||||
$allAccounts,
|
||||
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
||||
);
|
||||
)), 0, 10);
|
||||
|
||||
// Kontenliste für Prompt aufbauen
|
||||
$kategorieList = implode("\n", array_map(
|
||||
|
|
@ -61,20 +60,26 @@ class KontierungsvorschlagService
|
|||
$zahlungsKonten
|
||||
));
|
||||
|
||||
$beleginhalt = $text !== null && trim($text) !== ''
|
||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
|
||||
: '[Bilddatei – Inhalt wird direkt analysiert]';
|
||||
$beleginhalt = trim($text) !== ''
|
||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 2000)
|
||||
: '[Kein Text extrahierbar]';
|
||||
|
||||
$settings = Setting::get();
|
||||
$eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? ''));
|
||||
$eigenerHinweis = $eigenerName !== ''
|
||||
? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."
|
||||
: "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar.";
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit einem einzigen JSON-Objekt, ohne erklaerenden Text davor oder danach:
|
||||
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001"}
|
||||
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}
|
||||
|
||||
Erlaubte Werte:
|
||||
- buchungstyp: "Ausgabe" oder "Einnahme"
|
||||
- ust_satz: 0, 7 oder 19
|
||||
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen
|
||||
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen$eigenerHinweis
|
||||
|
||||
Verfuegbare Kategorie-Konten:
|
||||
$kategorieList
|
||||
|
|
@ -85,7 +90,7 @@ $zahlungsList
|
|||
$beleginhalt
|
||||
PROMPT;
|
||||
|
||||
$response = $this->llm->generate($prompt, $base64Image);
|
||||
$response = $this->llm->generate($prompt);
|
||||
if ($response === '') return null;
|
||||
|
||||
return $this->parseAndValidate($response, $allAccounts);
|
||||
|
|
@ -99,6 +104,21 @@ PROMPT;
|
|||
return $returnCode === 0 ? implode("\n", $output) : '';
|
||||
}
|
||||
|
||||
private function extractImageText(string $path): string
|
||||
{
|
||||
$tmpBase = sys_get_temp_dir() . '/ocr_' . uniqid();
|
||||
$safe = escapeshellarg($path);
|
||||
$safeTmp = escapeshellarg($tmpBase);
|
||||
exec("tesseract {$safe} {$safeTmp} -l deu+eng quiet 2>/dev/null", $out, $rc);
|
||||
$txtFile = $tmpBase . '.txt';
|
||||
if ($rc === 0 && file_exists($txtFile)) {
|
||||
$text = file_get_contents($txtFile);
|
||||
unlink($txtFile);
|
||||
return $text ?: '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function parseAndValidate(string $response, array $accounts): ?array
|
||||
{
|
||||
// JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen)
|
||||
|
|
@ -119,11 +139,25 @@ PROMPT;
|
|||
$data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0;
|
||||
$data['betrag_netto'] = round((float)$data['betrag_netto'], 2);
|
||||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||
|
||||
// IDs gegen Kontenliste validieren
|
||||
$accountIds = array_column($accounts, 'id');
|
||||
if (!in_array($data['kategorie_konto_id'], $accountIds, false)) return null;
|
||||
if (!in_array($data['zahlungskonto_id'], $accountIds, false)) return null;
|
||||
// IDs gegen Kontenliste validieren (mit Kontenartenprüfung)
|
||||
$kategorieIds = array_column(array_filter($accounts,
|
||||
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||
), 'id');
|
||||
$zahlungsIds = array_column(array_filter($accounts,
|
||||
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
||||
), 'id');
|
||||
if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) return null;
|
||||
if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) return null;
|
||||
|
||||
// Lieferant gegen vorhandene Suppliers prüfen
|
||||
if ($data['supplier_name'] !== '') {
|
||||
$existing = Supplier::findByName($data['supplier_name']);
|
||||
$data['supplier_found'] = $existing !== false;
|
||||
} else {
|
||||
$data['supplier_found'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ class LlmService
|
|||
};
|
||||
}
|
||||
|
||||
public function generate(string $prompt, ?string $base64Image = null): string
|
||||
public function generate(string $prompt): string
|
||||
{
|
||||
return match($this->mode) {
|
||||
'ollama' => $this->callOllama($prompt, $base64Image),
|
||||
'openai' => $this->callOpenAi($prompt, $base64Image),
|
||||
'ollama' => $this->callOllama($prompt),
|
||||
'openai' => $this->callOpenAi($prompt),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
|
@ -93,22 +93,19 @@ class LlmService
|
|||
return $err === 0 && $code >= 200 && $code < 300;
|
||||
}
|
||||
|
||||
private function callOllama(string $prompt, ?string $base64Image): string
|
||||
private function callOllama(string $prompt): string
|
||||
{
|
||||
$payload = [
|
||||
'model' => $this->model,
|
||||
'prompt' => $prompt,
|
||||
'stream' => false,
|
||||
];
|
||||
if ($base64Image !== null) {
|
||||
$payload['images'] = [$base64Image];
|
||||
}
|
||||
|
||||
$ch = curl_init($this->url . '/api/generate');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
]);
|
||||
|
|
@ -120,27 +117,18 @@ class LlmService
|
|||
return json_decode($response, true)['response'] ?? '';
|
||||
}
|
||||
|
||||
private function callOpenAi(string $prompt, ?string $base64Image): string
|
||||
private function callOpenAi(string $prompt): string
|
||||
{
|
||||
$content = [];
|
||||
if ($base64Image !== null) {
|
||||
$content[] = [
|
||||
'type' => 'image_url',
|
||||
'image_url' => ['url' => 'data:image/jpeg;base64,' . $base64Image],
|
||||
];
|
||||
}
|
||||
$content[] = ['type' => 'text', 'text' => $prompt];
|
||||
|
||||
$payload = [
|
||||
'model' => $this->model,
|
||||
'messages' => [['role' => 'user', 'content' => $content]],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
];
|
||||
|
||||
$ch = curl_init($this->url . '/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ $suppliers = $suppliers ?? [];
|
|||
data-belegtyp="<?= $h($beleg['belegtyp']) ?>"
|
||||
data-beleg-datum="<?= $h($beleg['beleg_datum'] ?? '') ?>"
|
||||
data-rechnungs-nr="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
|
||||
data-betrag-brutto="<?= $h($beleg['betrag_brutto'] ?? '') ?>">
|
||||
data-betrag-brutto="<?= $h($beleg['betrag_brutto'] ?? '') ?>"
|
||||
data-supplier-name="<?= $h($beleg['supplier_name'] ?? '') ?>">
|
||||
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
|
||||
<div>
|
||||
<span class="text-muted text-sm">Belegnr.</span><br>
|
||||
|
|
@ -25,8 +26,23 @@ $suppliers = $suppliers ?? [];
|
|||
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-sm">Lieferant</span><br>
|
||||
<span class="text-muted text-sm">Lieferant<?= (!$beleg['locked'] && $beleg['supplier_name']) ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
|
||||
<?php if (!$beleg['locked'] && $beleg['supplier_name']): ?>
|
||||
<span class="inline-edit-trigger"
|
||||
id="supplier-inline-trigger"
|
||||
data-field="supplier_name" data-type="text"
|
||||
data-raw="<?= $h($beleg['supplier_name']) ?>"
|
||||
title="Klicken zum Bearbeiten">
|
||||
<?= $h($beleg['supplier_name']) ?>
|
||||
</span>
|
||||
<?php elseif (!$beleg['locked']): ?>
|
||||
<input type="text" id="supplier-persistent-input"
|
||||
value="" placeholder="Lieferant eingeben…"
|
||||
list="supplier-datalist" autocomplete="off"
|
||||
style="font-size:inherit;padding:2px 5px;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;min-width:180px">
|
||||
<?php else: ?>
|
||||
<?= $beleg['supplier_name'] ? $h($beleg['supplier_name']) : '<span class="text-muted">–</span>' ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-sm">Belegdatum<?= !$beleg['locked'] ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
|
||||
|
|
@ -263,9 +279,13 @@ $suppliers = $suppliers ?? [];
|
|||
$beleg['belegtyp'] === 'Ausgangsrechnung' ? 'Einnahme' : 'Ausgabe'
|
||||
);
|
||||
|
||||
// Konten nach Art gruppieren
|
||||
// Konten nach Art gruppieren (Zahlungskonto nur 1200–1299)
|
||||
$kontenGruppen = [];
|
||||
foreach ($accounts as $acc) {
|
||||
if ($acc['kontenart'] === 'Zahlungskonto') {
|
||||
$nr = (int)$acc['kontonummer'];
|
||||
if ($nr < 1200 || $nr > 1299) continue;
|
||||
}
|
||||
$kontenGruppen[$acc['kontenart']][] = $acc;
|
||||
}
|
||||
?>
|
||||
|
|
@ -416,11 +436,11 @@ $suppliers = $suppliers ?? [];
|
|||
</div>
|
||||
|
||||
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
|
||||
<span id="eigenanteil_group"
|
||||
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none">
|
||||
<label id="eigenanteil_group"
|
||||
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none;cursor:not-allowed">
|
||||
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled>
|
||||
<span>30% Eigenanteil buchen</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
||||
|
|
@ -475,7 +495,7 @@ function updateEigenanteilState() {
|
|||
|
||||
group.style.opacity = aktiv ? '1' : '.35';
|
||||
group.style.pointerEvents = aktiv ? '' : 'none';
|
||||
group.querySelector('label').style.cursor = aktiv ? 'pointer' : 'not-allowed';
|
||||
group.style.cursor = aktiv ? 'pointer' : 'not-allowed';
|
||||
cb.disabled = !aktiv;
|
||||
if (!aktiv) cb.checked = false;
|
||||
}
|
||||
|
|
@ -486,6 +506,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (checked) filterKategorieKonten(checked.value);
|
||||
updateSteuerartVisibility();
|
||||
updateEigenanteilState();
|
||||
|
||||
// Leeres Lieferantenfeld fokussieren wenn kein KI-Block aktiv
|
||||
const sup = document.getElementById('supplier-persistent-input');
|
||||
if (sup && !document.getElementById('ki-badge')) sup.focus();
|
||||
});
|
||||
|
||||
function updateSteuerartVisibility() {
|
||||
|
|
@ -509,26 +533,70 @@ function updateSteuerartVisibility() {
|
|||
const _belegId = <?= (int)$beleg['id'] ?>;
|
||||
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
|
||||
|
||||
function _selectById(selectEl, id) {
|
||||
if (!selectEl) return;
|
||||
const str = String(id);
|
||||
for (const opt of selectEl.options) {
|
||||
opt.selected = (opt.value === str);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyKiVorschlag(v) {
|
||||
const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]');
|
||||
if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); }
|
||||
const ustEl = document.getElementById('ust_satz_select');
|
||||
if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); }
|
||||
const katEl = document.getElementById('kategorie_konto_id');
|
||||
if (katEl) { katEl.value = String(v.kategorie_konto_id); updateEigenanteilState(); }
|
||||
const zhlEl = document.getElementById('zahlungskonto_id');
|
||||
if (zhlEl) zhlEl.value = String(v.zahlungskonto_id);
|
||||
const katEl2 = document.getElementById('kategorie_konto_id');
|
||||
_selectById(katEl2, v.kategorie_konto_id);
|
||||
if (katEl2) transferKontoNr(katEl2);
|
||||
updateEigenanteilState();
|
||||
const zhlEl2 = document.getElementById('zahlungskonto_id');
|
||||
_selectById(zhlEl2, v.zahlungskonto_id);
|
||||
if (zhlEl2) transferKontoNr(zhlEl2);
|
||||
const netEl = document.querySelector('input[name="betrag_netto"]');
|
||||
if (netEl && v.betrag_netto) netEl.value = v.betrag_netto;
|
||||
const txtEl = document.querySelector('input[name="buchungstext"]');
|
||||
if (txtEl && v.buchungstext) txtEl.value = v.buchungstext;
|
||||
|
||||
// Lieferant aus KI-Vorschlag übernehmen
|
||||
if (v.supplier_name) {
|
||||
const bar = document.querySelector('[data-beleg-id]');
|
||||
const persistentInput = document.getElementById('supplier-persistent-input');
|
||||
const trigger = document.getElementById('supplier-inline-trigger');
|
||||
if (persistentInput) {
|
||||
persistentInput.value = v.supplier_name;
|
||||
if (bar) bar.dataset.supplierName = v.supplier_name;
|
||||
if (v.supplier_found === false) persistentInput.focus();
|
||||
} else if (trigger) {
|
||||
trigger.dataset.raw = v.supplier_name;
|
||||
if (bar) bar.dataset.supplierName = v.supplier_name;
|
||||
trigger.textContent = v.supplier_name;
|
||||
if (v.supplier_found === false && !trigger.querySelector('input')) {
|
||||
trigger.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _showKiBadgeDone() {
|
||||
function _showKiBadgeDone(v) {
|
||||
const katEl = document.getElementById('kategorie_konto_id');
|
||||
const zhlEl = document.getElementById('zahlungskonto_id');
|
||||
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
|
||||
const zhlOpt = zhlEl ? zhlEl.querySelector('option[value="' + v.zahlungskonto_id + '"]') : null;
|
||||
const katTxt = katOpt ? katOpt.text : v.kategorie_konto_id;
|
||||
const zhlTxt = zhlOpt ? zhlOpt.text : v.zahlungskonto_id;
|
||||
const supTxt = v.supplier_name
|
||||
? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
|
||||
: '';
|
||||
const details =
|
||||
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
|
||||
'USt ' + v.ust_satz + ' % · ' +
|
||||
'Kategorie: ' + katTxt + ' · ' +
|
||||
'Zahlung: ' + zhlTxt + supTxt;
|
||||
document.getElementById('ki-badge').innerHTML =
|
||||
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center">' +
|
||||
'<span>KI-Kontierungsvorschlag – Formular vorausgefüllt. Bitte prüfen und ggf. anpassen.</span>' +
|
||||
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
||||
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center;gap:1rem">' +
|
||||
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span></div>' +
|
||||
'<button class="btn btn-secondary btn-sm" style="flex-shrink:0" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
|
|
@ -549,7 +617,7 @@ async function _startKiAnalyse() {
|
|||
try {
|
||||
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
||||
const v = await r.json();
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); }
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
||||
else _showKiBadgeError();
|
||||
} catch (e) {
|
||||
_showKiBadgeError();
|
||||
|
|
@ -559,13 +627,13 @@ async function _startKiAnalyse() {
|
|||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
|
||||
_applyKiVorschlag(_kiVorschlag);
|
||||
_showKiBadgeDone();
|
||||
_showKiBadgeDone(_kiVorschlag);
|
||||
} else {
|
||||
_showKiBadgeLoading();
|
||||
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(v => {
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); }
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
||||
else _showKiBadgeError();
|
||||
})
|
||||
.catch(() => _showKiBadgeError());
|
||||
|
|
@ -573,3 +641,54 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$beleg['locked']): ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const _sup = document.getElementById('supplier-persistent-input');
|
||||
if (!_sup) return;
|
||||
const _bar = _sup.closest('[data-beleg-id]');
|
||||
async function _saveSupplier() {
|
||||
const newValue = _sup.value.trim();
|
||||
if (!_bar) return;
|
||||
const body = new URLSearchParams({
|
||||
rechnungs_nr: _bar.dataset.rechnungsNr || '',
|
||||
belegtyp: _bar.dataset.belegtyp || '',
|
||||
beleg_datum: _bar.dataset.belegDatum || '',
|
||||
betrag_brutto: _bar.dataset.betragBrutto || '',
|
||||
supplier_name: newValue,
|
||||
});
|
||||
try {
|
||||
const res = await fetch('/belege/<?= (int)$beleg['id'] ?>/meta', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
if (newValue) {
|
||||
_bar.dataset.supplierName = newValue;
|
||||
// Hidden-Metaformular synchron halten
|
||||
const hiddenInput = document.getElementById('supplier_name_input');
|
||||
if (hiddenInput) hiddenInput.value = newValue;
|
||||
// Persistentes Input durch Inline-Trigger ersetzen (= visuelle Bestätigung)
|
||||
const span = document.createElement('span');
|
||||
span.className = 'inline-edit-trigger';
|
||||
span.id = 'supplier-inline-trigger';
|
||||
span.dataset.field = 'supplier_name';
|
||||
span.dataset.type = 'text';
|
||||
span.dataset.raw = newValue;
|
||||
span.title = 'Klicken zum Bearbeiten';
|
||||
span.textContent = newValue;
|
||||
_sup.replaceWith(span);
|
||||
}
|
||||
} catch (_) {
|
||||
// Speichern fehlgeschlagen – Feld bleibt editierbar
|
||||
}
|
||||
}
|
||||
_sup.addEventListener('blur', _saveSupplier);
|
||||
_sup.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); _sup.blur(); }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,94 @@
|
|||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||
|
||||
<!-- Drag & Drop Upload -->
|
||||
<div id="bl-dnd-zone" style="
|
||||
border:2px dashed var(--color-border);
|
||||
border-radius:var(--radius);
|
||||
background:var(--color-surface);
|
||||
padding:2rem;
|
||||
text-align:center;
|
||||
margin-bottom:1.5rem;
|
||||
cursor:pointer;
|
||||
transition:border-color .15s, background .15s;
|
||||
position:relative;
|
||||
">
|
||||
<input type="file" id="bl-dnd-file-input" accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
|
||||
style="position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%">
|
||||
<div id="bl-dnd-icon" style="font-size:2rem;line-height:1;margin-bottom:.5rem;pointer-events:none">📎</div>
|
||||
<div style="font-weight:600;pointer-events:none">Beleg hierher ziehen oder klicken</div>
|
||||
<div class="text-muted text-sm" style="pointer-events:none">PDF, PNG, JPEG, TIFF, XML · max. 20 MB</div>
|
||||
<div id="bl-dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const zone = document.getElementById('bl-dnd-zone');
|
||||
const input = document.getElementById('bl-dnd-file-input');
|
||||
const status = document.getElementById('bl-dnd-status');
|
||||
const icon = document.getElementById('bl-dnd-icon');
|
||||
|
||||
function setActive(on) {
|
||||
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
|
||||
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
|
||||
}
|
||||
|
||||
function setStatus(msg, color) {
|
||||
status.innerHTML = `<span style="color:${color || 'var(--color-muted)'}">${msg}</span>`;
|
||||
}
|
||||
|
||||
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });
|
||||
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); });
|
||||
zone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
setActive(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) uploadFile(file);
|
||||
});
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files[0]) uploadFile(input.files[0]);
|
||||
input.value = '';
|
||||
});
|
||||
|
||||
function uploadFile(file) {
|
||||
const allowed = ['application/pdf','image/png','image/jpeg','image/tiff',
|
||||
'application/xml','text/xml'];
|
||||
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|png|jpe?g|tif{1,2}|xml)$/i)) {
|
||||
setStatus('Nicht unterstütztes Dateiformat.', 'var(--color-danger)');
|
||||
return;
|
||||
}
|
||||
|
||||
icon.textContent = '⏳';
|
||||
setStatus('Wird hochgeladen…');
|
||||
zone.style.pointerEvents = 'none';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('beleg_file', file);
|
||||
fd.append('belegtyp', 'Eingangsrechnung');
|
||||
fd.append('eingangs_datum', new Date().toISOString().slice(0,10));
|
||||
fd.append('beleg_datum', new Date().toISOString().slice(0,10));
|
||||
|
||||
fetch('/belege/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: fd,
|
||||
})
|
||||
.then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Unbekannter Fehler');
|
||||
icon.textContent = '✅';
|
||||
setStatus(`${data.beleg_nr} hochgeladen – weiterleiten…`, 'var(--color-success)');
|
||||
setTimeout(() => { window.location.href = '/belege/' + data.id; }, 600);
|
||||
})
|
||||
.catch((err) => {
|
||||
icon.textContent = '📎';
|
||||
zone.style.pointerEvents = '';
|
||||
setStatus('Fehler: ' + err.message, 'var(--color-danger)');
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue