feat: repeat claim items in document tables

This commit is contained in:
Marcel Peterkau
2026-06-21 22:33:24 +02:00
parent 6c7bf63280
commit c58072fe45
6 changed files with 157 additions and 17 deletions
+1
View File
@@ -27,6 +27,7 @@
"Positionen, Zahlungen und Mahnungen einer Forderung werden gemeinsam in einer farblich gruppierten Übersicht dargestellt.",
"OpenDocument-Templates mit Platzhaltern, lokaler PDF-Erzeugung, Audit-Verknüpfung und Dokumentöffnung aus der Mitgliederakte ergänzt.",
"Zentrale Vereins- und Absenderdaten sowie getrennte Mitgliedsbereiche für Anschrift, Telefon und validierte Bank-/SEPA-Daten ergänzt.",
"Wiederholbare OpenDocument-Tabellenzeilen für beliebig viele Forderungspositionen eingeführt.",
"Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.",
"Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.",
"Splash-Screen auf das eingebettete CCMA-Hintergrundmotiv umgestellt und redundante Titeltexte entfernt.",
+17 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:mimetype="application/vnd.oasis.opendocument.text" office:version="1.3">
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:mimetype="application/vnd.oasis.opendocument.text" office:version="1.3">
<office:styles>
<style:style style:name="Title" style:family="paragraph"><style:paragraph-properties fo:margin-bottom="0.4cm"/><style:text-properties fo:font-size="18pt" fo:font-weight="bold"/></style:style>
<style:style style:name="Heading" style:family="paragraph"><style:paragraph-properties fo:margin-top="0.35cm" fo:margin-bottom="0.15cm"/><style:text-properties fo:font-size="12pt" fo:font-weight="bold"/></style:style>
@@ -15,7 +15,22 @@
<text:p>Anschrift: {{member.address_line}}</text:p>
<text:p>Fällig am: {{claim.due_date}}</text:p>
<text:p text:style-name="Heading">Positionen</text:p>
<text:p>{{claim.items}}</text:p>
<table:table table:name="Forderungspositionen">
<table:table-row>
<table:table-cell><text:p>Beschreibung</text:p></table:table-cell>
<table:table-cell><text:p>Typ</text:p></table:table-cell>
<table:table-cell><text:p>Menge</text:p></table:table-cell>
<table:table-cell><text:p>Einzelpreis</text:p></table:table-cell>
<table:table-cell><text:p>Betrag</text:p></table:table-cell>
</table:table-row>
<table:table-row>
<table:table-cell><text:p>{{#claim.items}}{{item.description}}</text:p></table:table-cell>
<table:table-cell><text:p>{{item.type}}</text:p></table:table-cell>
<table:table-cell><text:p>{{item.quantity}}</text:p></table:table-cell>
<table:table-cell><text:p>{{item.unit_price}} EUR</text:p></table:table-cell>
<table:table-cell><text:p>{{item.amount}} EUR{{/claim.items}}</text:p></table:table-cell>
</table:table-row>
</table:table>
<text:p>Gesamtbetrag: {{claim.total}}</text:p>
<text:p>Bereits bezahlt: {{claim.paid}}</text:p>
<text:p text:style-name="Heading">Offener Betrag: {{claim.balance}}</text:p>
+75 -10
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import copy
import hashlib
import os
import re
@@ -27,6 +28,7 @@ from ccma.storage.repository import MemberRepository
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
REPEAT_PATTERN = re.compile(r"\{\{\s*([#/])\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
class DocumentError(RuntimeError):
@@ -119,7 +121,7 @@ class DocumentService:
organization = self.repository.get_configuration().get("organization") or {}
if not isinstance(organization, dict):
organization = {}
values = _template_values(
values, repeats = _template_values(
member,
data=data,
claim=claim,
@@ -133,7 +135,7 @@ class DocumentService:
with tempfile.TemporaryDirectory(prefix="ccma-document-") as temporary_name:
temporary = Path(temporary_name)
rendered = temporary / f"rendered{template_path.suffix.casefold()}"
_render_template(template_path, rendered, values)
_render_template(template_path, rendered, values, repeats)
converted = _convert_to_pdf(rendered, temporary)
temporary_destination = destination.with_name(f".{destination.name}.tmp")
try:
@@ -178,7 +180,7 @@ def _template_values(
claim: dict | None = None,
reminder: dict | None = None,
organization: dict | None = None,
) -> dict[str, str]:
) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
organization = organization or {}
organization_address = " ".join(
part
@@ -231,6 +233,7 @@ def _template_values(
"organization.bic": str(organization.get("bic", "")),
"organization.creditor_id": str(organization.get("creditor_id", "")),
}
repeats: dict[str, list[dict[str, str]]] = {}
if claim is not None and data is not None:
claim_id = str(claim.get("claim_id", ""))
item_lines = [
@@ -250,6 +253,25 @@ def _template_values(
"claim.items": "; ".join(item_lines),
}
)
item_type_labels = {
"base": "Grundposition",
"product": "Produkt",
"service": "Dienstleistung",
"fee": "Gebühr",
"discount": "Rabatt",
"credit": "Gutschrift",
"correction": "Korrektur",
}
repeats["claim.items"] = [
{
"item.type": item_type_labels.get(str(item.get("type", "")), str(item.get("type", ""))),
"item.description": str(item.get("description", "")),
"item.quantity": str(item.get("quantity", "1")),
"item.unit_price": str(item.get("unit_price", item.get("amount", ""))),
"item.amount": str(item.get("amount", "")),
}
for item in claim_items(claim)
]
if reminder is not None:
reminder_status_labels = {
"draft": "Entwurf",
@@ -277,23 +299,28 @@ def _template_values(
"reminder.channel": channel_labels.get(channel, channel),
}
)
return values
return values, repeats
def _render_template(source: Path, destination: Path, values: dict[str, str]) -> None:
def _render_template(
source: Path,
destination: Path,
values: dict[str, str],
repeats: dict[str, list[dict[str, str]]],
) -> None:
if source.suffix.casefold() == ".fodt":
try:
content = source.read_bytes()
except OSError as exc:
raise DocumentError(f"Template konnte nicht gelesen werden: {exc}") from exc
destination.write_bytes(_replace_xml_placeholders(content, values))
destination.write_bytes(_replace_xml_placeholders(content, values, repeats))
return
try:
with zipfile.ZipFile(source, "r") as archive, zipfile.ZipFile(destination, "w") as output:
for info in archive.infolist():
content = archive.read(info.filename)
if info.filename in {"content.xml", "styles.xml"}:
content = _replace_xml_placeholders(content, values)
content = _replace_xml_placeholders(content, values, repeats)
output.writestr(info, content)
except (OSError, zipfile.BadZipFile) as exc:
raise DocumentError(f"OpenDocument-Template ist beschädigt: {exc}") from exc
@@ -322,14 +349,20 @@ def _template_fields(source: Path) -> set[str]:
if _local_name(paragraph.tag) in {"p", "h"}:
combined = "".join(value for _node, _attribute, value in _text_slots(paragraph))
fields.update(match.group(1) for match in PLACEHOLDER_PATTERN.finditer(combined))
fields.update(match.group(2) for match in REPEAT_PATTERN.finditer(combined))
return fields
def _replace_xml_placeholders(content: bytes, values: dict[str, str]) -> bytes:
def _replace_xml_placeholders(
content: bytes,
values: dict[str, str],
repeats: dict[str, list[dict[str, str]]] | None = None,
) -> bytes:
try:
root = ElementTree.fromstring(content)
except ElementTree.ParseError as exc:
raise DocumentError(f"Template-XML ist beschädigt: {exc}") from exc
_expand_repeating_rows(root, repeats or {})
unknown: set[str] = set()
for paragraph in root.iter():
if _local_name(paragraph.tag) not in {"p", "h"}:
@@ -345,6 +378,38 @@ def _replace_xml_placeholders(content: bytes, values: dict[str, str]) -> bytes:
return ElementTree.tostring(root, encoding="utf-8", xml_declaration=True)
def _expand_repeating_rows(root, repeats: dict[str, list[dict[str, str]]]) -> None:
for parent in root.iter():
for index, row in reversed(list(enumerate(list(parent)))):
if _local_name(row.tag) != "table-row":
continue
slots = _text_slots(row)
combined = "".join(value for _node, _attribute, value in slots)
markers = list(REPEAT_PATTERN.finditer(combined))
starts = [match for match in markers if match.group(1) == "#"]
if not starts:
continue
if len(starts) != 1:
raise DocumentError("Eine Tabellenzeile darf genau eine Wiederholung öffnen.")
section = starts[0].group(2)
if not any(match.group(1) == "/" and match.group(2) == section for match in markers):
raise DocumentError(f"Wiederholung {section} wird in derselben Tabellenzeile nicht beendet.")
if section not in repeats:
raise DocumentError(f"Wiederholung ist im aktuellen Kontext nicht verfügbar: {section}")
parent.remove(row)
for offset, item_values in enumerate(repeats[section]):
clone = copy.deepcopy(row)
clone_slots = _text_slots(clone)
clone_text = "".join(value for _node, _attribute, value in clone_slots)
clone_markers = list(REPEAT_PATTERN.finditer(clone_text))
_replace_matches(clone_slots, clone_markers, {section: ""}, key_group=2)
clone_slots = _text_slots(clone)
clone_text = "".join(value for _node, _attribute, value in clone_slots)
item_matches = list(PLACEHOLDER_PATTERN.finditer(clone_text))
_replace_matches(clone_slots, item_matches, item_values)
parent.insert(index + offset, clone)
def _text_slots(element) -> list[tuple[object, str, str]]:
slots: list[tuple[object, str, str]] = []
@@ -360,14 +425,14 @@ def _text_slots(element) -> list[tuple[object, str, str]]:
return slots
def _replace_matches(slots, matches, values: dict[str, str]) -> None:
def _replace_matches(slots, matches, values: dict[str, str], *, key_group: int = 1) -> None:
boundaries: list[tuple[int, int]] = []
offset = 0
for _element, _attribute, text in slots:
boundaries.append((offset, offset + len(text)))
offset += len(text)
for match in reversed(matches):
key = match.group(1)
key = match.group(key_group)
if key not in values:
continue
start_index, start_offset = _slot_at(boundaries, match.start())
+6 -1
View File
@@ -106,7 +106,12 @@ class MemberRepository:
builtin_templates = Path(__file__).resolve().parent.parent / "assets" / "templates"
if builtin_templates.is_dir():
for source in builtin_templates.iterdir():
destination = templates_root / source.name
destination_name = (
"Forderung mit Positionen.fodt"
if source.name == "Forderung.fodt"
else source.name
)
destination = templates_root / destination_name
if source.is_file() and not destination.exists():
shutil.copyfile(source, destination)
config_path = self.root / "repository.json"