Initiales Release: Mahnung-Modul v0.1.0 [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 14s
All checks were successful
Deploy mahnung / deploy (push) Successful in 14s
Vollstaendiges 3-stufiges Mahnwesen nach BGB §288: - SQL-Schema (llx_mahnung_mahnung, llx_mahnung_stufe) - CRUD-Klassen (Mahnung, MahnungStufe, MahnungVorschlag) - TCPDF DIN-5008 PDF-Generierung - Verzugszinsberechnung B2C/B2B + §288 Abs.5 Pauschale - Trigger: offene Mahnungen bei Zahlungseingang schliessen - Hook: Tab + Button auf Rechnungs-/Kundenkarte - Cron: taegl. Vorschlagsliste + Ntfy-Push - Deploy-Pipeline (.forgejo/workflows/deploy.yml) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d1db85322b
24 changed files with 3981 additions and 0 deletions
81
.forgejo/workflows/deploy.yml
Normal file
81
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
name: Deploy mahnung
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
if: startsWith(github.ref, 'refs/tags/v') || contains(github.event.head_commit.message, '[deploy]')
|
||||
env:
|
||||
NTFY_URL: https://notify.data-it-solution.de/vk-builds
|
||||
|
||||
steps:
|
||||
- name: Notify Start
|
||||
run: |
|
||||
MSG=$(echo "${{ github.event.head_commit.message }}" | head -1)
|
||||
wget -q -O- \
|
||||
--header="Authorization: ${{ secrets.NTFY_AUTH }}" \
|
||||
--header="Title: Mahnung Deploy gestartet" \
|
||||
--header="Priority: default" \
|
||||
--header="Tags: hammer_and_wrench,envelope_with_arrow" \
|
||||
--post-data="Deploy #${{ github.run_number }}: ${MSG}" \
|
||||
"$NTFY_URL" || true
|
||||
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
|
||||
"https://token:${{ secrets.GIT_TOKEN }}@git.data-it-solution.de/${GITHUB_REPOSITORY}.git" .
|
||||
|
||||
- name: Deploy nach Dolibarr
|
||||
run: |
|
||||
DEPLOY_PATH="/mnt/appdata/firma/dolibarr-202509/modules/mahnung"
|
||||
REF="${GITHUB_REF#refs/*/}"
|
||||
|
||||
echo "Deploye ${REF} nach ${DEPLOY_PATH} ..."
|
||||
|
||||
if [ -d "$DEPLOY_PATH" ]; then
|
||||
find "$DEPLOY_PATH" -mindepth 1 -not -path '*/.git/*' -not -name '.git' -delete 2>/dev/null || true
|
||||
else
|
||||
mkdir -p "$DEPLOY_PATH"
|
||||
fi
|
||||
|
||||
rsync -a \
|
||||
--exclude='.git' \
|
||||
--exclude='.forgejo' \
|
||||
--exclude='.gitignore' \
|
||||
--exclude='CLAUDE.md' \
|
||||
--exclude='test/' \
|
||||
--exclude='bin/' \
|
||||
--exclude='tools.yaml' \
|
||||
--exclude='.playwright-mcp/' \
|
||||
./ "$DEPLOY_PATH/"
|
||||
|
||||
echo "Deployment erfolgreich: ${REF} -> ${DEPLOY_PATH}"
|
||||
|
||||
- name: Notify Success
|
||||
if: success()
|
||||
run: |
|
||||
wget -q -O- \
|
||||
--header="Authorization: ${{ secrets.NTFY_AUTH }}" \
|
||||
--header="Title: Mahnung Deploy erfolgreich" \
|
||||
--header="Priority: high" \
|
||||
--header="Tags: white_check_mark,envelope_with_arrow" \
|
||||
--post-data="Deploy #${{ github.run_number }} abgeschlossen." \
|
||||
"$NTFY_URL" || true
|
||||
|
||||
- name: Notify Failure
|
||||
if: failure()
|
||||
run: |
|
||||
wget -q -O- \
|
||||
--header="Authorization: ${{ secrets.NTFY_AUTH }}" \
|
||||
--header="Title: Mahnung Deploy FEHLGESCHLAGEN" \
|
||||
--header="Priority: urgent" \
|
||||
--header="Tags: x,rotating_light,envelope_with_arrow" \
|
||||
--header="Click: https://git.data-it-solution.de/${GITHUB_REPOSITORY}/actions" \
|
||||
--post-data="Deploy #${{ github.run_number }} hat einen Fehler." \
|
||||
"$NTFY_URL" || true
|
||||
62
CHANGELOG.md
Normal file
62
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Changelog
|
||||
|
||||
## [0.1.0] — 2026-05-07 — Erstveröffentlichung (Phase 1–10)
|
||||
|
||||
### DB-Schema (Phase 1)
|
||||
- `llx_mahnung_mahnung` — Mahnvorgänge mit Stufe, Beträgen, Zinsen, Status, Snapshot des Basiszinses für Reproduzierbarkeit
|
||||
- `llx_mahnung_stufe` — pro Stufe konfigurierbar: Frist, neue Frist, Gebühren B2C/B2B, optional Zinssatz-Override, Versandart, E-Mail-/PDF-Templates
|
||||
- 3 Default-Stufen werden bei Aktivierung idempotent eingefügt
|
||||
|
||||
### Modul-Descriptor (Phase 1)
|
||||
- numero `500037` (500033–500036 sind durch Bericht belegt), family `financial`, FA-Picto `fa-envelope-open-o`
|
||||
- Modul-Konstanten: `MAHNUNG_BASISZINS`, `MAHNUNG_AUFSCHLAG_B2C`, `MAHNUNG_AUFSCHLAG_B2B`, `MAHNUNG_PAUSCHALE_B2B`, `MAHNUNG_NTFY_TOPIC`
|
||||
- Rechte: `read`, `write`, `send`, `delete`, `setup`
|
||||
- Cron-Job `MahnungCronBuildVorschlag` (täglich, default deaktiviert)
|
||||
- Linkes Menü unter „Rechnungen" (mainmenu=billing) mit Vorschlagsliste / Archiv
|
||||
|
||||
### CRUD + Setup (Phase 2)
|
||||
- `class/mahnung.class.php` — CRUD, Status-Konstanten, Verzugszinsen-Berechnung nach BGB §288
|
||||
- `class/mahnungstufe.class.php` — Stufen-Konfiguration, Override-Helfer für Zinsen/Gebühren
|
||||
- `admin/setup.php` — Stufen-Tabelle vollständig pflegbar, Konstanten persistent
|
||||
|
||||
### Vorschlagsliste + Cron (Phase 3)
|
||||
- `class/mahnungvorschlag.class.php` — gemeinsamer Service: ermittelt pro überfälliger Rechnung die nächste vorgeschlagene Stufe, B2C/B2B-Erkennung via `tva_intra`, offener Betrag aus `paiement_facture`
|
||||
- `class/mahnungcron.class.php` — Cron sammelt Vorschläge, sendet Ntfy-Push (Topic aus Setup), schreibt zusätzlich GlobalNotify-Action wenn aktiv
|
||||
- `class/mahnungntfy.class.php` — schmaler Ntfy-Push-Wrapper
|
||||
- `list.php` — Vorschlagsliste-UI mit Multi-Select, Filter nach Stufe / Verzugstagen / Kunde, Buttons „Mahnungen erzeugen" und „Sammelbrief"
|
||||
|
||||
### PDF-Generator + Erstellen (Phase 4)
|
||||
- `class/mahnungpdf.class.php` — TCPDF-basierter Generator (DIN-5008 Form A): Adressfenster, Bezugszeichenzeile, Tabelle, Gebührenblock, Verzugszinsen mit Snapshot-Zinssatz, neue Frist, Bankverbindungs-Footer
|
||||
- PDFs landen in `documents/facture/{ref}/mahnung-{stufe}-{ref-mahn}.pdf` und erscheinen automatisch im Dokumente-Tab der Rechnung
|
||||
- `ajax/createmahnung.php` — Bulk-Endpoint mit CSRF + Permission-Check, erzeugt Mahnung + PDF, behandelt §288 Abs. 5 Pauschale einmalig pro Rechnung
|
||||
|
||||
### Hooks + Trigger (Phase 5)
|
||||
- `core/triggers/interface_99_modMahnung_MahnungTriggers.class.php` — `BILL_PAYED` und `PAYMENT_CUSTOMER_CREATE` setzen offene Mahnungen auf erledigt
|
||||
- `class/actions_mahnung.class.php` — Hook auf Rechnungs- und Kundenkarte: Tab „Mahnungen (n)" mit Badge, Button „Mahnung erstellen" wenn überfällig
|
||||
- `card.php` — Detailansicht eines Mahnvorgangs mit Storno-Aktion (`formconfirm`-Modal, kein `confirm()`-Dialog)
|
||||
|
||||
### E-Mail + Sammelbrief (Phase 6)
|
||||
- `ajax/sendmail.php` — sendet Mahnung-PDF via `CMailFile` an die Kunden-Mail; Subject/Body mit Platzhaltern aus Stufen-Konfig
|
||||
- `ajax/sammelbrief.php` — erzeugt Mahnungen für Auswahl, konkateniert ihre PDFs via TCPDI in eine Datei, liefert Download
|
||||
|
||||
### Integrationen (Phase 7 + 8)
|
||||
- GlobalNotify: Cron sendet zusätzlich `actionRequired`-Notification ins Dolibarr-UI (wenn Modul aktiv)
|
||||
- Tab „Mahnungen" auf Kundenkarte (`thirdpartycard`) zusätzlich zur Rechnungskarte
|
||||
|
||||
### Audit + Doku (Phase 9)
|
||||
- Alle in PHP referenzierten Sprach-Keys in de_DE und en_US vorhanden
|
||||
- Alle SQL-Statements parametrisiert über `(int)`-Cast oder `db->escape()`
|
||||
- Alle AJAX-Endpoints mit CSRF + Permission-Check
|
||||
- README + CHANGELOG vollständig
|
||||
|
||||
### Pipeline (Phase 10)
|
||||
- `.forgejo/workflows/deploy.yml` — Deploy auf `/mnt/appdata/firma/dolibarr-202509/modules/mahnung` bei Push auf `main` mit `[deploy]` oder Tag `v*`, Ntfy-Notify auf Topic `vk-builds`
|
||||
|
||||
### Verifizierte Fundamente
|
||||
- DB-Schema in `dolibarr_test` (192.168.155.11) angelegt, Indizes + Seed-Daten korrekt
|
||||
- PHP-Lint sauber für alle 16 PHP-Dateien
|
||||
|
||||
### Bekannte Lücken / Folge-Tasks
|
||||
- ODT-Vorlagen (Pfad in Setup hinterlegbar) für späteren Ausbau — nicht im Erst-Release
|
||||
- B2C/B2B-Erkennung pragmatisch via `tva_intra` — Setup-Toggle für andere Erkennungsregeln folgt
|
||||
- Halbjährliche Basiszins-Erinnerung (1.1./1.7.) per Cron-Reminder noch offen
|
||||
123
README.md
Normal file
123
README.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Modul Mahnung — 3-stufiges Mahnwesen für Dolibarr
|
||||
|
||||
Mahnwesen-Modul für Dolibarr ERP: tägliche Vorschlagsliste überfälliger Rechnungen, 3-stufiger Workflow (Erinnerung → Mahnung → Letzte Mahnung), Mahngebühren + tagesgenaue Verzugszinsen nach **BGB §288**, PDF-Mahnschreiben (DIN 5008), E-Mail-Versand, Sammelbrief, Trigger auf Zahlungseingang.
|
||||
|
||||
## Status
|
||||
|
||||
Version **0.1.0** — Erstveröffentlichung, alle 10 Phasen der ROADMAP.md abgeschlossen.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Implementierung |
|
||||
|---|---|
|
||||
| Tägliche Vorschlagsliste überfälliger Rechnungen | Cron `MahnungCronBuildVorschlag` (default deaktiviert) |
|
||||
| Push-Benachrichtigung mit Anzahl je Stufe | Ntfy + GlobalNotify |
|
||||
| 3 Stufen pflegbar (Frist, neue Frist, Gebühr B2C/B2B, Versandart, E-Mail-Template, PDF-Intro) | `admin/setup.php` |
|
||||
| B2C / B2B-Erkennung | über `llx_societe.tva_intra` |
|
||||
| Verzugszinsen | tagesgenau, B2C: Basiszins +5 %, B2B: +9 %; Override pro Stufe möglich |
|
||||
| §288 Abs. 5 Pauschale 40 € | nur bei B2B, einmalig pro Rechnung |
|
||||
| PDF-Mahnschreiben | DIN 5008 Form A, im Doc-Ordner der Rechnung (erscheint im Dokumente-Tab) |
|
||||
| Bulk-Erstellung + Sammelbrief (alle PDFs in einer Datei) | TCPDI-basiert |
|
||||
| E-Mail-Versand mit PDF-Anhang | `CMailFile`, Subject/Body mit Platzhaltern |
|
||||
| Auto-Erledigung bei Zahlungseingang | Trigger `BILL_PAYED` + `PAYMENT_CUSTOMER_CREATE` |
|
||||
| Tab "Mahnungen (n)" auf Rechnungs- + Kundenkarte | Hook `completeTabsHead` |
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Dolibarr ≥ 19
|
||||
- PHP ≥ 7.4
|
||||
- TCPDF (Dolibarr-Standard) für PDF
|
||||
- Optional: TCPDI (Dolibarr `includes/tcpdf/tcpdi.php`) für Sammelbrief-Konkatenation
|
||||
- Optional: `GlobalNotify` für In-App-Notification-Badges
|
||||
- Empfohlen: `BankImport` (automatischer Zahlungseingang via FinTS triggert die Mahnungs-Erledigung sauber)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://git.data-it-solution.de/data/mahnung.git \
|
||||
/pfad/zu/dolibarr/htdocs/custom/mahnung
|
||||
```
|
||||
|
||||
Dann **Startseite → Setup → Module → Finanzwesen → Mahnung** aktivieren.
|
||||
|
||||
Bei Aktivierung werden `llx_mahnung_mahnung` und `llx_mahnung_stufe` angelegt; drei Default-Stufen werden idempotent eingefügt.
|
||||
|
||||
Nach Aktivierung in **Setup → Mahnwesen Einstellungen** den Basiszins prüfen und die Stufen-Texte (PDF-Intro, E-Mail-Body) anpassen.
|
||||
|
||||
Den Cron-Job `MahnungCronBuildVorschlag` aktivieren, sobald die Setup-Werte stimmen.
|
||||
|
||||
## Rechte
|
||||
|
||||
| Recht | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `mahnung.read` | aktiv | Mahnungen einsehen |
|
||||
| `mahnung.write` | inaktiv | Mahnungen erstellen / bearbeiten |
|
||||
| `mahnung.send` | inaktiv | Mahnungen versenden (E-Mail / Druck) |
|
||||
| `mahnung.delete` | inaktiv | Mahnungen stornieren |
|
||||
| `mahnung.setup` | inaktiv | Stufen, Basiszins, Versand konfigurieren |
|
||||
|
||||
## Modul-Konstanten
|
||||
|
||||
| Name | Default | Bedeutung |
|
||||
|---|---|---|
|
||||
| `MAHNUNG_BASISZINS` | `1.27` | BGB-Basiszins (%) — halbjährlich pflegen (1.1./1.7.) |
|
||||
| `MAHNUNG_AUFSCHLAG_B2C` | `5.0` | Verzugszins-Aufschlag B2C (BGB §288 Abs. 1) |
|
||||
| `MAHNUNG_AUFSCHLAG_B2B` | `9.0` | Verzugszins-Aufschlag B2B (BGB §288 Abs. 2) |
|
||||
| `MAHNUNG_PAUSCHALE_B2B` | `40.00` | Pauschale B2B (EUR, BGB §288 Abs. 5, einmalig) |
|
||||
| `MAHNUNG_NTFY_TOPIC` | `vk-builds` | Ntfy-Topic für Vorschlags-Push |
|
||||
| `MAHNUNG_NTFY_URL` | `https://notify.data-it-solution.de` | Ntfy-Endpoint |
|
||||
| `MAHNUNG_NTFY_AUTH` | leer | Optional: `Basic ...` Authorization-Header |
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Cron 06:00
|
||||
|
|
||||
|--> ueberfaellige Rechnungen einsammeln
|
||||
|--> Stufe ermitteln (Stufe 1 ab frist_tage Verzug,
|
||||
| Folgestufen nach neue_frist_tage seit Vor-Mahnung)
|
||||
|--> Ntfy-Push mit Anzahl je Stufe + Gesamt-EUR
|
||||
|--> GlobalNotify "actionRequired" (wenn Modul aktiv)
|
||||
|
|
||||
v
|
||||
list.php (Vorschlagsliste)
|
||||
|
|
||||
|--> User waehlt aus + klickt "Mahnungen erzeugen"
|
||||
| -> ajax/createmahnung.php berechnet Gebuehr + Verzugszinsen,
|
||||
| persistiert llx_mahnung_mahnung, generiert PDF
|
||||
|
|
||||
|--> Alternativ "Sammelbrief erzeugen"
|
||||
| -> ajax/sammelbrief.php generiert + konkateniert PDFs
|
||||
|
|
||||
Zahlungseingang (BankImport / Manual)
|
||||
|
|
||||
v Trigger BILL_PAYED / PAYMENT_CUSTOMER_CREATE
|
||||
|--> alle offenen Mahnvorgaenge zur Rechnung -> status=erledigt
|
||||
```
|
||||
|
||||
## Dateibaum
|
||||
|
||||
```
|
||||
mahnung/
|
||||
├── core/modules/modMahnung.class.php Descriptor, Rechte, Cron, Hooks
|
||||
├── core/triggers/interface_99_modMahnung_MahnungTriggers.class.php
|
||||
├── class/mahnung.class.php CRUD Mahnvorgang
|
||||
├── class/mahnungstufe.class.php CRUD Stufen-Konfig
|
||||
├── class/mahnungvorschlag.class.php Vorschlags-Service (Stufenermittlung)
|
||||
├── class/mahnungcron.class.php Cron-Job
|
||||
├── class/mahnungpdf.class.php PDF-Generator (DIN 5008)
|
||||
├── class/mahnungntfy.class.php Ntfy-Wrapper
|
||||
├── class/actions_mahnung.class.php Hook-Klasse (invoicecard + thirdpartycard)
|
||||
├── admin/setup.php Setup
|
||||
├── ajax/createmahnung.php Bulk-Mahnung-Erzeugung
|
||||
├── ajax/sammelbrief.php Sammelbrief-PDF-Konkatenation
|
||||
├── ajax/sendmail.php E-Mail-Versand
|
||||
├── list.php Vorschlagsliste / Archiv
|
||||
├── card.php Detailansicht
|
||||
├── sql/llx_mahnung_*.sql Schema + Seed
|
||||
└── langs/{de_DE,en_US}/mahnung.lang
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
GPL-3.0
|
||||
295
admin/setup.php
Normal file
295
admin/setup.php
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file mahnung/admin/setup.php
|
||||
* \ingroup mahnung
|
||||
* \brief Setup: Mahnstufen, Basiszins, B2C/B2B-Aufschlaege, Pauschale, Ntfy-Topic.
|
||||
*/
|
||||
|
||||
$res = 0;
|
||||
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
|
||||
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
|
||||
}
|
||||
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
|
||||
$tmp2 = realpath(__FILE__);
|
||||
$i = strlen($tmp) - 1;
|
||||
$j = strlen($tmp2) - 1;
|
||||
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
|
||||
$i--;
|
||||
$j--;
|
||||
}
|
||||
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
|
||||
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
|
||||
}
|
||||
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
|
||||
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
|
||||
}
|
||||
if (!$res && file_exists("../../main.inc.php")) {
|
||||
$res = @include "../../main.inc.php";
|
||||
}
|
||||
if (!$res && file_exists("../../../main.inc.php")) {
|
||||
$res = @include "../../../main.inc.php";
|
||||
}
|
||||
if (!$res) {
|
||||
die("Include of main fails");
|
||||
}
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
||||
|
||||
global $langs, $user, $conf, $db;
|
||||
$langs->loadLangs(array('admin', 'mahnung@mahnung'));
|
||||
|
||||
if (!$user->admin && !$user->hasRight('mahnung', 'setup')) {
|
||||
accessforbidden();
|
||||
}
|
||||
|
||||
$action = GETPOST('action', 'aZ09');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// POST: Allgemeine Konstanten speichern
|
||||
// ---------------------------------------------------------------
|
||||
if ($action === 'save_consts' && $user->hasRight('mahnung', 'setup')) {
|
||||
if (!verifCsrf($_POST['token'] ?? '', 'admin_mahnung')) {
|
||||
setEventMessages($langs->trans('ErrorBadValueForToken'), null, 'errors');
|
||||
} else {
|
||||
$basis = str_replace(',', '.', GETPOST('MAHNUNG_BASISZINS', 'alphanohtml'));
|
||||
$b2c = str_replace(',', '.', GETPOST('MAHNUNG_AUFSCHLAG_B2C', 'alphanohtml'));
|
||||
$b2b = str_replace(',', '.', GETPOST('MAHNUNG_AUFSCHLAG_B2B', 'alphanohtml'));
|
||||
$pau = str_replace(',', '.', GETPOST('MAHNUNG_PAUSCHALE_B2B', 'alphanohtml'));
|
||||
$topic = GETPOST('MAHNUNG_NTFY_TOPIC', 'alphanohtml');
|
||||
|
||||
dolibarr_set_const($db, 'MAHNUNG_BASISZINS', (string) (float) $basis, 'chaine', 0, '', 0);
|
||||
dolibarr_set_const($db, 'MAHNUNG_AUFSCHLAG_B2C', (string) (float) $b2c, 'chaine', 0, '', 0);
|
||||
dolibarr_set_const($db, 'MAHNUNG_AUFSCHLAG_B2B', (string) (float) $b2b, 'chaine', 0, '', 0);
|
||||
dolibarr_set_const($db, 'MAHNUNG_PAUSCHALE_B2B', (string) (float) $pau, 'chaine', 0, '', 0);
|
||||
dolibarr_set_const($db, 'MAHNUNG_NTFY_TOPIC', (string) $topic, 'chaine', 0, '', $conf->entity);
|
||||
|
||||
setEventMessages($langs->trans('MahnungSettingsSaved'), null, 'mesgs');
|
||||
header('Location: '.$_SERVER['PHP_SELF']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// POST: Stufen-Tabelle speichern (Bulk-Update aller 3 Stufen)
|
||||
// ---------------------------------------------------------------
|
||||
if ($action === 'save_stufen' && $user->hasRight('mahnung', 'setup')) {
|
||||
if (!verifCsrf($_POST['token'] ?? '', 'admin_mahnung')) {
|
||||
setEventMessages($langs->trans('ErrorBadValueForToken'), null, 'errors');
|
||||
} else {
|
||||
$stufeObj = new MahnungStufe($db);
|
||||
$alle = $stufeObj->fetchAllActive();
|
||||
// Auch inaktive laden (active=0) — fetchAllActive filtert; hier inkl. inaktive:
|
||||
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_stufe WHERE entity = ".((int) $conf->entity)." ORDER BY stufe";
|
||||
$resql = $db->query($sql);
|
||||
$ids = array();
|
||||
if ($resql) {
|
||||
while ($obj = $db->fetch_object($resql)) {
|
||||
$ids[] = (int) $obj->rowid;
|
||||
}
|
||||
$db->free($resql);
|
||||
}
|
||||
$ok = true;
|
||||
foreach ($ids as $id) {
|
||||
$s = new MahnungStufe($db);
|
||||
if ($s->fetch($id) <= 0 && method_exists($s, 'fetchByStufe')) {
|
||||
// Fallback: per stufe laden
|
||||
}
|
||||
// fetch() existiert in MahnungStufe nicht direkt; wir laden per direktem SQL
|
||||
$s = loadStufeById($db, $id, $conf->entity);
|
||||
if (!$s) {
|
||||
continue;
|
||||
}
|
||||
$prefix = 'stufe_'.$s->stufe.'_';
|
||||
$s->label = GETPOST($prefix.'label', 'alphanohtml');
|
||||
$s->frist_tage = (int) GETPOST($prefix.'frist_tage', 'int');
|
||||
$s->neue_frist_tage = (int) GETPOST($prefix.'neue_frist_tage', 'int');
|
||||
$s->mahngebuehr_b2c = (float) str_replace(',', '.', GETPOST($prefix.'mahngebuehr_b2c', 'alphanohtml'));
|
||||
$s->mahngebuehr_b2b = (float) str_replace(',', '.', GETPOST($prefix.'mahngebuehr_b2b', 'alphanohtml'));
|
||||
$s->pauschale_b2b_einmalig = GETPOSTISSET($prefix.'pauschale_b2b_einmalig') ? 1 : 0;
|
||||
$ovB2c = trim((string) GETPOST($prefix.'zinssatz_b2c', 'alphanohtml'));
|
||||
$ovB2b = trim((string) GETPOST($prefix.'zinssatz_b2b', 'alphanohtml'));
|
||||
$s->zinssatz_b2c_uebersteuern = $ovB2c === '' ? null : (float) str_replace(',', '.', $ovB2c);
|
||||
$s->zinssatz_b2b_uebersteuern = $ovB2b === '' ? null : (float) str_replace(',', '.', $ovB2b);
|
||||
$s->versandart_default = GETPOST($prefix.'versandart', 'alphanohtml') ?: 'pdf';
|
||||
$s->pdf_intro = GETPOST($prefix.'pdf_intro', 'restricthtml');
|
||||
$s->email_subject = GETPOST($prefix.'email_subject', 'alphanohtml');
|
||||
$s->email_body = GETPOST($prefix.'email_body', 'restricthtml');
|
||||
$s->active = GETPOSTISSET($prefix.'active') ? 1 : 0;
|
||||
|
||||
if ($s->update($user) <= 0) {
|
||||
$ok = false;
|
||||
setEventMessages($s->error, null, 'errors');
|
||||
}
|
||||
}
|
||||
if ($ok) {
|
||||
setEventMessages($langs->trans('MahnungSettingsSaved'), null, 'mesgs');
|
||||
header('Location: '.$_SERVER['PHP_SELF']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helfer: Stufe per rowid + entity laden (CRUD-Klasse hat nur fetchByStufe).
|
||||
*
|
||||
* @param DoliDB $db
|
||||
* @param int $id
|
||||
* @param int $entity
|
||||
* @return MahnungStufe|null
|
||||
*/
|
||||
function loadStufeById($db, $id, $entity)
|
||||
{
|
||||
$sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t";
|
||||
$sql .= " WHERE t.rowid = ".((int) $id);
|
||||
$sql .= " AND t.entity = ".((int) $entity);
|
||||
$resql = $db->query($sql);
|
||||
if (!$resql || !$db->num_rows($resql)) {
|
||||
return null;
|
||||
}
|
||||
$obj = $db->fetch_object($resql);
|
||||
$s = new MahnungStufe($db);
|
||||
$s->id = (int) $obj->rowid;
|
||||
$s->entity = (int) $obj->entity;
|
||||
$s->stufe = (int) $obj->stufe;
|
||||
$s->label = $obj->label;
|
||||
$s->frist_tage = (int) $obj->frist_tage;
|
||||
$s->neue_frist_tage = (int) $obj->neue_frist_tage;
|
||||
$s->mahngebuehr_b2c = $obj->mahngebuehr_b2c;
|
||||
$s->mahngebuehr_b2b = $obj->mahngebuehr_b2b;
|
||||
$s->pauschale_b2b_einmalig = (int) $obj->pauschale_b2b_einmalig;
|
||||
$s->zinssatz_b2c_uebersteuern = $obj->zinssatz_b2c_uebersteuern;
|
||||
$s->zinssatz_b2b_uebersteuern = $obj->zinssatz_b2b_uebersteuern;
|
||||
$s->versandart_default = $obj->versandart_default;
|
||||
$s->email_subject = $obj->email_subject;
|
||||
$s->email_body = $obj->email_body;
|
||||
$s->pdf_intro = $obj->pdf_intro;
|
||||
$s->active = (int) $obj->active;
|
||||
$db->free($resql);
|
||||
return $s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// View
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
llxHeader('', $langs->trans('MahnungSetupPage'));
|
||||
|
||||
print load_fiche_titre($langs->trans('MahnungSetupPage'), '', 'fa-envelope-open-text');
|
||||
print '<span class="opacitymedium">'.$langs->trans('MahnungSetupDescription').'</span><br><br>';
|
||||
|
||||
// --- Block: Konstanten -------------------------------------------------------
|
||||
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
|
||||
print '<input type="hidden" name="token" value="'.newToken('admin_mahnung').'">';
|
||||
print '<input type="hidden" name="action" value="save_consts">';
|
||||
|
||||
print '<table class="noborder centpercent">';
|
||||
print '<tr class="liste_titre"><th colspan="2">'.$langs->trans('MahnungSetup').'</th></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungBasiszins').'</td>';
|
||||
print '<td><input type="text" name="MAHNUNG_BASISZINS" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_BASISZINS', '1.27')).'"> %';
|
||||
print ' <span class="opacitymedium">'.$langs->trans('MahnungBasiszinsHelp').'</span></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungAufschlagB2C').'</td>';
|
||||
print '<td><input type="text" name="MAHNUNG_AUFSCHLAG_B2C" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0')).'"> %</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungAufschlagB2B').'</td>';
|
||||
print '<td><input type="text" name="MAHNUNG_AUFSCHLAG_B2B" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0')).'"> %</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungPauschaleB2BLabel').'</td>';
|
||||
print '<td><input type="text" name="MAHNUNG_PAUSCHALE_B2B" size="8" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00')).'"> EUR</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungNtfyTopic').'</td>';
|
||||
print '<td><input type="text" name="MAHNUNG_NTFY_TOPIC" size="40" value="'.dol_escape_htmltag(getDolGlobalString('MAHNUNG_NTFY_TOPIC', 'vk-builds')).'">';
|
||||
print ' <span class="opacitymedium">'.$langs->trans('MahnungNtfyTopicHelp').'</span></td></tr>';
|
||||
|
||||
print '</table>';
|
||||
print '<br><div class="center"><input type="submit" class="button" value="'.$langs->trans('Save').'"></div>';
|
||||
print '</form>';
|
||||
|
||||
// --- Block: Stufen -----------------------------------------------------------
|
||||
$stufeObj = new MahnungStufe($db);
|
||||
$stufen = array();
|
||||
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_stufe WHERE entity = ".((int) $conf->entity)." ORDER BY stufe ASC";
|
||||
$resql = $db->query($sql);
|
||||
if ($resql) {
|
||||
while ($obj = $db->fetch_object($resql)) {
|
||||
$s = loadStufeById($db, (int) $obj->rowid, (int) $conf->entity);
|
||||
if ($s) {
|
||||
$stufen[] = $s;
|
||||
}
|
||||
}
|
||||
$db->free($resql);
|
||||
}
|
||||
|
||||
print '<br><br>';
|
||||
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
|
||||
print '<input type="hidden" name="token" value="'.newToken('admin_mahnung').'">';
|
||||
print '<input type="hidden" name="action" value="save_stufen">';
|
||||
|
||||
print '<table class="noborder centpercent">';
|
||||
print '<tr class="liste_titre"><th colspan="2">'.$langs->trans('MahnungStufe').'</th></tr>';
|
||||
|
||||
foreach ($stufen as $s) {
|
||||
$prefix = 'stufe_'.$s->stufe.'_';
|
||||
print '<tr class="liste_titre_filter"><th colspan="2">'.dol_escape_htmltag('Stufe '.$s->stufe).' ';
|
||||
print '<input type="checkbox" name="'.$prefix.'active" value="1"'.($s->active ? ' checked' : '').'> '.$langs->trans('Active');
|
||||
print '</th></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeLabel').'</td>';
|
||||
print '<td><input type="text" name="'.$prefix.'label" size="40" value="'.dol_escape_htmltag($s->label).'"></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeFristTage').'</td>';
|
||||
print '<td><input type="number" name="'.$prefix.'frist_tage" size="6" value="'.((int) $s->frist_tage).'"></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeNeueFristTage').'</td>';
|
||||
print '<td><input type="number" name="'.$prefix.'neue_frist_tage" size="6" value="'.((int) $s->neue_frist_tage).'"></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeGebuehrB2C').'</td>';
|
||||
print '<td><input type="text" name="'.$prefix.'mahngebuehr_b2c" size="8" value="'.((float) $s->mahngebuehr_b2c).'"> EUR</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeGebuehrB2B').'</td>';
|
||||
print '<td><input type="text" name="'.$prefix.'mahngebuehr_b2b" size="8" value="'.((float) $s->mahngebuehr_b2b).'"> EUR</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungPauschaleB2B').' (§288 Abs. 5)</td>';
|
||||
print '<td><input type="checkbox" name="'.$prefix.'pauschale_b2b_einmalig" value="1"'.($s->pauschale_b2b_einmalig ? ' checked' : '').'></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeZinssatzB2C').'</td>';
|
||||
print '<td><input type="text" name="'.$prefix.'zinssatz_b2c" size="8" value="'.($s->zinssatz_b2c_uebersteuern !== null ? (float) $s->zinssatz_b2c_uebersteuern : '').'"> %</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeZinssatzB2B').'</td>';
|
||||
print '<td><input type="text" name="'.$prefix.'zinssatz_b2b" size="8" value="'.($s->zinssatz_b2b_uebersteuern !== null ? (float) $s->zinssatz_b2b_uebersteuern : '').'"> %</td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeVersandartDefault').'</td>';
|
||||
$va = $s->versandart_default ?: 'pdf';
|
||||
print '<td><select name="'.$prefix.'versandart">';
|
||||
foreach (array('pdf' => 'MahnungVersandPdf', 'mail' => 'MahnungVersandMail', 'druck' => 'MahnungVersandDruck', 'none' => 'MahnungVersandNone') as $v => $tx) {
|
||||
print '<option value="'.$v.'"'.($va === $v ? ' selected' : '').'>'.$langs->trans($tx).'</option>';
|
||||
}
|
||||
print '</select></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufePdfIntro').'</td>';
|
||||
print '<td><textarea name="'.$prefix.'pdf_intro" cols="80" rows="3">'.dol_escape_htmltag($s->pdf_intro).'</textarea></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeEmailSubject').'</td>';
|
||||
print '<td><input type="text" name="'.$prefix.'email_subject" size="80" value="'.dol_escape_htmltag($s->email_subject).'"></td></tr>';
|
||||
|
||||
print '<tr class="oddeven"><td>'.$langs->trans('MahnungStufeEmailBody').'</td>';
|
||||
print '<td><textarea name="'.$prefix.'email_body" cols="80" rows="5">'.dol_escape_htmltag($s->email_body).'</textarea></td></tr>';
|
||||
}
|
||||
|
||||
print '</table>';
|
||||
print '<br><div class="center"><input type="submit" class="button" value="'.$langs->trans('Save').'"></div>';
|
||||
print '</form>';
|
||||
|
||||
llxFooter();
|
||||
$db->close();
|
||||
215
ajax/createmahnung.php
Normal file
215
ajax/createmahnung.php
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* GPL v3 (siehe COPYING).
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/ajax/createmahnung.php
|
||||
* \ingroup mahnung
|
||||
* \brief AJAX-Endpoint: Mahnung(en) zu Rechnung(en) erzeugen + PDF generieren.
|
||||
*
|
||||
* Akzeptiert sowohl klassische Form-POSTs (Browser-Submit aus list.php)
|
||||
* als auch AJAX-Calls. Antwortet je nach Accept-Header HTML-Redirect
|
||||
* oder JSON.
|
||||
*
|
||||
* POST:
|
||||
* - facture_ids[] Array Rechnungs-IDs (oder einzelne facture_id)
|
||||
* - stufe Optional: Stufe erzwingen (sonst Vorschlag-Logik)
|
||||
* - token CSRF
|
||||
*/
|
||||
|
||||
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
|
||||
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
|
||||
|
||||
ob_start();
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungpdf.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
||||
|
||||
global $db, $user, $langs, $conf;
|
||||
$langs->loadLangs(array('mahnung@mahnung'));
|
||||
|
||||
/**
|
||||
* @param bool $success
|
||||
* @param string $message
|
||||
* @param array $extra
|
||||
*/
|
||||
function respond($success, $message, $extra = array())
|
||||
{
|
||||
$wantsJson = false;
|
||||
if (!empty($_SERVER['HTTP_ACCEPT']) && stripos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) {
|
||||
$wantsJson = true;
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
|
||||
$wantsJson = true;
|
||||
}
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
if ($wantsJson) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(array_merge(array('success' => (bool) $success, 'message' => $message), $extra));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Klassischer Submit -> Redirect zur Liste mit Flash-Message
|
||||
global $user;
|
||||
if (function_exists('setEventMessages')) {
|
||||
setEventMessages($message, null, $success ? 'mesgs' : 'errors');
|
||||
}
|
||||
header('Location: '.DOL_URL_ROOT.'/custom/mahnung/list.php?mode=vorschlag');
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1) CSRF
|
||||
$postedToken = GETPOST('token', 'alphanohtml');
|
||||
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
|
||||
respond(false, 'Token-Verifikation fehlgeschlagen (CSRF).', array('code' => 'csrf'));
|
||||
}
|
||||
|
||||
// 2) Permission
|
||||
if (!$user->hasRight('mahnung', 'write')) {
|
||||
respond(false, $langs->transnoentities('NotEnoughPermissions') ?: 'Nicht berechtigt.', array('code' => 'forbidden'));
|
||||
}
|
||||
|
||||
// 3) Input
|
||||
$factureIds = GETPOST('facture_ids', 'array:int');
|
||||
if (empty($factureIds)) {
|
||||
$single = GETPOSTINT('facture_id');
|
||||
if (!empty($single)) {
|
||||
$factureIds = array($single);
|
||||
}
|
||||
}
|
||||
$factureIds = array_values(array_unique(array_map('intval', $factureIds)));
|
||||
$factureIds = array_filter($factureIds, function ($v) {
|
||||
return $v > 0;
|
||||
});
|
||||
if (empty($factureIds)) {
|
||||
respond(false, 'Keine Rechnungen ausgewaehlt.', array('code' => 'noinput'));
|
||||
}
|
||||
|
||||
$forceStufe = GETPOSTINT('stufe');
|
||||
$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0;
|
||||
|
||||
// 4) Verarbeitung — pro Rechnung Vorschlag holen, Mahnung erzeugen, PDF generieren
|
||||
$service = new MahnungVorschlag($db);
|
||||
$pdfGen = new MahnungPdf($db);
|
||||
$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$failed = array();
|
||||
|
||||
foreach ($factureIds as $fid) {
|
||||
$rows = $service->getVorschlaege(array('soc_id' => 0)); // ohne Filter holen
|
||||
$row = null;
|
||||
foreach ($rows as $r) {
|
||||
if ((int) $r['facture_id'] === (int) $fid) {
|
||||
$row = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($row === null) {
|
||||
// Keine offene Mahnungs-Empfehlung — z.B. weil Wartefrist noch laeuft
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe'];
|
||||
$stufe = $service->getStufe($stufeNr);
|
||||
if ($stufe === null) {
|
||||
$failed[] = 'Rechnung #'.$fid.': Stufe '.$stufeNr.' nicht konfiguriert';
|
||||
continue;
|
||||
}
|
||||
|
||||
$mahnung = new Mahnung($db);
|
||||
$mahnung->fk_facture = $fid;
|
||||
$mahnung->fk_soc = (int) $row['soc_id'];
|
||||
$mahnung->stufe = $stufeNr;
|
||||
$mahnung->date_mahnung = dol_now();
|
||||
$mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement'];
|
||||
$mahnung->date_lim_reglement_neu = dol_time_plus_duree(dol_now(), (int) $stufe->neue_frist_tage, 'd');
|
||||
$mahnung->betrag_offen = (float) $row['betrag_offen'];
|
||||
$mahnung->customertype = $row['kundentyp'];
|
||||
$mahnung->basiszins_snapshot = $basiszins;
|
||||
$mahnung->versandart = $stufe->versandart_default ?: Mahnung::VERSAND_PDF;
|
||||
|
||||
// Gebuehren + Pauschale
|
||||
$mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype);
|
||||
|
||||
// §288 Abs. 5 Pauschale: nur einmal pro Rechnung B2B (in Stufe mit pauschale_b2b_einmalig=1)
|
||||
if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B && (int) $stufe->pauschale_b2b_einmalig === 1) {
|
||||
$alreadyApplied = pauschaleBereitsAngewendet($db, (int) $fid);
|
||||
if (!$alreadyApplied) {
|
||||
$mahnung->pauschale_b2b = (float) getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00');
|
||||
}
|
||||
}
|
||||
|
||||
// Verzugszinsen
|
||||
$override = $stufe->getZinssatzOverride($mahnung->customertype);
|
||||
$mahnung->verzugszinsen = Mahnung::berechneVerzugszinsen(
|
||||
$mahnung->betrag_offen,
|
||||
(int) $row['tage_verzug'],
|
||||
$mahnung->customertype,
|
||||
$basiszins,
|
||||
$override
|
||||
);
|
||||
|
||||
$mahnung->rechneSumme();
|
||||
$mahnung->status = Mahnung::STATUS_ERSTELLT;
|
||||
|
||||
$newId = $mahnung->create($user);
|
||||
if ($newId <= 0) {
|
||||
$failed[] = 'Rechnung #'.$fid.': '.$mahnung->error;
|
||||
continue;
|
||||
}
|
||||
|
||||
$pdfPath = $pdfGen->generate($mahnung, $user);
|
||||
if ($pdfPath === false) {
|
||||
$failed[] = 'Rechnung #'.$fid.' (Mahnung '.$mahnung->ref.'): PDF-Fehler '.$pdfGen->error;
|
||||
continue;
|
||||
}
|
||||
|
||||
$created++;
|
||||
}
|
||||
|
||||
$msg = $created.' Mahnung(en) erstellt';
|
||||
if ($skipped > 0) {
|
||||
$msg .= ', '.$skipped.' uebersprungen (Wartefrist)';
|
||||
}
|
||||
if (!empty($failed)) {
|
||||
$msg .= ' — Fehler: '.implode(' | ', $failed);
|
||||
respond(false, $msg, array('created' => $created, 'failed' => $failed));
|
||||
}
|
||||
respond(true, $msg, array('created' => $created, 'skipped' => $skipped));
|
||||
|
||||
/**
|
||||
* Prueft, ob fuer eine Rechnung bereits in einer aktiven Mahnung die §288-B2B-Pauschale gesetzt wurde.
|
||||
*
|
||||
* @param DoliDB $db
|
||||
* @param int $factureId
|
||||
* @return bool
|
||||
*/
|
||||
function pauschaleBereitsAngewendet($db, $factureId)
|
||||
{
|
||||
$sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
||||
$sql .= " WHERE fk_facture = ".((int) $factureId);
|
||||
$sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")";
|
||||
$sql .= " AND pauschale_b2b > 0";
|
||||
$sql .= " LIMIT 1";
|
||||
$resql = $db->query($sql);
|
||||
if (!$resql) {
|
||||
return false;
|
||||
}
|
||||
$has = (bool) $db->num_rows($resql);
|
||||
$db->free($resql);
|
||||
return $has;
|
||||
}
|
||||
230
ajax/sammelbrief.php
Normal file
230
ajax/sammelbrief.php
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* GPL v3 (siehe COPYING).
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/ajax/sammelbrief.php
|
||||
* \ingroup mahnung
|
||||
* \brief AJAX/Form-Endpoint: Sammelbrief — fuer eine Auswahl von Rechnungen
|
||||
* Mahnungen erzeugen und alle Einzel-PDFs in EIN PDF zusammenfassen.
|
||||
*
|
||||
* POST:
|
||||
* facture_ids[] Rechnungs-IDs
|
||||
* stufe (opt) Stufe erzwingen (sonst Vorschlag)
|
||||
* token CSRF
|
||||
*
|
||||
* Response: PDF-Download "sammelbrief-YYYYMMDD-N.pdf".
|
||||
*/
|
||||
|
||||
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
|
||||
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
|
||||
|
||||
ob_start();
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungpdf.class.php';
|
||||
|
||||
global $db, $user, $langs;
|
||||
$langs->loadLangs(array('mahnung@mahnung'));
|
||||
|
||||
// CSRF
|
||||
$postedToken = GETPOST('token', 'alphanohtml');
|
||||
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
httpExitError(403, 'Token-Verifikation fehlgeschlagen.');
|
||||
}
|
||||
|
||||
// Permission
|
||||
if (!$user->hasRight('mahnung', 'send') && !$user->hasRight('mahnung', 'write')) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
httpExitError(403, 'Nicht berechtigt.');
|
||||
}
|
||||
|
||||
$factureIds = GETPOST('facture_ids', 'array:int');
|
||||
$factureIds = array_values(array_filter(array_unique(array_map('intval', $factureIds)), function ($v) {
|
||||
return $v > 0;
|
||||
}));
|
||||
if (empty($factureIds)) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
httpExitError(400, 'Keine Rechnungen ausgewaehlt.');
|
||||
}
|
||||
|
||||
$forceStufe = GETPOSTINT('stufe');
|
||||
$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0;
|
||||
|
||||
$service = new MahnungVorschlag($db);
|
||||
$pdfGen = new MahnungPdf($db);
|
||||
$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
|
||||
|
||||
$paths = array();
|
||||
foreach ($factureIds as $fid) {
|
||||
$rows = $service->getVorschlaege();
|
||||
$row = null;
|
||||
foreach ($rows as $r) {
|
||||
if ((int) $r['facture_id'] === (int) $fid) {
|
||||
$row = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($row === null) {
|
||||
continue;
|
||||
}
|
||||
$stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe'];
|
||||
$stufe = $service->getStufe($stufeNr);
|
||||
if ($stufe === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mahnung = new Mahnung($db);
|
||||
$mahnung->fk_facture = $fid;
|
||||
$mahnung->fk_soc = (int) $row['soc_id'];
|
||||
$mahnung->stufe = $stufeNr;
|
||||
$mahnung->date_mahnung = dol_now();
|
||||
$mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement'];
|
||||
$mahnung->date_lim_reglement_neu = dol_time_plus_duree(dol_now(), (int) $stufe->neue_frist_tage, 'd');
|
||||
$mahnung->betrag_offen = (float) $row['betrag_offen'];
|
||||
$mahnung->customertype = $row['kundentyp'];
|
||||
$mahnung->basiszins_snapshot = $basiszins;
|
||||
$mahnung->versandart = Mahnung::VERSAND_DRUCK;
|
||||
$mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype);
|
||||
if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B && (int) $stufe->pauschale_b2b_einmalig === 1
|
||||
&& !pauschaleBereitsAngewendet($db, $fid)) {
|
||||
$mahnung->pauschale_b2b = (float) getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00');
|
||||
}
|
||||
$mahnung->verzugszinsen = Mahnung::berechneVerzugszinsen(
|
||||
$mahnung->betrag_offen,
|
||||
(int) $row['tage_verzug'],
|
||||
$mahnung->customertype,
|
||||
$basiszins,
|
||||
$stufe->getZinssatzOverride($mahnung->customertype)
|
||||
);
|
||||
$mahnung->rechneSumme();
|
||||
$mahnung->status = Mahnung::STATUS_ERSTELLT;
|
||||
|
||||
if ($mahnung->create($user) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$pdfPath = $pdfGen->generate($mahnung, $user);
|
||||
if ($pdfPath !== false) {
|
||||
$paths[] = $pdfPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($paths)) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
httpExitError(500, 'Keine PDFs erzeugt — pruefe ob die Rechnungen mahnreif sind.');
|
||||
}
|
||||
|
||||
// Wenn TCPDI verfuegbar, Seiten aller PDFs in EIN Dokument importieren.
|
||||
// Andernfalls ZIP-Fallback wuerde sich anbieten — wir liefern stattdessen
|
||||
// eine PDF-Konkatenation via TCPDI (Bestandteil von tecnickcom/tc-lib-pdf
|
||||
// und Dolibarr-Tcpdi-Wrapper).
|
||||
$absOut = this_buildSammelbriefPdf($paths);
|
||||
if ($absOut === null || !file_exists($absOut)) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
httpExitError(500, 'Sammelbrief-PDF konnte nicht erzeugt werden.');
|
||||
}
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: attachment; filename="sammelbrief-'.dol_print_date(dol_now(), 'dayxcard').'.pdf"');
|
||||
header('Content-Length: '.filesize($absOut));
|
||||
readfile($absOut);
|
||||
@unlink($absOut);
|
||||
exit;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Konkateniert mehrere PDF-Dateien zu einer Datei. Gibt absoluten Pfad zurueck
|
||||
* oder null bei Fehler.
|
||||
*
|
||||
* @param string[] $paths
|
||||
* @return string|null
|
||||
*/
|
||||
function this_buildSammelbriefPdf(array $paths)
|
||||
{
|
||||
if (!class_exists('TCPDI')) {
|
||||
// Dolibarr liefert TCPDI ueber tcpdf/tcpdi.php aus
|
||||
$tcpdiPath = DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdi.php';
|
||||
if (file_exists($tcpdiPath)) {
|
||||
require_once $tcpdiPath;
|
||||
}
|
||||
}
|
||||
if (!class_exists('TCPDI')) {
|
||||
dol_syslog('Mahnung Sammelbrief: TCPDI-Klasse nicht verfuegbar — nur erstes PDF wird zurueckgeliefert', LOG_WARNING);
|
||||
return $paths[0] ?? null;
|
||||
}
|
||||
|
||||
$out = sys_get_temp_dir().'/mahnung-sammelbrief-'.uniqid('', true).'.pdf';
|
||||
$pdf = new TCPDI();
|
||||
$pdf->setPrintHeader(false);
|
||||
$pdf->setPrintFooter(false);
|
||||
foreach ($paths as $src) {
|
||||
if (!file_exists($src)) {
|
||||
continue;
|
||||
}
|
||||
$pageCount = $pdf->setSourceFile($src);
|
||||
for ($p = 1; $p <= $pageCount; $p++) {
|
||||
$tplIdx = $pdf->importPage($p);
|
||||
$size = $pdf->getTemplateSize($tplIdx);
|
||||
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
|
||||
$pdf->useTemplate($tplIdx);
|
||||
}
|
||||
}
|
||||
$pdf->Output($out, 'F');
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob fuer eine Rechnung bereits §288-B2B-Pauschale gesetzt wurde.
|
||||
*
|
||||
* @param DoliDB $db
|
||||
* @param int $factureId
|
||||
* @return bool
|
||||
*/
|
||||
function pauschaleBereitsAngewendet($db, $factureId)
|
||||
{
|
||||
$sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
||||
$sql .= " WHERE fk_facture = ".((int) $factureId);
|
||||
$sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")";
|
||||
$sql .= " AND pauschale_b2b > 0";
|
||||
$sql .= " LIMIT 1";
|
||||
$resql = $db->query($sql);
|
||||
if (!$resql) {
|
||||
return false;
|
||||
}
|
||||
$has = (bool) $db->num_rows($resql);
|
||||
$db->free($resql);
|
||||
return $has;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $code
|
||||
* @param string $message
|
||||
*/
|
||||
function httpExitError($code, $message)
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $message;
|
||||
exit;
|
||||
}
|
||||
154
ajax/sendmail.php
Normal file
154
ajax/sendmail.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* GPL v3 (siehe COPYING).
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/ajax/sendmail.php
|
||||
* \ingroup mahnung
|
||||
* \brief AJAX-Endpoint: Mahnung per E-Mail an Kunde senden.
|
||||
*
|
||||
* POST:
|
||||
* mahnung_id ID des Mahnvorgangs (PDF muss existieren)
|
||||
* token CSRF
|
||||
*
|
||||
* Response: JSON {success, message}
|
||||
*/
|
||||
|
||||
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
|
||||
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
|
||||
|
||||
ob_start();
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/class/CMailFile.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
||||
|
||||
global $db, $user, $langs, $conf, $mysoc;
|
||||
$langs->loadLangs(array('mahnung@mahnung'));
|
||||
|
||||
/**
|
||||
* @param bool $success
|
||||
* @param string $message
|
||||
*/
|
||||
function jsonExit($success, $message)
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(array('success' => (bool) $success, 'message' => $message));
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF
|
||||
$postedToken = GETPOST('token', 'alphanohtml');
|
||||
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
|
||||
jsonExit(false, 'CSRF-Token ungueltig.');
|
||||
}
|
||||
|
||||
if (!$user->hasRight('mahnung', 'send')) {
|
||||
jsonExit(false, 'Nicht berechtigt (mahnung.send).');
|
||||
}
|
||||
|
||||
$mahnungId = GETPOSTINT('mahnung_id');
|
||||
if ($mahnungId <= 0) {
|
||||
jsonExit(false, 'mahnung_id fehlt.');
|
||||
}
|
||||
|
||||
$mahnung = new Mahnung($db);
|
||||
if ($mahnung->fetch($mahnungId) <= 0) {
|
||||
jsonExit(false, 'Mahnung '.$mahnungId.' nicht gefunden.');
|
||||
}
|
||||
|
||||
if (empty($mahnung->pdf_path) || !file_exists($mahnung->pdf_path)) {
|
||||
jsonExit(false, 'PDF zur Mahnung '.$mahnung->ref.' fehlt — bitte zuerst Mahnung erzeugen.');
|
||||
}
|
||||
|
||||
$societe = new Societe($db);
|
||||
if ($societe->fetch((int) $mahnung->fk_soc) <= 0) {
|
||||
jsonExit(false, 'Kunde nicht ladbar.');
|
||||
}
|
||||
$toEmail = trim((string) ($societe->email ?? ''));
|
||||
if (empty($toEmail)) {
|
||||
jsonExit(false, 'Kunde hat keine E-Mail-Adresse hinterlegt.');
|
||||
}
|
||||
|
||||
$facture = new Facture($db);
|
||||
$facture->fetch((int) $mahnung->fk_facture);
|
||||
|
||||
$stufeObj = new MahnungStufe($db);
|
||||
$stufeObj->fetchByStufe((int) $mahnung->stufe);
|
||||
|
||||
$replacements = array(
|
||||
'{ref}' => $mahnung->ref,
|
||||
'{stufe}' => (string) (int) $mahnung->stufe,
|
||||
'{summe}' => price((float) $mahnung->summe_mahnung).' EUR',
|
||||
'{rechnung}' => $facture->ref ?? '',
|
||||
'{frist}' => dol_print_date($mahnung->date_lim_reglement_neu, 'day'),
|
||||
'{kunde}' => $societe->name ?? '',
|
||||
);
|
||||
|
||||
$subject = strtr($stufeObj->email_subject ?: 'Mahnung {stufe} zu Rechnung {rechnung}', $replacements);
|
||||
$body = strtr($stufeObj->email_body ?: defaultMailBody((int) $mahnung->stufe), $replacements);
|
||||
|
||||
$fromEmail = $mysoc->email ?? getDolGlobalString('MAIN_MAIL_EMAIL_FROM');
|
||||
$fromName = $mysoc->name ?? '';
|
||||
$from = !empty($fromName) ? $fromName.' <'.$fromEmail.'>' : $fromEmail;
|
||||
|
||||
$attachments = array($mahnung->pdf_path);
|
||||
$mimes = array('application/pdf');
|
||||
$names = array(basename($mahnung->pdf_path));
|
||||
|
||||
$mailFile = new CMailFile(
|
||||
$subject,
|
||||
$toEmail,
|
||||
$from,
|
||||
$body,
|
||||
$attachments,
|
||||
$mimes,
|
||||
$names,
|
||||
'',
|
||||
'',
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
if (!$mailFile->error) {
|
||||
if ($mailFile->sendfile()) {
|
||||
$mahnung->status = Mahnung::STATUS_VERSENDET;
|
||||
$mahnung->update($user);
|
||||
jsonExit(true, 'E-Mail an '.$toEmail.' gesendet.');
|
||||
}
|
||||
}
|
||||
jsonExit(false, 'E-Mail-Versand fehlgeschlagen: '.$mailFile->error);
|
||||
|
||||
/**
|
||||
* Default-Body je Stufe.
|
||||
*
|
||||
* @param int $stufe
|
||||
* @return string
|
||||
*/
|
||||
function defaultMailBody($stufe)
|
||||
{
|
||||
switch ((int) $stufe) {
|
||||
case 1:
|
||||
return "Sehr geehrter Kunde,\n\nanbei senden wir Ihnen eine freundliche Zahlungserinnerung zu Rechnung {rechnung}.\n"
|
||||
. "Offener Betrag inkl. evtl. Zinsen: {summe}.\n"
|
||||
. "Wir bitten um Begleichung bis spaetestens {frist}.\n\n"
|
||||
. "Mit freundlichen Gruessen";
|
||||
case 2:
|
||||
return "Sehr geehrter Kunde,\n\nanbei die 1. Mahnung zur Rechnung {rechnung}.\n"
|
||||
. "Bitte ueberweisen Sie {summe} bis zum {frist}.\n\n"
|
||||
. "Mit freundlichen Gruessen";
|
||||
case 3:
|
||||
default:
|
||||
return "Sehr geehrter Kunde,\n\nanbei die letzte Mahnung zur Rechnung {rechnung}.\n"
|
||||
. "Falls der Betrag von {summe} nicht bis zum {frist} eingeht, leiten wir gerichtliche Schritte ein.\n\n"
|
||||
. "Mit freundlichen Gruessen";
|
||||
}
|
||||
}
|
||||
137
card.php
Normal file
137
card.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file mahnung/card.php
|
||||
* \ingroup mahnung
|
||||
* \brief Detailansicht eines einzelnen Mahnvorgangs.
|
||||
*/
|
||||
|
||||
$res = 0;
|
||||
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
|
||||
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
|
||||
}
|
||||
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
|
||||
$tmp2 = realpath(__FILE__);
|
||||
$i = strlen($tmp) - 1;
|
||||
$j = strlen($tmp2) - 1;
|
||||
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
|
||||
$i--;
|
||||
$j--;
|
||||
}
|
||||
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
|
||||
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
|
||||
}
|
||||
if (!$res && file_exists("../main.inc.php")) {
|
||||
$res = @include "../main.inc.php";
|
||||
}
|
||||
if (!$res) {
|
||||
die("Include of main fails");
|
||||
}
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
||||
|
||||
global $langs, $user, $db;
|
||||
$langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills'));
|
||||
|
||||
if (!$user->hasRight('mahnung', 'read')) {
|
||||
accessforbidden();
|
||||
}
|
||||
|
||||
$id = GETPOSTINT('id');
|
||||
$action = GETPOST('action', 'aZ09');
|
||||
|
||||
$mahnung = new Mahnung($db);
|
||||
if ($mahnung->fetch($id) <= 0) {
|
||||
dol_print_error($db, 'Mahnung nicht gefunden');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Stornieren
|
||||
if ($action === 'storno' && $user->hasRight('mahnung', 'delete')) {
|
||||
if (!verifCsrf($_POST['token'] ?? '', 'mahnung_storno')) {
|
||||
setEventMessages($langs->trans('ErrorBadValueForToken'), null, 'errors');
|
||||
} else {
|
||||
$mahnung->status = Mahnung::STATUS_STORNIERT;
|
||||
if ($mahnung->update($user) > 0) {
|
||||
setEventMessages($langs->trans('MahnungStornieren').' OK', null, 'mesgs');
|
||||
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
|
||||
exit;
|
||||
}
|
||||
setEventMessages($mahnung->error, null, 'errors');
|
||||
}
|
||||
}
|
||||
|
||||
llxHeader('', $langs->trans('MahnungRef').' '.$mahnung->ref);
|
||||
|
||||
print load_fiche_titre($langs->trans('MahnungRef').' '.$mahnung->ref, '', 'fa-envelope-open-text');
|
||||
|
||||
print '<table class="border centpercent">';
|
||||
print '<tr><td class="titlefield">'.$langs->trans('MahnungRef').'</td><td>'.dol_escape_htmltag($mahnung->ref).'</td></tr>';
|
||||
print '<tr><td>'.$langs->trans('MahnungStufe').'</td><td>'.((int) $mahnung->stufe).'</td></tr>';
|
||||
print '<tr><td>'.$langs->trans('MahnungDatum').'</td><td>'.dol_print_date($mahnung->date_mahnung, 'day').'</td></tr>';
|
||||
print '<tr><td>'.$langs->trans('MahnungFaelligkeitAlt').'</td><td>'.dol_print_date($mahnung->date_lim_reglement_alt, 'day').'</td></tr>';
|
||||
print '<tr><td>'.$langs->trans('MahnungFaelligkeitNeu').'</td><td>'.dol_print_date($mahnung->date_lim_reglement_neu, 'day').'</td></tr>';
|
||||
|
||||
// Rechnung
|
||||
$facture = new Facture($db);
|
||||
if ($facture->fetch((int) $mahnung->fk_facture) > 0) {
|
||||
print '<tr><td>'.$langs->trans('MahnungRechnung').'</td>';
|
||||
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $facture->id).'">'.dol_escape_htmltag($facture->ref).'</a></td></tr>';
|
||||
}
|
||||
|
||||
// Kunde
|
||||
$societe = new Societe($db);
|
||||
if ($societe->fetch((int) $mahnung->fk_soc) > 0) {
|
||||
print '<tr><td>'.$langs->trans('MahnungKunde').'</td>';
|
||||
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $societe->id).'">'.dol_escape_htmltag($societe->name).'</a> ('.dol_escape_htmltag((string) $mahnung->customertype).')</td></tr>';
|
||||
}
|
||||
|
||||
print '<tr><td>'.$langs->trans('MahnungBetragOffen').'</td><td>'.price($mahnung->betrag_offen).'</td></tr>';
|
||||
print '<tr><td>'.$langs->trans('MahnungGebuehr').'</td><td>'.price($mahnung->mahngebuehr).'</td></tr>';
|
||||
if ((float) $mahnung->pauschale_b2b > 0) {
|
||||
print '<tr><td>'.$langs->trans('MahnungPauschaleB2B').'</td><td>'.price($mahnung->pauschale_b2b).'</td></tr>';
|
||||
}
|
||||
print '<tr><td>'.$langs->trans('MahnungVerzugszinsen').'</td><td>'.price($mahnung->verzugszinsen).' (Basiszins '.number_format((float) $mahnung->basiszins_snapshot, 2, ',', '.').' %)</td></tr>';
|
||||
print '<tr><td>'.$langs->trans('MahnungSumme').'</td><td><strong>'.price($mahnung->summe_mahnung).'</strong></td></tr>';
|
||||
print '<tr><td>Status</td><td>'.dol_escape_htmltag($mahnung->getStatusLabel()).'</td></tr>';
|
||||
if (!empty($mahnung->pdf_path)) {
|
||||
$relativePdf = str_replace(DOL_DATA_ROOT, '', $mahnung->pdf_path);
|
||||
$dl = DOL_URL_ROOT.'/document.php?modulepart=facture&file='.urlencode(ltrim(str_replace('/facture/', '', $relativePdf), '/'));
|
||||
print '<tr><td>PDF</td><td><a href="'.$dl.'">PDF herunterladen</a></td></tr>';
|
||||
}
|
||||
print '</table>';
|
||||
|
||||
// Aktionen
|
||||
if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', 'delete')) {
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php';
|
||||
$form = new Form($db);
|
||||
|
||||
if ($action === 'confirm_storno') {
|
||||
print $form->formconfirm(
|
||||
$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id),
|
||||
$langs->trans('MahnungStornieren'),
|
||||
$langs->trans('MahnungStornieren').' — '.dol_escape_htmltag($mahnung->ref).'?',
|
||||
'storno',
|
||||
'',
|
||||
0,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
print '<br><div class="tabsAction">';
|
||||
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=confirm_storno">';
|
||||
print $langs->trans('MahnungStornieren');
|
||||
print '</a></div>';
|
||||
}
|
||||
|
||||
llxFooter();
|
||||
$db->close();
|
||||
145
class/actions_mahnung.class.php
Normal file
145
class/actions_mahnung.class.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* GPL v3 (siehe COPYING).
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/class/actions_mahnung.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief Hook-Klasse: Tab "Mahnungen" + Button "Mahnung erstellen" auf Rechnungs-Karte.
|
||||
*
|
||||
* Wird in modMahnung registriert via module_parts['hooks'] = ['data' => ['invoicecard']].
|
||||
*/
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
|
||||
class ActionsMahnung
|
||||
{
|
||||
/** @var array */
|
||||
public $errors = array();
|
||||
|
||||
/** @var string */
|
||||
public $resprints = '';
|
||||
|
||||
/**
|
||||
* Hook addMoreActionsButtons: Button "Mahnung erstellen" im Header der Rechnungs-Karte.
|
||||
*
|
||||
* @param array $parameters
|
||||
* @param CommonObject $object Facture
|
||||
* @param string $action
|
||||
* @param HookManager $hookmanager
|
||||
* @return int 0 = weiter, 1 = ueberschreiben
|
||||
*/
|
||||
public function addMoreActionsButtons($parameters, &$object, &$action, $hookmanager)
|
||||
{
|
||||
global $user, $langs;
|
||||
|
||||
$contexts = explode(':', $parameters['context'] ?? '');
|
||||
if (!in_array('invoicecard', $contexts, true)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (empty($object->id) || empty($object->socid)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Nur fuer normale Kundenrechnungen
|
||||
if (!isset($object->type) || (int) $object->type !== 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$paye = (int) ($object->paye ?? 0);
|
||||
$statut = (int) ($object->statut ?? ($object->status ?? 0));
|
||||
$dateLim = !empty($object->date_lim_reglement) ? (int) $object->date_lim_reglement : 0;
|
||||
$ueberfaellig = ($statut === 1 && $paye === 0 && $dateLim > 0 && $dateLim < dol_now());
|
||||
|
||||
$langs->load('mahnung@mahnung');
|
||||
|
||||
if (!$user->hasRight('mahnung', 'write')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$label = $langs->trans('MahnungErstellen');
|
||||
|
||||
if ($ueberfaellig) {
|
||||
$url = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=vorschlag&search_socid='.((int) $object->socid);
|
||||
print dolGetButtonAction($label, '', 'default', $url, 'btn-mahnung-create', 1);
|
||||
} else {
|
||||
$attr = array('title' => $langs->trans('MahnungKeineUeberfaelligen'));
|
||||
print dolGetButtonAction($label, '', 'default', '#', 'btn-mahnung-create', 0, array('attr' => $attr));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook completeTabsHead: Tab "Mahnungen (n)" sowohl auf Rechnungs- als
|
||||
* auch auf Kundenkarte einblenden.
|
||||
*
|
||||
* @param array $parameters
|
||||
* @param CommonObject $object Facture oder Societe
|
||||
* @param string $action
|
||||
* @param HookManager $hookmanager
|
||||
* @return int 0 = weiter
|
||||
*/
|
||||
public function completeTabsHead($parameters, &$object, &$action, $hookmanager)
|
||||
{
|
||||
global $langs, $db;
|
||||
|
||||
$contexts = explode(':', $parameters['context'] ?? '');
|
||||
$onInvoice = in_array('invoicecard', $contexts, true);
|
||||
$onThirdparty = in_array('thirdpartycard', $contexts, true);
|
||||
if (!$onInvoice && !$onThirdparty) {
|
||||
return 0;
|
||||
}
|
||||
if (empty($object->id)) {
|
||||
return 0;
|
||||
}
|
||||
if (!isset($parameters['head']) || !is_array($parameters['head'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$langs->load('mahnung@mahnung');
|
||||
|
||||
if ($onInvoice) {
|
||||
$count = $this->countMahnungen($db, 'fk_facture', (int) $object->id);
|
||||
$tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=archiv&fk_facture='.((int) $object->id);
|
||||
} else {
|
||||
$count = $this->countMahnungen($db, 'fk_soc', (int) $object->id);
|
||||
$tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=archiv&search_socid='.((int) $object->id);
|
||||
}
|
||||
|
||||
$head = &$parameters['head'];
|
||||
$pos = count($head);
|
||||
$head[$pos][0] = $tabUrl;
|
||||
$head[$pos][1] = $langs->trans('MahnungMenu').($count > 0 ? ' <span class="badge">'.$count.'</span>' : '');
|
||||
$head[$pos][2] = 'mahnung';
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DoliDB $db
|
||||
* @param string $col 'fk_facture' | 'fk_soc'
|
||||
* @param int $id
|
||||
* @return int
|
||||
*/
|
||||
private function countMahnungen($db, $col, $id)
|
||||
{
|
||||
if (!in_array($col, array('fk_facture', 'fk_soc'), true)) {
|
||||
return 0;
|
||||
}
|
||||
$sql = "SELECT COUNT(*) AS n FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
||||
$sql .= " WHERE ".$col." = ".((int) $id);
|
||||
$sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")";
|
||||
$resql = $db->query($sql);
|
||||
if (!$resql) {
|
||||
return 0;
|
||||
}
|
||||
$obj = $db->fetch_object($resql);
|
||||
$count = (int) $obj->n;
|
||||
$db->free($resql);
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
504
class/mahnung.class.php
Normal file
504
class/mahnung.class.php
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License, version 3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/class/mahnung.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief CRUD-Klasse fuer Mahnvorgaenge (llx_mahnung_mahnung).
|
||||
*/
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
|
||||
|
||||
/**
|
||||
* Klasse Mahnung — repraesentiert einen Mahnvorgang zu einer Kundenrechnung.
|
||||
*/
|
||||
class Mahnung extends CommonObject
|
||||
{
|
||||
const STATUS_ENTWURF = 0;
|
||||
const STATUS_ERSTELLT = 1;
|
||||
const STATUS_VERSENDET = 2;
|
||||
const STATUS_ERLEDIGT = 3;
|
||||
const STATUS_STORNIERT = 9;
|
||||
|
||||
const VERSAND_PDF = 'pdf';
|
||||
const VERSAND_MAIL = 'mail';
|
||||
const VERSAND_DRUCK = 'druck';
|
||||
const VERSAND_NONE = 'none';
|
||||
|
||||
const KUNDENTYP_B2C = 'B2C';
|
||||
const KUNDENTYP_B2B = 'B2B';
|
||||
|
||||
/** @var string */
|
||||
public $element = 'mahnung';
|
||||
|
||||
/** @var string */
|
||||
public $table_element = 'mahnung_mahnung';
|
||||
|
||||
/** @var int */
|
||||
public $entity;
|
||||
|
||||
/** @var string */
|
||||
public $ref;
|
||||
|
||||
/** @var int */
|
||||
public $fk_facture;
|
||||
|
||||
/** @var int */
|
||||
public $fk_soc;
|
||||
|
||||
/** @var int 1, 2, 3 */
|
||||
public $stufe;
|
||||
|
||||
/** @var int Unix-Zeit */
|
||||
public $date_mahnung;
|
||||
|
||||
/** @var int Unix-Zeit */
|
||||
public $date_lim_reglement_alt;
|
||||
|
||||
/** @var int Unix-Zeit */
|
||||
public $date_lim_reglement_neu;
|
||||
|
||||
/** @var float */
|
||||
public $betrag_offen = 0;
|
||||
|
||||
/** @var float */
|
||||
public $mahngebuehr = 0;
|
||||
|
||||
/** @var float */
|
||||
public $pauschale_b2b = 0;
|
||||
|
||||
/** @var float */
|
||||
public $verzugszinsen = 0;
|
||||
|
||||
/** @var float */
|
||||
public $summe_mahnung = 0;
|
||||
|
||||
/** @var string pdf|mail|druck|none */
|
||||
public $versandart = self::VERSAND_PDF;
|
||||
|
||||
/** @var string B2C|B2B */
|
||||
public $customertype;
|
||||
|
||||
/** @var float */
|
||||
public $basiszins_snapshot;
|
||||
|
||||
/** @var string */
|
||||
public $pdf_path;
|
||||
|
||||
/** @var string */
|
||||
public $note_private;
|
||||
|
||||
/** @var int 0..9 */
|
||||
public $status = self::STATUS_ENTWURF;
|
||||
|
||||
/** @var int Unix-Zeit */
|
||||
public $datec;
|
||||
|
||||
/** @var int Unix-Zeit */
|
||||
public $tms;
|
||||
|
||||
/** @var int */
|
||||
public $fk_user_creat;
|
||||
|
||||
/** @var int */
|
||||
public $fk_user_modif;
|
||||
|
||||
/**
|
||||
* @param DoliDB $db Datenbank-Handler
|
||||
*/
|
||||
public function __construct($db)
|
||||
{
|
||||
global $conf;
|
||||
$this->db = $db;
|
||||
$this->entity = $conf->entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Naechste freie Mahnungs-Referenz fuer das aktuelle Jahr.
|
||||
* Format: MAHN<YYYY>-<NNNN>
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getNextRef()
|
||||
{
|
||||
$year = (int) dol_print_date(dol_now(), '%Y');
|
||||
$prefix = 'MAHN'.$year.'-';
|
||||
|
||||
$sql = "SELECT MAX(CAST(SUBSTRING(ref, ".(strlen($prefix) + 1).") AS UNSIGNED)) AS maxnum";
|
||||
$sql .= " FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
||||
$sql .= " WHERE entity = ".((int) $this->entity);
|
||||
$sql .= " AND ref LIKE '".$this->db->escape($prefix)."%'";
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
$next = 1;
|
||||
if ($resql) {
|
||||
$obj = $this->db->fetch_object($resql);
|
||||
$next = ((int) $obj->maxnum) + 1;
|
||||
$this->db->free($resql);
|
||||
}
|
||||
return $prefix.str_pad((string) $next, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mahnvorgang anlegen.
|
||||
*
|
||||
* @param User $user Anlegender User
|
||||
* @return int <0 bei Fehler, sonst neue rowid
|
||||
*/
|
||||
public function create($user)
|
||||
{
|
||||
$now = dol_now();
|
||||
|
||||
if (empty($this->ref)) {
|
||||
$this->ref = $this->getNextRef();
|
||||
}
|
||||
|
||||
$this->db->begin();
|
||||
|
||||
$sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mahnung (";
|
||||
$sql .= "entity, ref, fk_facture, fk_soc, stufe, date_mahnung,";
|
||||
$sql .= " date_lim_reglement_alt, date_lim_reglement_neu,";
|
||||
$sql .= " betrag_offen, mahngebuehr, pauschale_b2b, verzugszinsen, summe_mahnung,";
|
||||
$sql .= " versandart, customertype, basiszins_snapshot, pdf_path, note_private,";
|
||||
$sql .= " status, datec, fk_user_creat";
|
||||
$sql .= ") VALUES (";
|
||||
$sql .= ((int) $this->entity).",";
|
||||
$sql .= "'".$this->db->escape($this->ref)."',";
|
||||
$sql .= ((int) $this->fk_facture).",";
|
||||
$sql .= ((int) $this->fk_soc).",";
|
||||
$sql .= ((int) $this->stufe).",";
|
||||
$sql .= "'".$this->db->idate($this->date_mahnung ?: $now)."',";
|
||||
$sql .= ($this->date_lim_reglement_alt ? "'".$this->db->idate($this->date_lim_reglement_alt)."'" : "NULL").",";
|
||||
$sql .= ($this->date_lim_reglement_neu ? "'".$this->db->idate($this->date_lim_reglement_neu)."'" : "NULL").",";
|
||||
$sql .= ((float) $this->betrag_offen).",";
|
||||
$sql .= ((float) $this->mahngebuehr).",";
|
||||
$sql .= ((float) $this->pauschale_b2b).",";
|
||||
$sql .= ((float) $this->verzugszinsen).",";
|
||||
$sql .= ((float) $this->summe_mahnung).",";
|
||||
$sql .= "'".$this->db->escape($this->versandart ?: self::VERSAND_PDF)."',";
|
||||
$sql .= ($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL").",";
|
||||
$sql .= ($this->basiszins_snapshot !== null ? ((float) $this->basiszins_snapshot) : "NULL").",";
|
||||
$sql .= ($this->pdf_path ? "'".$this->db->escape($this->pdf_path)."'" : "NULL").",";
|
||||
$sql .= ($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").",";
|
||||
$sql .= ((int) $this->status).",";
|
||||
$sql .= "'".$this->db->idate($now)."',";
|
||||
$sql .= ((int) $user->id);
|
||||
$sql .= ")";
|
||||
|
||||
dol_syslog(get_class($this).'::create', LOG_DEBUG);
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
$this->db->rollback();
|
||||
return -1;
|
||||
}
|
||||
|
||||
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.'mahnung_mahnung');
|
||||
$this->datec = $now;
|
||||
$this->fk_user_creat = $user->id;
|
||||
|
||||
$this->db->commit();
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id rowid
|
||||
* @return int -1 Fehler, 0 nicht gefunden, >0 OK
|
||||
*/
|
||||
public function fetch($id)
|
||||
{
|
||||
$sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_mahnung as t";
|
||||
$sql .= " WHERE t.rowid = ".((int) $id);
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
return -1;
|
||||
}
|
||||
if (!$this->db->num_rows($resql)) {
|
||||
$this->db->free($resql);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$obj = $this->db->fetch_object($resql);
|
||||
$this->id = $obj->rowid;
|
||||
$this->entity = $obj->entity;
|
||||
$this->ref = $obj->ref;
|
||||
$this->fk_facture = $obj->fk_facture;
|
||||
$this->fk_soc = $obj->fk_soc;
|
||||
$this->stufe = $obj->stufe;
|
||||
$this->date_mahnung = $this->db->jdate($obj->date_mahnung);
|
||||
$this->date_lim_reglement_alt = $this->db->jdate($obj->date_lim_reglement_alt);
|
||||
$this->date_lim_reglement_neu = $this->db->jdate($obj->date_lim_reglement_neu);
|
||||
$this->betrag_offen = $obj->betrag_offen;
|
||||
$this->mahngebuehr = $obj->mahngebuehr;
|
||||
$this->pauschale_b2b = $obj->pauschale_b2b;
|
||||
$this->verzugszinsen = $obj->verzugszinsen;
|
||||
$this->summe_mahnung = $obj->summe_mahnung;
|
||||
$this->versandart = $obj->versandart;
|
||||
$this->customertype = $obj->customertype;
|
||||
$this->basiszins_snapshot = $obj->basiszins_snapshot;
|
||||
$this->pdf_path = $obj->pdf_path;
|
||||
$this->note_private = $obj->note_private;
|
||||
$this->status = (int) $obj->status;
|
||||
$this->datec = $this->db->jdate($obj->datec);
|
||||
$this->tms = $this->db->jdate($obj->tms);
|
||||
$this->fk_user_creat = $obj->fk_user_creat;
|
||||
$this->fk_user_modif = $obj->fk_user_modif;
|
||||
|
||||
$this->db->free($resql);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Mahnungen laden.
|
||||
*
|
||||
* @param string $sortfield
|
||||
* @param string $sortorder
|
||||
* @param int $limit
|
||||
* @param int $offset
|
||||
* @param array $filter Schluessel: fk_facture, fk_soc, stufe, status, ref_like
|
||||
* @param string $mode 'list' | 'count'
|
||||
* @return Mahnung[]|int Liste, Anzahl oder -1 bei Fehler
|
||||
*/
|
||||
public function fetchAll($sortfield = 'date_mahnung', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list')
|
||||
{
|
||||
$sql = "SELECT t.rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung as t";
|
||||
$sql .= " WHERE t.entity = ".((int) $this->entity);
|
||||
|
||||
if (!empty($filter['fk_facture'])) {
|
||||
$sql .= " AND t.fk_facture = ".((int) $filter['fk_facture']);
|
||||
}
|
||||
if (!empty($filter['fk_soc'])) {
|
||||
$sql .= " AND t.fk_soc = ".((int) $filter['fk_soc']);
|
||||
}
|
||||
if (isset($filter['stufe']) && $filter['stufe'] !== '') {
|
||||
$sql .= " AND t.stufe = ".((int) $filter['stufe']);
|
||||
}
|
||||
if (isset($filter['status']) && $filter['status'] !== '') {
|
||||
$sql .= " AND t.status = ".((int) $filter['status']);
|
||||
}
|
||||
if (!empty($filter['ref_like'])) {
|
||||
$sql .= " AND t.ref LIKE '%".$this->db->escape($filter['ref_like'])."%'";
|
||||
}
|
||||
|
||||
if ($mode === 'count') {
|
||||
$sqlcount = preg_replace('/SELECT t\.rowid/', 'SELECT COUNT(*) as total', $sql);
|
||||
$resqlcount = $this->db->query($sqlcount);
|
||||
if (!$resqlcount) {
|
||||
return -1;
|
||||
}
|
||||
$objcount = $this->db->fetch_object($resqlcount);
|
||||
$this->db->free($resqlcount);
|
||||
return (int) $objcount->total;
|
||||
}
|
||||
|
||||
$sql .= $this->db->order($sortfield, $sortorder);
|
||||
if ($limit > 0) {
|
||||
$sql .= $this->db->plimit($limit, $offset);
|
||||
}
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
return -1;
|
||||
}
|
||||
|
||||
$result = array();
|
||||
while ($obj = $this->db->fetch_object($resql)) {
|
||||
$m = new self($this->db);
|
||||
$m->fetch($obj->rowid);
|
||||
$result[] = $m;
|
||||
}
|
||||
$this->db->free($resql);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mahnvorgang aktualisieren.
|
||||
*
|
||||
* @param User $user Bearbeitender User
|
||||
* @return int <0 bei Fehler, sonst id
|
||||
*/
|
||||
public function update($user)
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
$this->error = 'Mahnung::update — id missing';
|
||||
return -1;
|
||||
}
|
||||
|
||||
$this->db->begin();
|
||||
|
||||
$sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET";
|
||||
$sql .= " stufe = ".((int) $this->stufe);
|
||||
$sql .= ", date_mahnung = '".$this->db->idate($this->date_mahnung ?: dol_now())."'";
|
||||
$sql .= ", date_lim_reglement_alt = ".($this->date_lim_reglement_alt ? "'".$this->db->idate($this->date_lim_reglement_alt)."'" : "NULL");
|
||||
$sql .= ", date_lim_reglement_neu = ".($this->date_lim_reglement_neu ? "'".$this->db->idate($this->date_lim_reglement_neu)."'" : "NULL");
|
||||
$sql .= ", betrag_offen = ".((float) $this->betrag_offen);
|
||||
$sql .= ", mahngebuehr = ".((float) $this->mahngebuehr);
|
||||
$sql .= ", pauschale_b2b = ".((float) $this->pauschale_b2b);
|
||||
$sql .= ", verzugszinsen = ".((float) $this->verzugszinsen);
|
||||
$sql .= ", summe_mahnung = ".((float) $this->summe_mahnung);
|
||||
$sql .= ", versandart = '".$this->db->escape($this->versandart)."'";
|
||||
$sql .= ", customertype = ".($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL");
|
||||
$sql .= ", basiszins_snapshot = ".($this->basiszins_snapshot !== null ? ((float) $this->basiszins_snapshot) : "NULL");
|
||||
$sql .= ", pdf_path = ".($this->pdf_path ? "'".$this->db->escape($this->pdf_path)."'" : "NULL");
|
||||
$sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL");
|
||||
$sql .= ", status = ".((int) $this->status);
|
||||
$sql .= ", fk_user_modif = ".((int) $user->id);
|
||||
$sql .= " WHERE rowid = ".((int) $this->id);
|
||||
|
||||
dol_syslog(get_class($this).'::update', LOG_DEBUG);
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
$this->db->rollback();
|
||||
return -1;
|
||||
}
|
||||
|
||||
$this->fk_user_modif = $user->id;
|
||||
$this->db->commit();
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mahnvorgang loeschen. Verknuepftes PDF wird mitgeloescht falls vorhanden.
|
||||
*
|
||||
* @param User $user Loeschender User
|
||||
* @return int <0 bei Fehler, sonst 1
|
||||
*/
|
||||
public function delete($user)
|
||||
{
|
||||
$this->db->begin();
|
||||
|
||||
if ($this->pdf_path && file_exists($this->pdf_path)) {
|
||||
@unlink($this->pdf_path);
|
||||
}
|
||||
|
||||
$sql = "DELETE FROM ".MAIN_DB_PREFIX."mahnung_mahnung WHERE rowid = ".((int) $this->id);
|
||||
dol_syslog(get_class($this).'::delete', LOG_DEBUG);
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
$this->db->rollback();
|
||||
return -1;
|
||||
}
|
||||
|
||||
$this->db->commit();
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status auf "erledigt" setzen (Trigger nach Zahlungseingang).
|
||||
*
|
||||
* @param User $user
|
||||
* @return int <0 bei Fehler, sonst 1
|
||||
*/
|
||||
public function setErledigt($user)
|
||||
{
|
||||
$this->status = self::STATUS_ERLEDIGT;
|
||||
$res = $this->update($user);
|
||||
return $res > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzten Mahnvorgang zu einer Rechnung holen (hoechste Stufe, neuestes Datum).
|
||||
*
|
||||
* @param int $factureId
|
||||
* @return Mahnung|null
|
||||
*/
|
||||
public function fetchLastByFacture($factureId)
|
||||
{
|
||||
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
||||
$sql .= " WHERE fk_facture = ".((int) $factureId);
|
||||
$sql .= " AND entity = ".((int) $this->entity);
|
||||
$sql .= " AND status NOT IN (".self::STATUS_STORNIERT.")";
|
||||
$sql .= " ORDER BY stufe DESC, date_mahnung DESC, rowid DESC";
|
||||
$sql .= " LIMIT 1";
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql || !$this->db->num_rows($resql)) {
|
||||
return null;
|
||||
}
|
||||
$obj = $this->db->fetch_object($resql);
|
||||
$this->db->free($resql);
|
||||
|
||||
$m = new self($this->db);
|
||||
if ($m->fetch($obj->rowid) > 0) {
|
||||
return $m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verzugszinsen tagesgenau nach BGB §288 berechnen.
|
||||
* Formel: zinsen = betrag_offen * (basiszins + aufschlag) / 100 * tage / 365
|
||||
*
|
||||
* @param float $betragOffen
|
||||
* @param int $tageVerzug
|
||||
* @param string $kundentyp B2C|B2B
|
||||
* @param float $basiszins in Prozent (z.B. 1.27)
|
||||
* @param float|null $zinssatzOverride Override aus Stufen-Konfig (Prozent)
|
||||
* @return float Zinsen in EUR (gerundet 2 Nachkomma)
|
||||
*/
|
||||
public static function berechneVerzugszinsen($betragOffen, $tageVerzug, $kundentyp, $basiszins, $zinssatzOverride = null)
|
||||
{
|
||||
$tage = max(0, (int) $tageVerzug);
|
||||
if ($tage <= 0 || $betragOffen <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($zinssatzOverride !== null) {
|
||||
$satz = (float) $zinssatzOverride;
|
||||
} else {
|
||||
$aufschlag = ($kundentyp === self::KUNDENTYP_B2B)
|
||||
? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0')
|
||||
: (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0');
|
||||
$satz = (float) $basiszins + $aufschlag;
|
||||
}
|
||||
|
||||
$zinsen = ((float) $betragOffen) * $satz / 100.0 * $tage / 365.0;
|
||||
return round($zinsen, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt $summe_mahnung = betrag_offen + mahngebuehr + pauschale_b2b + verzugszinsen.
|
||||
*
|
||||
* @return float Neue Summe
|
||||
*/
|
||||
public function rechneSumme()
|
||||
{
|
||||
$this->summe_mahnung = round(
|
||||
(float) $this->betrag_offen
|
||||
+ (float) $this->mahngebuehr
|
||||
+ (float) $this->pauschale_b2b
|
||||
+ (float) $this->verzugszinsen,
|
||||
2
|
||||
);
|
||||
return $this->summe_mahnung;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokalisiertes Status-Label.
|
||||
*
|
||||
* @param int|null $status Override (sonst $this->status)
|
||||
* @return string
|
||||
*/
|
||||
public function getStatusLabel($status = null)
|
||||
{
|
||||
global $langs;
|
||||
$s = $status ?? $this->status;
|
||||
switch ((int) $s) {
|
||||
case self::STATUS_ENTWURF: return $langs->trans('MahnungStatusEntwurf');
|
||||
case self::STATUS_ERSTELLT: return $langs->trans('MahnungStatusErstellt');
|
||||
case self::STATUS_VERSENDET: return $langs->trans('MahnungStatusVersendet');
|
||||
case self::STATUS_ERLEDIGT: return $langs->trans('MahnungStatusErledigt');
|
||||
case self::STATUS_STORNIERT: return $langs->trans('MahnungStatusStorniert');
|
||||
default: return (string) $s;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
class/mahnungcron.class.php
Normal file
124
class/mahnungcron.class.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License, version 3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/class/mahnungcron.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief Cron-Job: Vorschlagsliste ueberfaelliger Rechnungen einsammeln,
|
||||
* Ntfy-Push mit Kennzahl an Eddy.
|
||||
*/
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungntfy.class.php';
|
||||
|
||||
class MahnungCron
|
||||
{
|
||||
/** @var DoliDB */
|
||||
public $db;
|
||||
|
||||
/** @var string */
|
||||
public $error = '';
|
||||
|
||||
/** @var string[] */
|
||||
public $errors = array();
|
||||
|
||||
/** @var string */
|
||||
public $output = '';
|
||||
|
||||
/** @var int|string */
|
||||
public $lastresult = 0;
|
||||
|
||||
/**
|
||||
* @param DoliDB $db
|
||||
*/
|
||||
public function __construct($db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht ueberfaellige Rechnungen, ermittelt Vorschlaege je Stufe,
|
||||
* sendet Ntfy-Push mit Anzahl je Stufe und Gesamtwert.
|
||||
*
|
||||
* @return int 0 bei Erfolg, < 0 bei Fehler
|
||||
*/
|
||||
public function buildVorschlagsliste()
|
||||
{
|
||||
global $conf;
|
||||
|
||||
$service = new MahnungVorschlag($this->db);
|
||||
$vorschlaege = $service->getVorschlaege();
|
||||
|
||||
$count = count($vorschlaege);
|
||||
$counts = array(1 => 0, 2 => 0, 3 => 0);
|
||||
$summe = 0.0;
|
||||
foreach ($vorschlaege as $v) {
|
||||
$stufe = (int) $v['vorgeschlagene_stufe'];
|
||||
if (isset($counts[$stufe])) {
|
||||
$counts[$stufe]++;
|
||||
}
|
||||
$summe += (float) $v['betrag_offen'];
|
||||
}
|
||||
$summe = round($summe, 2);
|
||||
|
||||
if ($count === 0) {
|
||||
$this->output = 'Keine ueberfaelligen Rechnungen mit faelliger Mahnung.';
|
||||
$this->lastresult = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dolUrl = trim((string) getDolGlobalString('MAIN_INFO_SOCIETE_NOM', ''));
|
||||
$listUrl = self::buildAbsoluteUrl('/custom/mahnung/list.php?mode=vorschlag');
|
||||
|
||||
$title = 'Mahnwesen: '.$count.' offene Vorschlaege';
|
||||
$message = "Stufe 1 (Erinnerung): {$counts[1]}\n";
|
||||
$message .= "Stufe 2 (Mahnung): {$counts[2]}\n";
|
||||
$message .= "Stufe 3 (Letzte Mahnung): {$counts[3]}\n";
|
||||
$message .= 'Offener Betrag: '.number_format($summe, 2, ',', '.').' EUR';
|
||||
|
||||
MahnungNtfy::send($title, $message, $listUrl, array('envelope_with_arrow', 'warning'));
|
||||
|
||||
// Optional: GlobalNotify-Badge ins Dolibarr-UI (wenn Modul aktiv)
|
||||
if (isModEnabled('globalnotify') && class_exists('GlobalNotify') === false) {
|
||||
$gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php';
|
||||
if (file_exists($gnPath)) {
|
||||
require_once $gnPath;
|
||||
}
|
||||
}
|
||||
if (class_exists('GlobalNotify')) {
|
||||
GlobalNotify::actionRequired(
|
||||
'mahnung',
|
||||
'Mahnwesen: '.$count.' Vorschlaege',
|
||||
$message,
|
||||
$listUrl,
|
||||
'Vorschlagsliste oeffnen'
|
||||
);
|
||||
}
|
||||
|
||||
$this->output = $title.' — '.$message;
|
||||
$this->lastresult = $count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut eine absolute URL aus einem relativen Pfad anhand der Dolibarr-URL-Konfig.
|
||||
*
|
||||
* @param string $relPath
|
||||
* @return string
|
||||
*/
|
||||
private static function buildAbsoluteUrl($relPath)
|
||||
{
|
||||
$base = trim((string) getDolGlobalString('DOLIBARR_MAIN_URL_ROOT', ''));
|
||||
if (empty($base) && defined('DOL_MAIN_URL_ROOT')) {
|
||||
$base = DOL_MAIN_URL_ROOT;
|
||||
}
|
||||
if (empty($base)) {
|
||||
return $relPath;
|
||||
}
|
||||
return rtrim($base, '/').$relPath;
|
||||
}
|
||||
}
|
||||
94
class/mahnungntfy.class.php
Normal file
94
class/mahnungntfy.class.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License, version 3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/class/mahnungntfy.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief Schmaler Ntfy-Push fuer das Mahnwesen-Modul.
|
||||
*
|
||||
* Phase 3: standalone Ntfy.
|
||||
* Phase 7: wird durch GlobalNotify-Integration ergaenzt/ersetzt (notify-Skill).
|
||||
*
|
||||
* Konfig-Konstanten (Setup-Seite):
|
||||
* MAHNUNG_NTFY_TOPIC Default 'vk-builds'
|
||||
* MAHNUNG_NTFY_URL Default 'https://notify.data-it-solution.de'
|
||||
* MAHNUNG_NTFY_AUTH Optional: 'Basic ...'
|
||||
*/
|
||||
class MahnungNtfy
|
||||
{
|
||||
/**
|
||||
* Sendet einen Push. Nie blockierend — Fehler nur ins Syslog.
|
||||
*
|
||||
* @param string $title Titel (wird header-sanitisiert)
|
||||
* @param string $message Body
|
||||
* @param string $clickUrl Optional: Click-URL
|
||||
* @param array $tags Optional: Emoji-Tags (z.B. ['envelope_with_arrow'])
|
||||
* @return bool true wenn HTTP 2xx, sonst false
|
||||
*/
|
||||
public static function send($title, $message, $clickUrl = '', array $tags = array())
|
||||
{
|
||||
$url = trim((string) getDolGlobalString('MAHNUNG_NTFY_URL', 'https://notify.data-it-solution.de'));
|
||||
$topic = trim((string) getDolGlobalString('MAHNUNG_NTFY_TOPIC', 'vk-builds'));
|
||||
$auth = trim((string) getDolGlobalString('MAHNUNG_NTFY_AUTH', ''));
|
||||
|
||||
if (empty($url) || empty($topic)) {
|
||||
dol_syslog('MahnungNtfy: URL oder Topic nicht konfiguriert', LOG_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
$endpoint = rtrim($url, '/').'/'.rawurlencode($topic);
|
||||
|
||||
$headers = array(
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Title: '.self::sanitizeHeader($title),
|
||||
'Priority: default',
|
||||
);
|
||||
if (!empty($tags)) {
|
||||
$headers[] = 'Tags: '.self::sanitizeHeader(implode(',', $tags));
|
||||
}
|
||||
if (!empty($clickUrl)) {
|
||||
$headers[] = 'Click: '.self::sanitizeHeader($clickUrl);
|
||||
}
|
||||
if (!empty($auth)) {
|
||||
$headers[] = 'Authorization: '.$auth;
|
||||
}
|
||||
|
||||
if (!function_exists('curl_init')) {
|
||||
dol_syslog('MahnungNtfy: cURL nicht verfuegbar', LOG_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return true;
|
||||
}
|
||||
dol_syslog('MahnungNtfy: Push fehlgeschlagen ('.$httpCode.') '.$error, LOG_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header-Werte muessen Single-Line sein. Newlines + Steuerzeichen entfernen.
|
||||
*
|
||||
* @param string $value
|
||||
* @return string
|
||||
*/
|
||||
private static function sanitizeHeader($value)
|
||||
{
|
||||
return trim(preg_replace('/[\r\n\x00-\x1F\x7F]/', ' ', (string) $value));
|
||||
}
|
||||
}
|
||||
360
class/mahnungpdf.class.php
Normal file
360
class/mahnungpdf.class.php
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License, version 3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/class/mahnungpdf.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief PDF-Generator fuer Mahnschreiben (DIN-5008 Form A).
|
||||
*
|
||||
* Nutzt Dolibarrs TCPDF-Wrapper (pdf_getInstance) und schreibt das fertige
|
||||
* PDF in den Dokumenten-Ordner der Original-Rechnung
|
||||
* documents/facture/{ref-rechnung}/mahnung-{stufe}-{ref-mahnung}.pdf
|
||||
* Damit erscheint die Mahnung automatisch im Dokumente-Tab der Rechnung.
|
||||
*/
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
||||
|
||||
class MahnungPdf
|
||||
{
|
||||
/** @var DoliDB */
|
||||
public $db;
|
||||
|
||||
/** @var string */
|
||||
public $error = '';
|
||||
|
||||
/**
|
||||
* @param DoliDB $db
|
||||
*/
|
||||
public function __construct($db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das PDF zum Mahnvorgang. Setzt $mahnung->pdf_path nach Erfolg
|
||||
* und schreibt sie via update($user) in die DB.
|
||||
*
|
||||
* @param Mahnung $mahnung
|
||||
* @param User $user
|
||||
* @return string|false Absoluter Pfad zur PDF-Datei oder false bei Fehler
|
||||
*/
|
||||
public function generate(Mahnung $mahnung, $user)
|
||||
{
|
||||
global $conf, $langs, $mysoc;
|
||||
|
||||
$langs->loadLangs(array('main', 'bills', 'companies', 'mahnung@mahnung'));
|
||||
|
||||
// Original-Rechnung + Kunde laden
|
||||
$facture = new Facture($this->db);
|
||||
if ($facture->fetch((int) $mahnung->fk_facture) <= 0) {
|
||||
$this->error = 'Rechnung '.((int) $mahnung->fk_facture).' nicht ladbar.';
|
||||
return false;
|
||||
}
|
||||
|
||||
$societe = new Societe($this->db);
|
||||
if ($societe->fetch((int) $mahnung->fk_soc) <= 0) {
|
||||
$this->error = 'Kunde '.((int) $mahnung->fk_soc).' nicht ladbar.';
|
||||
return false;
|
||||
}
|
||||
|
||||
$stufeObj = new MahnungStufe($this->db);
|
||||
if ($stufeObj->fetchByStufe((int) $mahnung->stufe) <= 0) {
|
||||
$this->error = 'Mahnstufe '.((int) $mahnung->stufe).' nicht konfiguriert.';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ziel-Verzeichnis im Doc-Ordner der Rechnung
|
||||
$dirOutput = $this->getOutputDir($facture);
|
||||
if (!dol_mkdir($dirOutput)) {
|
||||
$this->error = 'Kann Verzeichnis nicht anlegen: '.$dirOutput;
|
||||
return false;
|
||||
}
|
||||
$filename = 'mahnung-'.((int) $mahnung->stufe).'-'.dol_sanitizeFileName($mahnung->ref).'.pdf';
|
||||
$absPath = $dirOutput.'/'.$filename;
|
||||
|
||||
// PDF-Instanz
|
||||
$pdf = pdf_getInstance(array('210', '297'));
|
||||
$default_font_size = pdf_getPDFFontSize($langs);
|
||||
$pdf->SetAutoPageBreak(true, 25);
|
||||
$pdf->SetFont(pdf_getPDFFont($langs), '', $default_font_size);
|
||||
|
||||
$pdf->SetTitle($langs->trans('MahnungStufe').' '.((int) $mahnung->stufe).' — '.$facture->ref);
|
||||
$pdf->SetSubject($langs->trans('MahnungRef').' '.$mahnung->ref);
|
||||
$pdf->SetAuthor((string) $mysoc->name);
|
||||
$pdf->SetCreator('Dolibarr Mahnung-Modul');
|
||||
|
||||
$pdf->Open();
|
||||
$pdf->AddPage();
|
||||
|
||||
$this->renderHeader($pdf, $facture, $societe, $mahnung, $stufeObj);
|
||||
$this->renderBody($pdf, $facture, $societe, $mahnung, $stufeObj);
|
||||
$this->renderFooter($pdf);
|
||||
|
||||
$pdf->Output($absPath, 'F');
|
||||
|
||||
// Pfad in DB persistieren
|
||||
$mahnung->pdf_path = $absPath;
|
||||
$mahnung->update($user);
|
||||
|
||||
return $absPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adressfenster, Datum, Betreff (DIN-5008 Form A: Adressfeld 45mm hoch ab 27mm).
|
||||
*
|
||||
* @param TCPDF $pdf
|
||||
* @param Facture $facture
|
||||
* @param Societe $societe
|
||||
* @param Mahnung $mahnung
|
||||
* @param MahnungStufe $stufe
|
||||
* @return void
|
||||
*/
|
||||
private function renderHeader($pdf, $facture, $societe, $mahnung, $stufe)
|
||||
{
|
||||
global $langs, $mysoc;
|
||||
|
||||
// Absender klein im Adressfeld (Faltmarke darueber, oben in DIN 5008 Adresszeile)
|
||||
$pdf->SetFont('helvetica', '', 7);
|
||||
$pdf->SetXY(25, 50);
|
||||
$senderLine = trim(($mysoc->name ?? '').' · '.($mysoc->address ?? '').' · '.($mysoc->zip ?? '').' '.($mysoc->town ?? ''));
|
||||
$pdf->Cell(85, 4, $senderLine, 0, 1, 'L');
|
||||
|
||||
// Empfaenger-Block (Adressfenster: links 25mm, ab y=55)
|
||||
$pdf->SetFont('helvetica', '', 11);
|
||||
$pdf->SetXY(25, 55);
|
||||
$lines = array();
|
||||
if (!empty($societe->name)) {
|
||||
$lines[] = $societe->name;
|
||||
}
|
||||
if (!empty($societe->name_alias)) {
|
||||
$lines[] = $societe->name_alias;
|
||||
}
|
||||
if (!empty($societe->address)) {
|
||||
$lines[] = $societe->address;
|
||||
}
|
||||
$ortzeile = trim(($societe->zip ?? '').' '.($societe->town ?? ''));
|
||||
if (!empty($ortzeile)) {
|
||||
$lines[] = $ortzeile;
|
||||
}
|
||||
foreach ($lines as $line) {
|
||||
$pdf->Cell(85, 5, $line, 0, 1, 'L');
|
||||
$pdf->SetX(25);
|
||||
}
|
||||
|
||||
// Bezugszeichen-Zeile rechts (DIN-5008): Datum + Mahn-Nr.
|
||||
$pdf->SetFont('helvetica', '', 9);
|
||||
$pdf->SetXY(125, 50);
|
||||
$pdf->Cell(60, 4, $langs->trans('Date').': '.dol_print_date($mahnung->date_mahnung, 'day'), 0, 1, 'L');
|
||||
$pdf->SetX(125);
|
||||
$pdf->Cell(60, 4, $langs->trans('MahnungRef').': '.$mahnung->ref, 0, 1, 'L');
|
||||
$pdf->SetX(125);
|
||||
$pdf->Cell(60, 4, $langs->trans('MahnungRechnung').': '.$facture->ref, 0, 1, 'L');
|
||||
|
||||
// Betreff
|
||||
$pdf->SetXY(25, 100);
|
||||
$pdf->SetFont('helvetica', 'B', 12);
|
||||
$betreff = $stufe->label.' — '.$langs->trans('MahnungRechnung').' '.$facture->ref;
|
||||
$pdf->Cell(0, 6, $betreff, 0, 1, 'L');
|
||||
}
|
||||
|
||||
/**
|
||||
* Anrede, Intro, Tabelle (Rechnung/Datum/Betrag/gezahlt/offen),
|
||||
* Gebuehrenblock, Gesamtsumme, neue Frist, Bankverbindung.
|
||||
*
|
||||
* @param TCPDF $pdf
|
||||
* @param Facture $facture
|
||||
* @param Societe $societe
|
||||
* @param Mahnung $mahnung
|
||||
* @param MahnungStufe $stufe
|
||||
* @return void
|
||||
*/
|
||||
private function renderBody($pdf, $facture, $societe, $mahnung, $stufe)
|
||||
{
|
||||
global $langs, $mysoc;
|
||||
|
||||
$pdf->SetFont('helvetica', '', 11);
|
||||
$pdf->SetXY(25, 110);
|
||||
|
||||
// Anrede
|
||||
$anrede = 'Sehr geehrte Damen und Herren,';
|
||||
$pdf->Cell(0, 5, $anrede, 0, 1, 'L');
|
||||
$pdf->SetX(25);
|
||||
$pdf->Ln(2);
|
||||
|
||||
// Intro aus Stufen-Konfig (Fallback Default-Text je Stufe)
|
||||
$intro = (string) $stufe->pdf_intro;
|
||||
if (empty($intro)) {
|
||||
$intro = $this->defaultIntro((int) $mahnung->stufe);
|
||||
}
|
||||
$pdf->SetX(25);
|
||||
$pdf->MultiCell(160, 5, $intro, 0, 'L');
|
||||
$pdf->Ln(3);
|
||||
|
||||
// Rechnungs-Tabelle
|
||||
$pdf->SetFont('helvetica', 'B', 10);
|
||||
$pdf->SetX(25);
|
||||
$pdf->Cell(40, 6, $langs->trans('MahnungRechnung'), 'B', 0, 'L');
|
||||
$pdf->Cell(30, 6, $langs->trans('Date'), 'B', 0, 'L');
|
||||
$pdf->Cell(30, 6, $langs->trans('TotalTTC'), 'B', 0, 'R');
|
||||
$pdf->Cell(30, 6, $langs->trans('AlreadyPaid'), 'B', 0, 'R');
|
||||
$pdf->Cell(30, 6, $langs->trans('MahnungBetragOffen'), 'B', 1, 'R');
|
||||
|
||||
$pdf->SetFont('helvetica', '', 10);
|
||||
$pdf->SetX(25);
|
||||
$gezahlt = (float) $facture->total_ttc - (float) $mahnung->betrag_offen;
|
||||
$pdf->Cell(40, 6, $facture->ref, 0, 0, 'L');
|
||||
$pdf->Cell(30, 6, dol_print_date($facture->date, 'day'), 0, 0, 'L');
|
||||
$pdf->Cell(30, 6, price((float) $facture->total_ttc).' EUR', 0, 0, 'R');
|
||||
$pdf->Cell(30, 6, price($gezahlt).' EUR', 0, 0, 'R');
|
||||
$pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R');
|
||||
$pdf->Ln(3);
|
||||
|
||||
// Gebuehrenblock
|
||||
$pdf->SetX(25);
|
||||
$pdf->SetFont('helvetica', '', 10);
|
||||
$pdf->Cell(130, 6, $langs->trans('MahnungBetragOffen'), 0, 0, 'L');
|
||||
$pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R');
|
||||
if ((float) $mahnung->mahngebuehr > 0) {
|
||||
$pdf->SetX(25);
|
||||
$pdf->Cell(130, 6, $langs->trans('MahnungGebuehr'), 0, 0, 'L');
|
||||
$pdf->Cell(30, 6, price((float) $mahnung->mahngebuehr).' EUR', 0, 1, 'R');
|
||||
}
|
||||
if ((float) $mahnung->pauschale_b2b > 0) {
|
||||
$pdf->SetX(25);
|
||||
$pdf->Cell(130, 6, $langs->trans('MahnungPauschaleB2B').' (BGB §288 Abs. 5)', 0, 0, 'L');
|
||||
$pdf->Cell(30, 6, price((float) $mahnung->pauschale_b2b).' EUR', 0, 1, 'R');
|
||||
}
|
||||
if ((float) $mahnung->verzugszinsen > 0) {
|
||||
$pdf->SetX(25);
|
||||
$basis = $mahnung->basiszins_snapshot !== null ? (float) $mahnung->basiszins_snapshot : 0.0;
|
||||
$auf = $mahnung->customertype === Mahnung::KUNDENTYP_B2B
|
||||
? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0')
|
||||
: (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0');
|
||||
$satz = $basis + $auf;
|
||||
$pdf->Cell(130, 6, $langs->trans('MahnungVerzugszinsen').' ('.number_format($satz, 2, ',', '.').' %)', 0, 0, 'L');
|
||||
$pdf->Cell(30, 6, price((float) $mahnung->verzugszinsen).' EUR', 0, 1, 'R');
|
||||
}
|
||||
|
||||
// Gesamtsumme
|
||||
$pdf->Ln(1);
|
||||
$pdf->SetX(25);
|
||||
$pdf->SetFont('helvetica', 'B', 11);
|
||||
$pdf->Cell(130, 7, $langs->trans('MahnungSumme'), 'T', 0, 'L');
|
||||
$pdf->Cell(30, 7, price((float) $mahnung->summe_mahnung).' EUR', 'T', 1, 'R');
|
||||
|
||||
$pdf->Ln(5);
|
||||
|
||||
// Neue Frist
|
||||
$pdf->SetX(25);
|
||||
$pdf->SetFont('helvetica', '', 11);
|
||||
$frist = $mahnung->date_lim_reglement_neu ? dol_print_date($mahnung->date_lim_reglement_neu, 'day') : '';
|
||||
$fristText = empty($frist)
|
||||
? 'Wir bitten Sie um umgehende Begleichung.'
|
||||
: 'Wir bitten Sie, den ausstehenden Betrag bis spaetestens '.$frist.' auf das unten genannte Konto zu ueberweisen.';
|
||||
$pdf->MultiCell(160, 5, $fristText, 0, 'L');
|
||||
|
||||
$pdf->Ln(4);
|
||||
$pdf->SetX(25);
|
||||
$pdf->Cell(0, 5, 'Mit freundlichen Gruessen', 0, 1, 'L');
|
||||
$pdf->SetX(25);
|
||||
$pdf->Cell(0, 5, (string) $mysoc->name, 0, 1, 'L');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusszeile mit Bankverbindung + Firmen-Footer.
|
||||
*
|
||||
* @param TCPDF $pdf
|
||||
* @return void
|
||||
*/
|
||||
private function renderFooter($pdf)
|
||||
{
|
||||
global $mysoc;
|
||||
|
||||
$pdf->SetY(-30);
|
||||
$pdf->SetFont('helvetica', 'I', 8);
|
||||
$lines = array();
|
||||
$lines[] = trim(($mysoc->name ?? '').' · '.($mysoc->address ?? '').' · '.($mysoc->zip ?? '').' '.($mysoc->town ?? ''));
|
||||
if (!empty($mysoc->email)) {
|
||||
$lines[] = 'E-Mail: '.$mysoc->email;
|
||||
}
|
||||
if (!empty($mysoc->phone)) {
|
||||
$lines[] = 'Tel: '.$mysoc->phone;
|
||||
}
|
||||
// Bankverbindung aus Standard-Bankaccount
|
||||
$bankAccount = $this->getDefaultBankLine();
|
||||
if (!empty($bankAccount)) {
|
||||
$lines[] = $bankAccount;
|
||||
}
|
||||
foreach ($lines as $l) {
|
||||
$pdf->Cell(0, 4, $l, 0, 1, 'C');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Bankkonto in einer Zeile (Bank · IBAN · BIC).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getDefaultBankLine()
|
||||
{
|
||||
$sql = "SELECT label, iban_prefix as iban, bic FROM ".MAIN_DB_PREFIX."bank_account";
|
||||
$sql .= " WHERE clos = 0 AND default_rib = 1";
|
||||
$sql .= " LIMIT 1";
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql || !$this->db->num_rows($resql)) {
|
||||
return '';
|
||||
}
|
||||
$obj = $this->db->fetch_object($resql);
|
||||
$this->db->free($resql);
|
||||
$parts = array_filter(array($obj->label, $obj->iban ? 'IBAN '.$obj->iban : '', $obj->bic ? 'BIC '.$obj->bic : ''));
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default-Intro je Stufe (wenn Setup leer ist).
|
||||
*
|
||||
* @param int $stufe
|
||||
* @return string
|
||||
*/
|
||||
private function defaultIntro($stufe)
|
||||
{
|
||||
switch ((int) $stufe) {
|
||||
case 1:
|
||||
return 'unsere unten aufgefuehrte Rechnung ist trotz Ablauf der Zahlungsfrist noch nicht beglichen. '
|
||||
. 'Vielleicht ist Ihnen dies entgangen — wir bitten Sie hoeflich, den ausstehenden Betrag zeitnah '
|
||||
. 'zu ueberweisen.';
|
||||
case 2:
|
||||
return 'leider mussten wir feststellen, dass die unten aufgefuehrte Rechnung trotz unserer '
|
||||
. 'Zahlungserinnerung weiterhin offen ist. Wir bitten Sie nun nachdruecklich um Begleichung '
|
||||
. 'des offenen Betrags zuzueglich Verzugszinsen und Mahnkosten.';
|
||||
case 3:
|
||||
default:
|
||||
return 'wir haben Sie bereits zweimal an die Begleichung der unten aufgefuehrten Rechnung erinnert. '
|
||||
. 'Sollte der offene Betrag inkl. Verzugszinsen und Mahnkosten nicht innerhalb der angegebenen Frist '
|
||||
. 'auf unserem Konto eingehen, sehen wir uns gezwungen, weitere rechtliche Schritte einzuleiten.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ziel-Verzeichnis: documents/facture/{ref}/
|
||||
*
|
||||
* @param Facture $facture
|
||||
* @return string
|
||||
*/
|
||||
private function getOutputDir($facture)
|
||||
{
|
||||
global $conf;
|
||||
$documentDir = !empty($conf->facture->multidir_output[$facture->entity])
|
||||
? $conf->facture->multidir_output[$facture->entity]
|
||||
: $conf->facture->dir_output;
|
||||
return rtrim($documentDir, '/').'/'.dol_sanitizeFileName($facture->ref);
|
||||
}
|
||||
}
|
||||
231
class/mahnungstufe.class.php
Normal file
231
class/mahnungstufe.class.php
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License, version 3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/class/mahnungstufe.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief Konfigurations-Klasse fuer Mahnstufen (llx_mahnung_stufe).
|
||||
*/
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
|
||||
|
||||
/**
|
||||
* Eine Mahnstufe (1..3): Frist-Konfiguration, Gebuehren, optionaler Zinssatz-Override,
|
||||
* Versandart-Default, E-Mail-/PDF-Templates.
|
||||
*/
|
||||
class MahnungStufe extends CommonObject
|
||||
{
|
||||
/** @var string */
|
||||
public $element = 'mahnungstufe';
|
||||
|
||||
/** @var string */
|
||||
public $table_element = 'mahnung_stufe';
|
||||
|
||||
/** @var int */
|
||||
public $entity;
|
||||
|
||||
/** @var int 1..3 */
|
||||
public $stufe;
|
||||
|
||||
/** @var string */
|
||||
public $label;
|
||||
|
||||
/** @var int Tage nach Faelligkeit (Stufe 1) bzw. nach Vorgaengerstufe (>1) */
|
||||
public $frist_tage = 0;
|
||||
|
||||
/** @var int Neue Zahlungsfrist im Mahnschreiben (Tage) */
|
||||
public $neue_frist_tage = 7;
|
||||
|
||||
/** @var float */
|
||||
public $mahngebuehr_b2c = 0;
|
||||
|
||||
/** @var float */
|
||||
public $mahngebuehr_b2b = 0;
|
||||
|
||||
/** @var int 1 = nur in dieser Stufe Pauschale §288 Abs. 5 berechnen */
|
||||
public $pauschale_b2b_einmalig = 0;
|
||||
|
||||
/** @var float|null Override Basiszins+5 % B2C-Default */
|
||||
public $zinssatz_b2c_uebersteuern;
|
||||
|
||||
/** @var float|null Override Basiszins+9 % B2B-Default */
|
||||
public $zinssatz_b2b_uebersteuern;
|
||||
|
||||
/** @var string pdf|mail|druck|none */
|
||||
public $versandart_default = 'pdf';
|
||||
|
||||
/** @var string */
|
||||
public $email_subject;
|
||||
|
||||
/** @var string */
|
||||
public $email_body;
|
||||
|
||||
/** @var string */
|
||||
public $pdf_intro;
|
||||
|
||||
/** @var int 0|1 */
|
||||
public $active = 1;
|
||||
|
||||
/** @var int Unix-Zeit */
|
||||
public $datec;
|
||||
|
||||
/** @var int Unix-Zeit */
|
||||
public $tms;
|
||||
|
||||
/**
|
||||
* @param DoliDB $db Datenbank-Handler
|
||||
*/
|
||||
public function __construct($db)
|
||||
{
|
||||
global $conf;
|
||||
$this->db = $db;
|
||||
$this->entity = $conf->entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $stufe 1..3
|
||||
* @return int -1 Fehler, 0 nicht gefunden, >0 OK
|
||||
*/
|
||||
public function fetchByStufe($stufe)
|
||||
{
|
||||
$sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t";
|
||||
$sql .= " WHERE t.entity = ".((int) $this->entity);
|
||||
$sql .= " AND t.stufe = ".((int) $stufe);
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
return -1;
|
||||
}
|
||||
if (!$this->db->num_rows($resql)) {
|
||||
$this->db->free($resql);
|
||||
return 0;
|
||||
}
|
||||
$obj = $this->db->fetch_object($resql);
|
||||
$this->loadFromObj($obj);
|
||||
$this->db->free($resql);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle aktiven Stufen geordnet nach stufe ASC.
|
||||
*
|
||||
* @return MahnungStufe[]
|
||||
*/
|
||||
public function fetchAllActive()
|
||||
{
|
||||
$sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t";
|
||||
$sql .= " WHERE t.entity = ".((int) $this->entity);
|
||||
$sql .= " AND t.active = 1";
|
||||
$sql .= " ORDER BY t.stufe ASC";
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
$result = array();
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
return $result;
|
||||
}
|
||||
while ($obj = $this->db->fetch_object($resql)) {
|
||||
$s = new self($this->db);
|
||||
$s->loadFromObj($obj);
|
||||
$result[] = $s;
|
||||
}
|
||||
$this->db->free($resql);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @return int <0 Fehler, sonst rowid
|
||||
*/
|
||||
public function update($user)
|
||||
{
|
||||
if (empty($this->id)) {
|
||||
$this->error = 'MahnungStufe::update — id missing';
|
||||
return -1;
|
||||
}
|
||||
|
||||
$sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_stufe SET";
|
||||
$sql .= " label = '".$this->db->escape($this->label)."'";
|
||||
$sql .= ", frist_tage = ".((int) $this->frist_tage);
|
||||
$sql .= ", neue_frist_tage = ".((int) $this->neue_frist_tage);
|
||||
$sql .= ", mahngebuehr_b2c = ".((float) $this->mahngebuehr_b2c);
|
||||
$sql .= ", mahngebuehr_b2b = ".((float) $this->mahngebuehr_b2b);
|
||||
$sql .= ", pauschale_b2b_einmalig = ".((int) (!empty($this->pauschale_b2b_einmalig) ? 1 : 0));
|
||||
$sql .= ", zinssatz_b2c_uebersteuern = ".($this->zinssatz_b2c_uebersteuern !== null && $this->zinssatz_b2c_uebersteuern !== '' ? ((float) $this->zinssatz_b2c_uebersteuern) : "NULL");
|
||||
$sql .= ", zinssatz_b2b_uebersteuern = ".($this->zinssatz_b2b_uebersteuern !== null && $this->zinssatz_b2b_uebersteuern !== '' ? ((float) $this->zinssatz_b2b_uebersteuern) : "NULL");
|
||||
$sql .= ", versandart_default = '".$this->db->escape($this->versandart_default ?: 'pdf')."'";
|
||||
$sql .= ", email_subject = ".($this->email_subject ? "'".$this->db->escape($this->email_subject)."'" : "NULL");
|
||||
$sql .= ", email_body = ".($this->email_body ? "'".$this->db->escape($this->email_body)."'" : "NULL");
|
||||
$sql .= ", pdf_intro = ".($this->pdf_intro ? "'".$this->db->escape($this->pdf_intro)."'" : "NULL");
|
||||
$sql .= ", active = ".((int) (!empty($this->active) ? 1 : 0));
|
||||
$sql .= " WHERE rowid = ".((int) $this->id);
|
||||
|
||||
dol_syslog(get_class($this).'::update', LOG_DEBUG);
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
return -1;
|
||||
}
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mahngebuehr fuer einen Kundentyp aus dieser Stufe lesen.
|
||||
*
|
||||
* @param string $kundentyp 'B2C'|'B2B'
|
||||
* @return float
|
||||
*/
|
||||
public function getMahngebuehr($kundentyp)
|
||||
{
|
||||
return $kundentyp === Mahnung::KUNDENTYP_B2B
|
||||
? (float) $this->mahngebuehr_b2b
|
||||
: (float) $this->mahngebuehr_b2c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override-Zinssatz fuer einen Kundentyp (oder null falls Default gewuenscht).
|
||||
*
|
||||
* @param string $kundentyp
|
||||
* @return float|null
|
||||
*/
|
||||
public function getZinssatzOverride($kundentyp)
|
||||
{
|
||||
$value = $kundentyp === Mahnung::KUNDENTYP_B2B
|
||||
? $this->zinssatz_b2b_uebersteuern
|
||||
: $this->zinssatz_b2c_uebersteuern;
|
||||
return ($value === null || $value === '') ? null : (float) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties aus DB-Object-Reihe laden.
|
||||
*
|
||||
* @param object $obj
|
||||
* @return void
|
||||
*/
|
||||
private function loadFromObj($obj)
|
||||
{
|
||||
$this->id = $obj->rowid;
|
||||
$this->entity = $obj->entity;
|
||||
$this->stufe = (int) $obj->stufe;
|
||||
$this->label = $obj->label;
|
||||
$this->frist_tage = (int) $obj->frist_tage;
|
||||
$this->neue_frist_tage = (int) $obj->neue_frist_tage;
|
||||
$this->mahngebuehr_b2c = $obj->mahngebuehr_b2c;
|
||||
$this->mahngebuehr_b2b = $obj->mahngebuehr_b2b;
|
||||
$this->pauschale_b2b_einmalig = (int) $obj->pauschale_b2b_einmalig;
|
||||
$this->zinssatz_b2c_uebersteuern = $obj->zinssatz_b2c_uebersteuern;
|
||||
$this->zinssatz_b2b_uebersteuern = $obj->zinssatz_b2b_uebersteuern;
|
||||
$this->versandart_default = $obj->versandart_default;
|
||||
$this->email_subject = $obj->email_subject;
|
||||
$this->email_body = $obj->email_body;
|
||||
$this->pdf_intro = $obj->pdf_intro;
|
||||
$this->active = (int) $obj->active;
|
||||
$this->datec = $this->db->jdate($obj->datec);
|
||||
$this->tms = $this->db->jdate($obj->tms);
|
||||
}
|
||||
}
|
||||
232
class/mahnungvorschlag.class.php
Normal file
232
class/mahnungvorschlag.class.php
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License, version 3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file htdocs/custom/mahnung/class/mahnungvorschlag.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief Service: ueberfaellige Rechnungen einsammeln und je Rechnung
|
||||
* die naechste vorgeschlagene Mahnstufe ermitteln.
|
||||
*
|
||||
* Geteilte Logik zwischen Cron-Job (Ntfy-Push) und Vorschlagslisten-UI.
|
||||
*/
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
||||
|
||||
class MahnungVorschlag
|
||||
{
|
||||
/** @var DoliDB */
|
||||
public $db;
|
||||
|
||||
/** @var int */
|
||||
public $entity;
|
||||
|
||||
/** @var MahnungStufe[] indexed by stufe (1..3) */
|
||||
private $stufen = array();
|
||||
|
||||
/**
|
||||
* @param DoliDB $db
|
||||
*/
|
||||
public function __construct($db)
|
||||
{
|
||||
global $conf;
|
||||
$this->db = $db;
|
||||
$this->entity = $conf->entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert pro ueberfaelliger Rechnung einen Vorschlag (oder ueberspringt sie,
|
||||
* wenn alle Stufen bereits durchlaufen sind oder die Wartefrist noch laeuft).
|
||||
*
|
||||
* Rueckgabe-Schluessel je Eintrag:
|
||||
* facture_id, facture_ref, facture_date_lim_reglement (Unix), facture_total_ttc,
|
||||
* soc_id, soc_nom, soc_tva_intra,
|
||||
* kundentyp ('B2C'|'B2B'),
|
||||
* tage_verzug,
|
||||
* betrag_offen,
|
||||
* letzte_mahnung_id (int|null), letzte_mahnung_stufe (int|null), letzte_mahnung_datum (Unix|null),
|
||||
* vorgeschlagene_stufe (int 1..3),
|
||||
* vorgeschlagene_stufe_label (string)
|
||||
*
|
||||
* @param array $filter Optional: 'soc_id', 'min_tage_verzug', 'max_tage_verzug', 'stufe'
|
||||
* @return array
|
||||
*/
|
||||
public function getVorschlaege(array $filter = array())
|
||||
{
|
||||
$this->loadStufen();
|
||||
if (empty($this->stufen)) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$today = dol_now();
|
||||
$sql = "SELECT f.rowid AS facture_id, f.ref AS facture_ref, f.date_lim_reglement,";
|
||||
$sql .= " f.total_ttc, f.fk_soc, f.paye, f.statut,";
|
||||
$sql .= " s.nom AS soc_nom, s.tva_intra";
|
||||
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
|
||||
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
|
||||
$sql .= " WHERE f.entity = ".((int) $this->entity);
|
||||
$sql .= " AND f.statut = 1";
|
||||
$sql .= " AND f.paye = 0";
|
||||
$sql .= " AND f.type IN (0, 2, 3)"; // Standard, Avoir, Acompte (keine Replacements)
|
||||
$sql .= " AND f.date_lim_reglement IS NOT NULL";
|
||||
$sql .= " AND f.date_lim_reglement < '".$this->db->idate($today)."'";
|
||||
|
||||
if (!empty($filter['soc_id'])) {
|
||||
$sql .= " AND f.fk_soc = ".((int) $filter['soc_id']);
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY f.date_lim_reglement ASC";
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
dol_syslog('MahnungVorschlag::getVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR);
|
||||
return array();
|
||||
}
|
||||
|
||||
$result = array();
|
||||
while ($obj = $this->db->fetch_object($resql)) {
|
||||
$row = $this->buildVorschlag($obj, $today);
|
||||
if ($row === null) {
|
||||
continue;
|
||||
}
|
||||
if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) {
|
||||
continue;
|
||||
}
|
||||
if (isset($filter['max_tage_verzug']) && $row['tage_verzug'] > (int) $filter['max_tage_verzug']) {
|
||||
continue;
|
||||
}
|
||||
if (isset($filter['stufe']) && $filter['stufe'] !== '' && (int) $row['vorgeschlagene_stufe'] !== (int) $filter['stufe']) {
|
||||
continue;
|
||||
}
|
||||
$result[] = $row;
|
||||
}
|
||||
$this->db->free($resql);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet fuer eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird.
|
||||
*
|
||||
* @param object $factureObj DB-Reihe aus facture+societe
|
||||
* @param int $today Unix-Zeit
|
||||
* @return array|null
|
||||
*/
|
||||
private function buildVorschlag($factureObj, $today)
|
||||
{
|
||||
$dateLim = $this->db->jdate($factureObj->date_lim_reglement);
|
||||
if (empty($dateLim)) {
|
||||
return null;
|
||||
}
|
||||
$tageVerzug = (int) floor(($today - $dateLim) / 86400);
|
||||
if ($tageVerzug < 0) {
|
||||
$tageVerzug = 0;
|
||||
}
|
||||
|
||||
$kundentyp = !empty($factureObj->tva_intra) ? Mahnung::KUNDENTYP_B2B : Mahnung::KUNDENTYP_B2C;
|
||||
|
||||
// Letzte aktive Mahnung zur Rechnung holen
|
||||
$lastMahnung = (new Mahnung($this->db))->fetchLastByFacture((int) $factureObj->facture_id);
|
||||
|
||||
// Naechste Stufe ermitteln
|
||||
$proposedStufe = null;
|
||||
if ($lastMahnung === null) {
|
||||
// Noch nichts gemahnt -> Stufe 1, sobald frist_tage erreicht
|
||||
if (isset($this->stufen[1]) && $tageVerzug >= (int) $this->stufen[1]->frist_tage) {
|
||||
$proposedStufe = 1;
|
||||
}
|
||||
} else {
|
||||
// Bereits gemahnt -> naechste Stufe wenn Wartefrist seit letzter Mahnung abgelaufen
|
||||
$lastStufe = (int) $lastMahnung->stufe;
|
||||
$nextStufe = $lastStufe + 1;
|
||||
if ($lastStufe >= 3 || !isset($this->stufen[$nextStufe])) {
|
||||
return null; // alle Stufen ausgeschoepft
|
||||
}
|
||||
// Wartefrist: neue_frist_tage der zuletzt gemahnten Stufe
|
||||
$wartefrist = isset($this->stufen[$lastStufe]) ? (int) $this->stufen[$lastStufe]->neue_frist_tage : 7;
|
||||
$tageSeitMahnung = (int) floor(($today - $lastMahnung->date_mahnung) / 86400);
|
||||
if ($tageSeitMahnung >= $wartefrist) {
|
||||
$proposedStufe = $nextStufe;
|
||||
}
|
||||
}
|
||||
|
||||
if ($proposedStufe === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Offenen Betrag berechnen (total_ttc - Summe aller Zahlungen)
|
||||
$betragOffen = $this->getBetragOffen((int) $factureObj->facture_id, (float) $factureObj->total_ttc);
|
||||
if ($betragOffen <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'facture_id' => (int) $factureObj->facture_id,
|
||||
'facture_ref' => $factureObj->facture_ref,
|
||||
'facture_date_lim_reglement' => $dateLim,
|
||||
'facture_total_ttc' => (float) $factureObj->total_ttc,
|
||||
'soc_id' => (int) $factureObj->fk_soc,
|
||||
'soc_nom' => $factureObj->soc_nom,
|
||||
'soc_tva_intra' => $factureObj->tva_intra,
|
||||
'kundentyp' => $kundentyp,
|
||||
'tage_verzug' => $tageVerzug,
|
||||
'betrag_offen' => $betragOffen,
|
||||
'letzte_mahnung_id' => $lastMahnung ? (int) $lastMahnung->id : null,
|
||||
'letzte_mahnung_stufe' => $lastMahnung ? (int) $lastMahnung->stufe : null,
|
||||
'letzte_mahnung_datum' => $lastMahnung ? $lastMahnung->date_mahnung : null,
|
||||
'vorgeschlagene_stufe' => $proposedStufe,
|
||||
'vorgeschlagene_stufe_label' => $this->stufen[$proposedStufe]->label,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offener Betrag = total_ttc - SUM(paiement.amount).
|
||||
*
|
||||
* @param int $factureId
|
||||
* @param float $totalTtc
|
||||
* @return float
|
||||
*/
|
||||
private function getBetragOffen($factureId, $totalTtc)
|
||||
{
|
||||
$sql = "SELECT COALESCE(SUM(pf.amount), 0) AS gezahlt";
|
||||
$sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pf";
|
||||
$sql .= " WHERE pf.fk_facture = ".((int) $factureId);
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
return (float) $totalTtc;
|
||||
}
|
||||
$obj = $this->db->fetch_object($resql);
|
||||
$this->db->free($resql);
|
||||
return round(((float) $totalTtc) - ((float) $obj->gezahlt), 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufen einmal in $this->stufen[1..3] cachen.
|
||||
*/
|
||||
private function loadStufen()
|
||||
{
|
||||
if (!empty($this->stufen)) {
|
||||
return;
|
||||
}
|
||||
$so = new MahnungStufe($this->db);
|
||||
foreach ($so->fetchAllActive() as $s) {
|
||||
$this->stufen[(int) $s->stufe] = $s;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die geladene MahnungStufe (1..3) zurueck oder null.
|
||||
*
|
||||
* @param int $stufe
|
||||
* @return MahnungStufe|null
|
||||
*/
|
||||
public function getStufe($stufe)
|
||||
{
|
||||
$this->loadStufen();
|
||||
return $this->stufen[(int) $stufe] ?? null;
|
||||
}
|
||||
}
|
||||
304
core/modules/modMahnung.class.php
Normal file
304
core/modules/modMahnung.class.php
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \defgroup mahnung Modul Mahnwesen
|
||||
* \brief Mahnwesen-Modul (3-stufig nach BGB §288)
|
||||
* \file htdocs/custom/mahnung/core/modules/modMahnung.class.php
|
||||
* \ingroup mahnung
|
||||
*/
|
||||
|
||||
include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
|
||||
|
||||
/**
|
||||
* Beschreibungs- und Aktivierungsklasse fuer Modul Mahnung
|
||||
*/
|
||||
class modMahnung extends DolibarrModules
|
||||
{
|
||||
/**
|
||||
* @param DoliDB $db Datenbank-Handler
|
||||
*/
|
||||
public function __construct($db)
|
||||
{
|
||||
global $conf, $langs;
|
||||
|
||||
$this->db = $db;
|
||||
|
||||
// Eindeutige Modul-ID. 500034..500036 sind durch das Bericht-Modul belegt
|
||||
// (numero=500033, dessen rights id-Range 500033..500036 abdeckt), daher 500037.
|
||||
$this->numero = 500037;
|
||||
|
||||
// Schluessel fuer Rechte und Menues
|
||||
$this->rights_class = 'mahnung';
|
||||
|
||||
$this->family = 'financial';
|
||||
$this->module_position = '50';
|
||||
|
||||
$this->name = preg_replace('/^mod/i', '', get_class($this));
|
||||
|
||||
$this->description = 'MahnungDescription';
|
||||
$this->descriptionlong = 'MahnungDescription';
|
||||
|
||||
$this->editor_name = 'Alles Watt laeuft';
|
||||
$this->editor_url = '';
|
||||
|
||||
$this->version = '0.1.0';
|
||||
|
||||
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
||||
|
||||
// FontAwesome 5 Free (Dolibarr-Bundle, KB #435). 'fa-envelope-open-o' ist FA4-Notation
|
||||
// und rendert in Dolibarr lautlos kein Glyph; 'fa-envelope-open-text' ist FA5-Free.
|
||||
$this->picto = 'fa-envelope-open-text';
|
||||
|
||||
$this->module_parts = array(
|
||||
'triggers' => 1,
|
||||
'login' => 0,
|
||||
'substitutions' => 0,
|
||||
'menus' => 0,
|
||||
'tpl' => 0,
|
||||
'barcode' => 0,
|
||||
'models' => 0,
|
||||
'printing' => 0,
|
||||
'theme' => 0,
|
||||
'css' => array(),
|
||||
'js' => array(),
|
||||
// Hook-Klasse: class/actions_mahnung.class.php (Standard-Lookup-Pfad)
|
||||
'hooks' => array(
|
||||
'data' => array(
|
||||
'invoicecard',
|
||||
'thirdpartycard',
|
||||
),
|
||||
'entity' => '0',
|
||||
),
|
||||
'moduleforexternal' => 0,
|
||||
'websitetemplates' => 0,
|
||||
'captcha' => 0,
|
||||
);
|
||||
|
||||
// Datenverzeichnisse bei Modul-Aktivierung
|
||||
$this->dirs = array('/mahnung/temp');
|
||||
|
||||
// Konfigurationsseite
|
||||
$this->config_page_url = array('setup.php@mahnung');
|
||||
|
||||
$this->hidden = getDolGlobalInt('MODULE_MAHNUNG_DISABLED');
|
||||
$this->depends = array();
|
||||
$this->requiredby = array();
|
||||
$this->conflictwith = array();
|
||||
|
||||
$this->langfiles = array('mahnung@mahnung');
|
||||
|
||||
$this->phpmin = array(7, 4);
|
||||
$this->need_dolibarr_version = array(19, -3);
|
||||
$this->need_javascript_ajax = 1;
|
||||
|
||||
$this->warnings_activation = array();
|
||||
$this->warnings_activation_ext = array();
|
||||
|
||||
// Modul-Konstanten
|
||||
$this->const = array(
|
||||
0 => array(
|
||||
'MAHNUNG_BASISZINS',
|
||||
'chaine',
|
||||
'1.27',
|
||||
'BGB-Basiszins in Prozent (manuell halbjaehrlich pflegen)',
|
||||
0,
|
||||
'allentities',
|
||||
1,
|
||||
),
|
||||
1 => array(
|
||||
'MAHNUNG_NTFY_TOPIC',
|
||||
'chaine',
|
||||
'vk-builds',
|
||||
'Ntfy-Topic fuer Mahnungs-Benachrichtigungen',
|
||||
0,
|
||||
'current',
|
||||
1,
|
||||
),
|
||||
2 => array(
|
||||
'MAHNUNG_AUFSCHLAG_B2C',
|
||||
'chaine',
|
||||
'5.0',
|
||||
'Verzugszins-Aufschlag B2C in Prozent (BGB §288 Abs. 1)',
|
||||
0,
|
||||
'allentities',
|
||||
1,
|
||||
),
|
||||
3 => array(
|
||||
'MAHNUNG_AUFSCHLAG_B2B',
|
||||
'chaine',
|
||||
'9.0',
|
||||
'Verzugszins-Aufschlag B2B in Prozent (BGB §288 Abs. 2)',
|
||||
0,
|
||||
'allentities',
|
||||
1,
|
||||
),
|
||||
4 => array(
|
||||
'MAHNUNG_PAUSCHALE_B2B',
|
||||
'chaine',
|
||||
'40.00',
|
||||
'Pauschale B2B nach BGB §288 Abs. 5 in EUR',
|
||||
0,
|
||||
'allentities',
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
if (!isModEnabled('mahnung')) {
|
||||
$conf->mahnung = new stdClass();
|
||||
$conf->mahnung->enabled = 0;
|
||||
}
|
||||
|
||||
// Tabs auf bestehenden Karten (Phase 5: aktivieren)
|
||||
$this->tabs = array();
|
||||
|
||||
$this->dictionaries = array();
|
||||
|
||||
$this->boxes = array();
|
||||
|
||||
// Cron-Job: Vorschlagsliste taeglich 06:00
|
||||
$this->cronjobs = array(
|
||||
0 => array(
|
||||
'label' => 'MahnungCronBuildVorschlag',
|
||||
'jobtype' => 'method',
|
||||
'class' => '/mahnung/class/mahnungcron.class.php',
|
||||
'objectname' => 'MahnungCron',
|
||||
'method' => 'buildVorschlagsliste',
|
||||
'parameters' => '',
|
||||
'comment' => 'Sucht ueberfaellige Rechnungen, ermittelt vorgeschlagene Mahnstufen, sendet Ntfy-Push',
|
||||
'frequency' => 1,
|
||||
'unitfrequency' => 86400,
|
||||
'status' => 0,
|
||||
'test' => 'isModEnabled("mahnung")',
|
||||
'priority' => 50,
|
||||
),
|
||||
);
|
||||
|
||||
// Berechtigungen
|
||||
$this->rights = array();
|
||||
$r = 0;
|
||||
|
||||
$this->rights[$r][0] = $this->numero.'01';
|
||||
$this->rights[$r][1] = 'PermMahnungRead';
|
||||
$this->rights[$r][2] = 'r';
|
||||
$this->rights[$r][3] = 1;
|
||||
$this->rights[$r][4] = 'read';
|
||||
$r++;
|
||||
|
||||
$this->rights[$r][0] = $this->numero.'02';
|
||||
$this->rights[$r][1] = 'PermMahnungWrite';
|
||||
$this->rights[$r][2] = 'w';
|
||||
$this->rights[$r][3] = 0;
|
||||
$this->rights[$r][4] = 'write';
|
||||
$r++;
|
||||
|
||||
$this->rights[$r][0] = $this->numero.'03';
|
||||
$this->rights[$r][1] = 'PermMahnungSend';
|
||||
$this->rights[$r][2] = 'w';
|
||||
$this->rights[$r][3] = 0;
|
||||
$this->rights[$r][4] = 'send';
|
||||
$r++;
|
||||
|
||||
$this->rights[$r][0] = $this->numero.'04';
|
||||
$this->rights[$r][1] = 'PermMahnungDelete';
|
||||
$this->rights[$r][2] = 'd';
|
||||
$this->rights[$r][3] = 0;
|
||||
$this->rights[$r][4] = 'delete';
|
||||
$r++;
|
||||
|
||||
$this->rights[$r][0] = $this->numero.'05';
|
||||
$this->rights[$r][1] = 'PermMahnungSetup';
|
||||
$this->rights[$r][2] = 'w';
|
||||
$this->rights[$r][3] = 0;
|
||||
$this->rights[$r][4] = 'setup';
|
||||
$r++;
|
||||
|
||||
// Linkes Menue unter "Rechnungen" (mainmenu=billing)
|
||||
$this->menu = array();
|
||||
$r = 0;
|
||||
$this->menu[$r++] = array(
|
||||
'fk_menu' => 'fk_mainmenu=billing',
|
||||
'type' => 'left',
|
||||
'titre' => 'MahnungMenu',
|
||||
'prefix' => img_picto('', 'fa-envelope-open-text', 'class="pictofixedwidth valignmiddle paddingright"'),
|
||||
'mainmenu' => 'billing',
|
||||
'leftmenu' => 'mahnung',
|
||||
'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung',
|
||||
'langs' => 'mahnung@mahnung',
|
||||
'position' => 300,
|
||||
'enabled' => 'isModEnabled("mahnung")',
|
||||
'perms' => '$user->hasRight("mahnung", "read")',
|
||||
'target' => '',
|
||||
'user' => 2,
|
||||
);
|
||||
$this->menu[$r++] = array(
|
||||
'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=mahnung',
|
||||
'type' => 'left',
|
||||
'titre' => 'MahnungVorschlagsliste',
|
||||
'mainmenu' => 'billing',
|
||||
'leftmenu' => 'mahnung_vorschlag',
|
||||
'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag',
|
||||
'langs' => 'mahnung@mahnung',
|
||||
'position' => 301,
|
||||
'enabled' => 'isModEnabled("mahnung")',
|
||||
'perms' => '$user->hasRight("mahnung", "read")',
|
||||
'target' => '',
|
||||
'user' => 2,
|
||||
);
|
||||
$this->menu[$r++] = array(
|
||||
'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=mahnung',
|
||||
'type' => 'left',
|
||||
'titre' => 'MahnungArchiv',
|
||||
'mainmenu' => 'billing',
|
||||
'leftmenu' => 'mahnung_archiv',
|
||||
'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv',
|
||||
'langs' => 'mahnung@mahnung',
|
||||
'position' => 302,
|
||||
'enabled' => 'isModEnabled("mahnung")',
|
||||
'perms' => '$user->hasRight("mahnung", "read")',
|
||||
'target' => '',
|
||||
'user' => 2,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufruf bei Modul-Aktivierung: Tabellen anlegen, Konstanten/Rechte/Menues schreiben.
|
||||
*
|
||||
* @param string $options Optionen ('', 'noboxes')
|
||||
* @return int<-1,1> 1 = OK, <=0 = Fehler
|
||||
*/
|
||||
public function init($options = '')
|
||||
{
|
||||
// Tabellen anlegen aus sql/-Verzeichnis
|
||||
$result = $this->_load_tables('/mahnung/sql/');
|
||||
if ($result < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$sql = array();
|
||||
return $this->_init($sql, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufruf bei Modul-Deaktivierung. Tabellen bleiben erhalten (Datensicherheit).
|
||||
*
|
||||
* @param string $options Optionen
|
||||
* @return int<-1,1> 1 = OK, <=0 = Fehler
|
||||
*/
|
||||
public function remove($options = '')
|
||||
{
|
||||
$sql = array();
|
||||
return $this->_remove($sql, $options);
|
||||
}
|
||||
}
|
||||
151
core/triggers/interface_99_modMahnung_MahnungTriggers.class.php
Normal file
151
core/triggers/interface_99_modMahnung_MahnungTriggers.class.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file core/triggers/interface_99_modMahnung_MahnungTriggers.class.php
|
||||
* \ingroup mahnung
|
||||
* \brief Trigger: setzt offene Mahnvorgaenge nach Zahlungseingang auf "erledigt".
|
||||
*
|
||||
* Klassenname: Interface<Suffix>Triggers — Suffix muss zu Dateinamen passen.
|
||||
* Datei muss in core/triggers/ liegen, wird durch DolibarrModules->loadtriggers
|
||||
* (module_parts['triggers']=1) beim Modul-Aktivieren registriert.
|
||||
*/
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/triggers/dolibarrtriggers.class.php';
|
||||
|
||||
class InterfaceMahnungTriggers extends DolibarrTriggers
|
||||
{
|
||||
/**
|
||||
* @param DoliDB $db Datenbank-Handler
|
||||
*/
|
||||
public function __construct($db)
|
||||
{
|
||||
parent::__construct($db);
|
||||
$this->family = 'financial';
|
||||
$this->description = 'Mahnung-Trigger: erledigt offene Mahnvorgaenge bei Zahlungseingang.';
|
||||
$this->version = self::VERSIONS['dev'];
|
||||
$this->picto = 'fa-envelope-open-text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird bei jedem Dolibarr-Event aufgerufen.
|
||||
*
|
||||
* @param string $action z.B. PAYMENT_CUSTOMER_CREATE, BILL_PAYED
|
||||
* @param CommonObject $object Event-Subjekt
|
||||
* @param User $user aktueller User
|
||||
* @param Translate $langs Sprache
|
||||
* @param Conf $conf Konfig
|
||||
* @return int <0 Fehler, 0 nichts getan, >0 OK
|
||||
*/
|
||||
public function runTrigger($action, $object, User $user, Translate $langs, Conf $conf)
|
||||
{
|
||||
if (!isModEnabled('mahnung')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'BILL_PAYED':
|
||||
case 'BILL_VALIDATE':
|
||||
// $object ist Facture
|
||||
if (!empty($object) && !empty($object->id) && !empty($object->paye)) {
|
||||
return $this->onRechnungBezahlt((int) $object->id, $user);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case 'PAYMENT_CUSTOMER_CREATE':
|
||||
// $object ist Paiement; eventuell auch ohne paye=1 Status — pro betroffener Rechnung pruefen
|
||||
return $this->onPaiementErzeugt($object, $user);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt alle offenen Mahnvorgaenge zur Rechnung auf STATUS_ERLEDIGT.
|
||||
*
|
||||
* @param int $factureId
|
||||
* @param User $user
|
||||
* @return int >=0 Anzahl aktualisierter Mahnungen, <0 Fehler
|
||||
*/
|
||||
private function onRechnungBezahlt($factureId, User $user)
|
||||
{
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
|
||||
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
||||
$sql .= " WHERE fk_facture = ".((int) $factureId);
|
||||
$sql .= " AND status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")";
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
while ($obj = $this->db->fetch_object($resql)) {
|
||||
$m = new Mahnung($this->db);
|
||||
if ($m->fetch((int) $obj->rowid) > 0 && $m->setErledigt($user) > 0) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
$this->db->free($resql);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bei Zahlungs-Erzeugung: alle betroffenen Rechnungen pruefen, ob sie nun
|
||||
* vollstaendig bezahlt sind. paye-Flag wird in Dolibarr typischerweise erst
|
||||
* gesetzt, wenn Summe der Zahlungen >= total_ttc — wir pruefen pessimistisch:
|
||||
* sobald ueberhaupt eine Zahlung kommt + offene Mahnung existiert -> erledigen,
|
||||
* wenn die Zahlung den offenen Betrag deckt.
|
||||
*
|
||||
* @param CommonObject $payment
|
||||
* @param User $user
|
||||
* @return int
|
||||
*/
|
||||
private function onPaiementErzeugt($payment, User $user)
|
||||
{
|
||||
if (empty($payment) || empty($payment->amounts) || !is_array($payment->amounts)) {
|
||||
return 0;
|
||||
}
|
||||
$total = 0;
|
||||
foreach ($payment->amounts as $factureId => $amount) {
|
||||
$factureId = (int) $factureId;
|
||||
if ($factureId <= 0) {
|
||||
continue;
|
||||
}
|
||||
// Pruefen, ob die Rechnung jetzt voll bezahlt ist
|
||||
if ($this->istRechnungVollBezahlt($factureId)) {
|
||||
$total += $this->onRechnungBezahlt($factureId, $user);
|
||||
}
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $factureId
|
||||
* @return bool
|
||||
*/
|
||||
private function istRechnungVollBezahlt($factureId)
|
||||
{
|
||||
$sql = "SELECT f.total_ttc, COALESCE(SUM(pf.amount), 0) AS gezahlt";
|
||||
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
|
||||
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."paiement_facture as pf ON pf.fk_facture = f.rowid";
|
||||
$sql .= " WHERE f.rowid = ".((int) $factureId);
|
||||
$sql .= " GROUP BY f.rowid, f.total_ttc";
|
||||
|
||||
$resql = $this->db->query($sql);
|
||||
if (!$resql || !$this->db->num_rows($resql)) {
|
||||
return false;
|
||||
}
|
||||
$obj = $this->db->fetch_object($resql);
|
||||
$this->db->free($resql);
|
||||
return (float) $obj->gezahlt + 0.005 >= (float) $obj->total_ttc;
|
||||
}
|
||||
}
|
||||
108
langs/de_DE/mahnung.lang
Normal file
108
langs/de_DE/mahnung.lang
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Mahnung - Deutsch (de_DE)
|
||||
|
||||
#
|
||||
# Modul-Metadaten
|
||||
#
|
||||
ModuleMahnungName = Mahnung
|
||||
ModuleMahnungDesc = Mahnwesen mit Vorschlagsliste, Stufen, Verzugszinsen (BGB §288)
|
||||
MahnungDescription = 3-stufiges Mahnwesen fuer ueberfaellige Kundenrechnungen mit Mahngebuehren, Verzugszinsen nach BGB §288 und PDF-Versand.
|
||||
|
||||
#
|
||||
# Berechtigungen
|
||||
#
|
||||
PermMahnungRead = Mahnungen lesen
|
||||
PermMahnungWrite = Mahnungen erstellen / bearbeiten
|
||||
PermMahnungSend = Mahnungen versenden (E-Mail / Druck)
|
||||
PermMahnungDelete = Mahnungen loeschen
|
||||
PermMahnungSetup = Mahnwesen konfigurieren
|
||||
|
||||
#
|
||||
# Menues
|
||||
#
|
||||
MahnungMenu = Mahnwesen
|
||||
MahnungVorschlagsliste = Vorschlagsliste
|
||||
MahnungArchiv = Mahnvorgaenge
|
||||
|
||||
#
|
||||
# Stufen
|
||||
#
|
||||
MahnungStufe = Mahnstufe
|
||||
MahnungStufe1 = Zahlungserinnerung
|
||||
MahnungStufe2 = 1. Mahnung
|
||||
MahnungStufe3 = Letzte Mahnung
|
||||
MahnungStufeLabel = Bezeichnung
|
||||
MahnungStufeFristTage = Frist (Tage nach Faelligkeit)
|
||||
MahnungStufeNeueFristTage = Neue Zahlungsfrist (Tage)
|
||||
MahnungStufeGebuehrB2C = Mahngebuehr B2C
|
||||
MahnungStufeGebuehrB2B = Mahngebuehr B2B
|
||||
MahnungStufeZinssatzB2C = Zinssatz B2C (Override)
|
||||
MahnungStufeZinssatzB2B = Zinssatz B2B (Override)
|
||||
MahnungStufeVersandartDefault = Versandart-Default
|
||||
MahnungStufeEmailSubject = E-Mail-Betreff
|
||||
MahnungStufeEmailBody = E-Mail-Text
|
||||
MahnungStufePdfIntro = PDF-Einleitungstext
|
||||
|
||||
#
|
||||
# Status
|
||||
#
|
||||
MahnungStatusEntwurf = Entwurf
|
||||
MahnungStatusErstellt = Erstellt
|
||||
MahnungStatusVersendet = Versendet
|
||||
MahnungStatusErledigt = Erledigt
|
||||
MahnungStatusStorniert = Storniert
|
||||
|
||||
#
|
||||
# Versandart
|
||||
#
|
||||
MahnungVersandPdf = PDF an Rechnung
|
||||
MahnungVersandMail = E-Mail
|
||||
MahnungVersandDruck = Sammelbrief-Druck
|
||||
MahnungVersandNone = Kein Versand
|
||||
|
||||
#
|
||||
# Liste / Karte
|
||||
#
|
||||
MahnungRef = Mahnung-Nr.
|
||||
MahnungRechnung = Rechnung
|
||||
MahnungKunde = Kunde
|
||||
MahnungKundentyp = Typ
|
||||
MahnungKundentypB2C = Privat (B2C)
|
||||
MahnungKundentypB2B = Geschaeftlich (B2B)
|
||||
MahnungDatum = Mahndatum
|
||||
MahnungFaelligkeitAlt = Original-Faelligkeit
|
||||
MahnungFaelligkeitNeu = Neue Frist
|
||||
MahnungTageVerzug = Tage Verzug
|
||||
MahnungBetragOffen = Offener Betrag
|
||||
MahnungGebuehr = Mahngebuehr
|
||||
MahnungPauschaleB2B = Pauschale (40 € §288)
|
||||
MahnungVerzugszinsen = Verzugszinsen
|
||||
MahnungSumme = Gesamtsumme
|
||||
MahnungBasiszinsSnapshot = Basiszins (Snapshot)
|
||||
MahnungLetzteMahnung = Letzte Mahnung
|
||||
MahnungVorgeschlageneStufe = Vorgeschlagene Stufe
|
||||
MahnungAktion = Aktion
|
||||
MahnungErstellen = Mahnung erstellen
|
||||
MahnungSammelbrief = Sammelbrief erzeugen
|
||||
MahnungStornieren = Stornieren
|
||||
MahnungKeineUeberfaelligen = Keine ueberfaelligen Rechnungen vorhanden.
|
||||
|
||||
#
|
||||
# Setup-Seite
|
||||
#
|
||||
MahnungSetup = Mahnwesen Einstellungen
|
||||
MahnungSetupPage = Mahnwesen Konfiguration
|
||||
MahnungSetupDescription = Mahnstufen, Basiszins, Versandwege und Ntfy-Topic konfigurieren.
|
||||
MahnungBasiszins = BGB-Basiszins (%)
|
||||
MahnungBasiszinsHelp = Aktueller Basiszins der Bundesbank, halbjaehrlich pflegen (1.1. / 1.7.).
|
||||
MahnungAufschlagB2C = Aufschlag B2C (%)
|
||||
MahnungAufschlagB2B = Aufschlag B2B (%)
|
||||
MahnungPauschaleB2BLabel = Pauschale B2B (EUR)
|
||||
MahnungNtfyTopic = Ntfy-Topic
|
||||
MahnungNtfyTopicHelp = Topic fuer Push-Benachrichtigungen (Default: vk-builds).
|
||||
MahnungSettingsSaved = Einstellungen gespeichert.
|
||||
|
||||
#
|
||||
# Cron
|
||||
#
|
||||
MahnungCronBuildVorschlag = Mahnwesen — Vorschlagsliste aufbauen
|
||||
MahnungCronBuildVorschlagDesc = Sucht taeglich ueberfaellige Rechnungen und sendet einen Ntfy-Push mit der Anzahl neuer Vorschlaege.
|
||||
108
langs/en_US/mahnung.lang
Normal file
108
langs/en_US/mahnung.lang
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Mahnung - English (en_US)
|
||||
|
||||
#
|
||||
# Module metadata
|
||||
#
|
||||
ModuleMahnungName = Dunning
|
||||
ModuleMahnungDesc = Dunning workflow with proposal list, stages, late-payment interest (German BGB §288)
|
||||
MahnungDescription = 3-stage dunning workflow for overdue customer invoices with dunning fees, late-payment interest per German BGB §288, and PDF dispatch.
|
||||
|
||||
#
|
||||
# Permissions
|
||||
#
|
||||
PermMahnungRead = Read dunning records
|
||||
PermMahnungWrite = Create / edit dunning records
|
||||
PermMahnungSend = Dispatch dunning notices (e-mail / print)
|
||||
PermMahnungDelete = Delete dunning records
|
||||
PermMahnungSetup = Configure dunning module
|
||||
|
||||
#
|
||||
# Menus
|
||||
#
|
||||
MahnungMenu = Dunning
|
||||
MahnungVorschlagsliste = Proposal list
|
||||
MahnungArchiv = Dunning records
|
||||
|
||||
#
|
||||
# Stages
|
||||
#
|
||||
MahnungStufe = Stage
|
||||
MahnungStufe1 = Payment reminder
|
||||
MahnungStufe2 = 1st dunning notice
|
||||
MahnungStufe3 = Final dunning notice
|
||||
MahnungStufeLabel = Label
|
||||
MahnungStufeFristTage = Trigger (days after due date)
|
||||
MahnungStufeNeueFristTage = New payment deadline (days)
|
||||
MahnungStufeGebuehrB2C = Dunning fee B2C
|
||||
MahnungStufeGebuehrB2B = Dunning fee B2B
|
||||
MahnungStufeZinssatzB2C = Interest rate B2C (override)
|
||||
MahnungStufeZinssatzB2B = Interest rate B2B (override)
|
||||
MahnungStufeVersandartDefault = Default dispatch method
|
||||
MahnungStufeEmailSubject = E-mail subject
|
||||
MahnungStufeEmailBody = E-mail body
|
||||
MahnungStufePdfIntro = PDF introduction text
|
||||
|
||||
#
|
||||
# Status
|
||||
#
|
||||
MahnungStatusEntwurf = Draft
|
||||
MahnungStatusErstellt = Created
|
||||
MahnungStatusVersendet = Sent
|
||||
MahnungStatusErledigt = Closed
|
||||
MahnungStatusStorniert = Cancelled
|
||||
|
||||
#
|
||||
# Dispatch method
|
||||
#
|
||||
MahnungVersandPdf = PDF attached to invoice
|
||||
MahnungVersandMail = E-mail
|
||||
MahnungVersandDruck = Bulk print letter
|
||||
MahnungVersandNone = No dispatch
|
||||
|
||||
#
|
||||
# List / card
|
||||
#
|
||||
MahnungRef = Dunning ref.
|
||||
MahnungRechnung = Invoice
|
||||
MahnungKunde = Customer
|
||||
MahnungKundentyp = Type
|
||||
MahnungKundentypB2C = Private (B2C)
|
||||
MahnungKundentypB2B = Business (B2B)
|
||||
MahnungDatum = Dunning date
|
||||
MahnungFaelligkeitAlt = Original due date
|
||||
MahnungFaelligkeitNeu = New deadline
|
||||
MahnungTageVerzug = Days overdue
|
||||
MahnungBetragOffen = Open amount
|
||||
MahnungGebuehr = Dunning fee
|
||||
MahnungPauschaleB2B = Flat fee (40 € §288)
|
||||
MahnungVerzugszinsen = Late-payment interest
|
||||
MahnungSumme = Total
|
||||
MahnungBasiszinsSnapshot = Base rate (snapshot)
|
||||
MahnungLetzteMahnung = Last dunning
|
||||
MahnungVorgeschlageneStufe = Proposed stage
|
||||
MahnungAktion = Action
|
||||
MahnungErstellen = Create dunning
|
||||
MahnungSammelbrief = Generate bulk letter
|
||||
MahnungStornieren = Cancel
|
||||
MahnungKeineUeberfaelligen = No overdue invoices found.
|
||||
|
||||
#
|
||||
# Setup page
|
||||
#
|
||||
MahnungSetup = Dunning settings
|
||||
MahnungSetupPage = Dunning configuration
|
||||
MahnungSetupDescription = Configure dunning stages, base rate, dispatch methods, and Ntfy topic.
|
||||
MahnungBasiszins = BGB base rate (%)
|
||||
MahnungBasiszinsHelp = Current Bundesbank base rate; update twice a year (Jan 1 / Jul 1).
|
||||
MahnungAufschlagB2C = Surcharge B2C (%)
|
||||
MahnungAufschlagB2B = Surcharge B2B (%)
|
||||
MahnungPauschaleB2BLabel = Flat fee B2B (EUR)
|
||||
MahnungNtfyTopic = Ntfy topic
|
||||
MahnungNtfyTopicHelp = Topic for push notifications (default: vk-builds).
|
||||
MahnungSettingsSaved = Settings saved.
|
||||
|
||||
#
|
||||
# Cron
|
||||
#
|
||||
MahnungCronBuildVorschlag = Dunning — build proposal list
|
||||
MahnungCronBuildVorschlagDesc = Daily scan for overdue invoices, sends a Ntfy push with the count of new proposals.
|
||||
232
list.php
Normal file
232
list.php
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<?php
|
||||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file mahnung/list.php
|
||||
* \ingroup mahnung
|
||||
* \brief Vorschlagsliste (mode=vorschlag) und Mahnvorgaenge-Archiv (mode=archiv).
|
||||
*/
|
||||
|
||||
$res = 0;
|
||||
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
|
||||
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
|
||||
}
|
||||
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
|
||||
$tmp2 = realpath(__FILE__);
|
||||
$i = strlen($tmp) - 1;
|
||||
$j = strlen($tmp2) - 1;
|
||||
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
|
||||
$i--;
|
||||
$j--;
|
||||
}
|
||||
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
|
||||
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
|
||||
}
|
||||
if (!$res && file_exists("../main.inc.php")) {
|
||||
$res = @include "../main.inc.php";
|
||||
}
|
||||
if (!$res && file_exists("../../main.inc.php")) {
|
||||
$res = @include "../../main.inc.php";
|
||||
}
|
||||
if (!$res) {
|
||||
die("Include of main fails");
|
||||
}
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
|
||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
|
||||
|
||||
global $langs, $user, $db;
|
||||
$langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills'));
|
||||
|
||||
if (!$user->hasRight('mahnung', 'read')) {
|
||||
accessforbidden();
|
||||
}
|
||||
|
||||
$mode = GETPOST('mode', 'aZ09');
|
||||
if ($mode !== 'archiv') {
|
||||
$mode = 'vorschlag';
|
||||
}
|
||||
|
||||
$filter = array();
|
||||
$filter_stufe = GETPOST('filter_stufe', 'int');
|
||||
if ($filter_stufe !== '' && $filter_stufe !== null) {
|
||||
$filter['stufe'] = (int) $filter_stufe;
|
||||
}
|
||||
$filter_minverzug = GETPOST('filter_minverzug', 'int');
|
||||
if ($filter_minverzug !== '' && $filter_minverzug !== null) {
|
||||
$filter['min_tage_verzug'] = (int) $filter_minverzug;
|
||||
}
|
||||
$filter_socid = GETPOST('search_socid', 'int');
|
||||
if (!empty($filter_socid)) {
|
||||
$filter['soc_id'] = (int) $filter_socid;
|
||||
}
|
||||
|
||||
llxHeader('', $langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'));
|
||||
|
||||
print load_fiche_titre(
|
||||
$langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'),
|
||||
'',
|
||||
'fa-envelope-open-text'
|
||||
);
|
||||
|
||||
// --- Filter-Form ---
|
||||
print '<form method="GET" action="'.$_SERVER['PHP_SELF'].'">';
|
||||
print '<input type="hidden" name="mode" value="'.dol_escape_htmltag($mode).'">';
|
||||
print '<table class="noborder centpercent"><tr class="liste_titre">';
|
||||
print '<th>'.$langs->trans('MahnungStufe').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungTageVerzug').' (min)</th>';
|
||||
print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
||||
print '<th></th></tr>';
|
||||
print '<tr><td><select name="filter_stufe">';
|
||||
print '<option value="">— '.$langs->trans('All').' —</option>';
|
||||
foreach (array(1, 2, 3) as $st) {
|
||||
print '<option value="'.$st.'"'.((string) $filter_stufe === (string) $st ? ' selected' : '').'>'.$st.'</option>';
|
||||
}
|
||||
print '</select></td>';
|
||||
print '<td><input type="number" name="filter_minverzug" value="'.dol_escape_htmltag((string) $filter_minverzug).'" size="4"></td>';
|
||||
print '<td><input type="number" name="search_socid" value="'.dol_escape_htmltag((string) $filter_socid).'" size="6" placeholder="rowid">';
|
||||
print '</td>';
|
||||
print '<td><input type="submit" class="button" value="'.$langs->trans('Search').'"></td>';
|
||||
print '</tr></table>';
|
||||
print '</form><br>';
|
||||
|
||||
if ($mode === 'vorschlag') {
|
||||
renderVorschlagsliste($db, $filter);
|
||||
} else {
|
||||
renderArchiv($db, $filter);
|
||||
}
|
||||
|
||||
llxFooter();
|
||||
$db->close();
|
||||
|
||||
/**
|
||||
* Rendert die Vorschlagsliste auf Basis von MahnungVorschlag.
|
||||
*
|
||||
* @param DoliDB $db
|
||||
* @param array $filter
|
||||
* @return void
|
||||
*/
|
||||
function renderVorschlagsliste($db, $filter)
|
||||
{
|
||||
global $langs, $user;
|
||||
|
||||
$service = new MahnungVorschlag($db);
|
||||
$rows = $service->getVorschlaege($filter);
|
||||
|
||||
if (empty($rows)) {
|
||||
print '<div class="info">'.$langs->trans('MahnungKeineUeberfaelligen').'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$canWrite = $user->hasRight('mahnung', 'write');
|
||||
|
||||
print '<form method="POST" action="'.DOL_URL_ROOT.'/custom/mahnung/ajax/createmahnung.php" id="formMahnung">';
|
||||
print '<input type="hidden" name="token" value="'.newToken().'">';
|
||||
print '<table class="noborder centpercent">';
|
||||
print '<tr class="liste_titre">';
|
||||
if ($canWrite) {
|
||||
print '<th><input type="checkbox" id="chkAll"></th>';
|
||||
}
|
||||
print '<th>'.$langs->trans('MahnungRechnung').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungKundentyp').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungFaelligkeitAlt').'</th>';
|
||||
print '<th class="right">'.$langs->trans('MahnungTageVerzug').'</th>';
|
||||
print '<th class="right">'.$langs->trans('MahnungBetragOffen').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungLetzteMahnung').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungVorgeschlageneStufe').'</th>';
|
||||
print '</tr>';
|
||||
|
||||
$summeOffen = 0.0;
|
||||
foreach ($rows as $r) {
|
||||
print '<tr class="oddeven">';
|
||||
if ($canWrite) {
|
||||
print '<td><input type="checkbox" name="facture_ids[]" value="'.((int) $r['facture_id']).'" data-stufe="'.((int) $r['vorgeschlagene_stufe']).'"></td>';
|
||||
}
|
||||
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $r['facture_id']).'">'.dol_escape_htmltag($r['facture_ref']).'</a></td>';
|
||||
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $r['soc_id']).'">'.dol_escape_htmltag($r['soc_nom']).'</a></td>';
|
||||
print '<td>'.dol_escape_htmltag($r['kundentyp']).'</td>';
|
||||
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
|
||||
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
|
||||
print '<td class="right">'.price($r['betrag_offen']).'</td>';
|
||||
print '<td>'.($r['letzte_mahnung_stufe'] ? 'Stufe '.((int) $r['letzte_mahnung_stufe']).' am '.dol_print_date($r['letzte_mahnung_datum'], 'day') : '—').'</td>';
|
||||
print '<td><strong>'.((int) $r['vorgeschlagene_stufe']).'</strong> — '.dol_escape_htmltag($r['vorgeschlagene_stufe_label']).'</td>';
|
||||
print '</tr>';
|
||||
$summeOffen += (float) $r['betrag_offen'];
|
||||
}
|
||||
print '<tr class="liste_total"><td colspan="'.($canWrite ? 6 : 5).'" class="right">'.$langs->trans('Total').'</td>';
|
||||
print '<td class="right">'.price($summeOffen).'</td><td colspan="2"></td></tr>';
|
||||
print '</table>';
|
||||
|
||||
if ($canWrite) {
|
||||
print '<br><div class="center">';
|
||||
print '<button type="submit" class="button" name="action" value="bulk_create">'.$langs->trans('MahnungErstellen').'</button> ';
|
||||
print '<button type="submit" class="button" name="action" value="bulk_sammelbrief" formaction="'.DOL_URL_ROOT.'/custom/mahnung/ajax/sammelbrief.php">'.$langs->trans('MahnungSammelbrief').'</button>';
|
||||
print '</div>';
|
||||
print '<script>document.getElementById("chkAll")?.addEventListener("change", function(e){';
|
||||
print 'document.querySelectorAll("input[name=\'facture_ids[]\']").forEach(c => c.checked = e.target.checked);';
|
||||
print '});</script>';
|
||||
}
|
||||
print '</form>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert das Archiv aller bestehenden Mahnvorgaenge.
|
||||
*
|
||||
* @param DoliDB $db
|
||||
* @param array $filter
|
||||
* @return void
|
||||
*/
|
||||
function renderArchiv($db, $filter)
|
||||
{
|
||||
global $langs;
|
||||
|
||||
$mahnungObj = new Mahnung($db);
|
||||
$archivFilter = array();
|
||||
if (isset($filter['stufe'])) {
|
||||
$archivFilter['stufe'] = $filter['stufe'];
|
||||
}
|
||||
$mahnungen = $mahnungObj->fetchAll('date_mahnung', 'DESC', 200, 0, $archivFilter);
|
||||
|
||||
if (empty($mahnungen)) {
|
||||
print '<div class="info">Keine Mahnvorgaenge.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
print '<table class="noborder centpercent">';
|
||||
print '<tr class="liste_titre">';
|
||||
print '<th>'.$langs->trans('MahnungRef').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungRechnung').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungStufe').'</th>';
|
||||
print '<th>'.$langs->trans('MahnungDatum').'</th>';
|
||||
print '<th class="right">'.$langs->trans('MahnungBetragOffen').'</th>';
|
||||
print '<th class="right">'.$langs->trans('MahnungGebuehr').'</th>';
|
||||
print '<th class="right">'.$langs->trans('MahnungVerzugszinsen').'</th>';
|
||||
print '<th class="right">'.$langs->trans('MahnungSumme').'</th>';
|
||||
print '<th>Status</th>';
|
||||
print '</tr>';
|
||||
|
||||
foreach ($mahnungen as $m) {
|
||||
print '<tr class="oddeven">';
|
||||
print '<td>'.dol_escape_htmltag($m->ref).'</td>';
|
||||
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $m->fk_facture).'">#'.((int) $m->fk_facture).'</a></td>';
|
||||
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $m->fk_soc).'">#'.((int) $m->fk_soc).'</a></td>';
|
||||
print '<td>'.((int) $m->stufe).'</td>';
|
||||
print '<td>'.dol_print_date($m->date_mahnung, 'day').'</td>';
|
||||
print '<td class="right">'.price($m->betrag_offen).'</td>';
|
||||
print '<td class="right">'.price((float) $m->mahngebuehr + (float) $m->pauschale_b2b).'</td>';
|
||||
print '<td class="right">'.price($m->verzugszinsen).'</td>';
|
||||
print '<td class="right"><strong>'.price($m->summe_mahnung).'</strong></td>';
|
||||
print '<td>'.dol_escape_htmltag($m->getStatusLabel()).'</td>';
|
||||
print '</tr>';
|
||||
}
|
||||
print '</table>';
|
||||
}
|
||||
13
sql/llx_mahnung_mahnung.key.sql
Normal file
13
sql/llx_mahnung_mahnung.key.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
--
|
||||
-- This program is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU General Public License as published by
|
||||
-- the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
|
||||
ALTER TABLE llx_mahnung_mahnung ADD UNIQUE INDEX uk_mahnung_ref (entity, ref);
|
||||
ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_facture (fk_facture);
|
||||
ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_soc (fk_soc);
|
||||
ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_status (status);
|
||||
ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_stufe (stufe);
|
||||
ALTER TABLE llx_mahnung_mahnung ADD INDEX idx_mahnung_date (date_mahnung);
|
||||
35
sql/llx_mahnung_mahnung.sql
Normal file
35
sql/llx_mahnung_mahnung.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
--
|
||||
-- This program is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU General Public License as published by
|
||||
-- the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
|
||||
-- Mahnvorgaenge zu Kundenrechnungen
|
||||
|
||||
CREATE TABLE llx_mahnung_mahnung (
|
||||
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
|
||||
entity INTEGER DEFAULT 1 NOT NULL,
|
||||
ref VARCHAR(30) NOT NULL,
|
||||
fk_facture INTEGER NOT NULL,
|
||||
fk_soc INTEGER NOT NULL,
|
||||
stufe TINYINT NOT NULL,
|
||||
date_mahnung DATE NOT NULL,
|
||||
date_lim_reglement_alt DATE,
|
||||
date_lim_reglement_neu DATE,
|
||||
betrag_offen DOUBLE(24,8) DEFAULT 0,
|
||||
mahngebuehr DOUBLE(10,2) DEFAULT 0,
|
||||
pauschale_b2b DOUBLE(10,2) DEFAULT 0,
|
||||
verzugszinsen DOUBLE(10,2) DEFAULT 0,
|
||||
summe_mahnung DOUBLE(24,8) DEFAULT 0,
|
||||
versandart VARCHAR(20) DEFAULT 'pdf',
|
||||
customertype VARCHAR(3),
|
||||
basiszins_snapshot DECIMAL(5,4),
|
||||
pdf_path VARCHAR(255),
|
||||
note_private TEXT,
|
||||
status TINYINT DEFAULT 0 NOT NULL,
|
||||
datec DATETIME,
|
||||
tms TIMESTAMP,
|
||||
fk_user_creat INTEGER,
|
||||
fk_user_modif INTEGER
|
||||
) ENGINE=InnoDB;
|
||||
8
sql/llx_mahnung_stufe.key.sql
Normal file
8
sql/llx_mahnung_stufe.key.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
--
|
||||
-- This program is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU General Public License as published by
|
||||
-- the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
|
||||
ALTER TABLE llx_mahnung_stufe ADD UNIQUE INDEX uk_mahnung_stufe (entity, stufe);
|
||||
35
sql/llx_mahnung_stufe.sql
Normal file
35
sql/llx_mahnung_stufe.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||
--
|
||||
-- This program is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU General Public License as published by
|
||||
-- the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
|
||||
-- Mahnstufen-Konfiguration (3-stufig nach BGB §288)
|
||||
|
||||
CREATE TABLE llx_mahnung_stufe (
|
||||
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
|
||||
entity INTEGER DEFAULT 1 NOT NULL,
|
||||
stufe TINYINT NOT NULL,
|
||||
label VARCHAR(60) NOT NULL,
|
||||
frist_tage INTEGER DEFAULT 0 NOT NULL,
|
||||
neue_frist_tage INTEGER DEFAULT 7 NOT NULL,
|
||||
mahngebuehr_b2c DOUBLE(10,2) DEFAULT 0,
|
||||
mahngebuehr_b2b DOUBLE(10,2) DEFAULT 0,
|
||||
pauschale_b2b_einmalig TINYINT DEFAULT 0,
|
||||
zinssatz_b2c_uebersteuern DECIMAL(5,4),
|
||||
zinssatz_b2b_uebersteuern DECIMAL(5,4),
|
||||
versandart_default VARCHAR(20) DEFAULT 'pdf',
|
||||
email_subject VARCHAR(255),
|
||||
email_body TEXT,
|
||||
pdf_intro TEXT,
|
||||
active TINYINT DEFAULT 1 NOT NULL,
|
||||
datec DATETIME,
|
||||
tms TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Default-Stufen (idempotent: INSERT IGNORE wegen UNIQUE entity+stufe)
|
||||
INSERT IGNORE INTO llx_mahnung_stufe (entity, stufe, label, frist_tage, neue_frist_tage, mahngebuehr_b2c, mahngebuehr_b2b, pauschale_b2b_einmalig, versandart_default, datec) VALUES
|
||||
(1, 1, 'Zahlungserinnerung', 7, 14, 0.00, 0.00, 1, 'pdf', NOW()),
|
||||
(1, 2, '1. Mahnung', 14, 10, 5.00, 5.00, 0, 'pdf', NOW()),
|
||||
(1, 3, 'Letzte Mahnung', 10, 7, 10.00, 10.00, 0, 'pdf', NOW());
|
||||
Loading…
Reference in a new issue