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
+15 -1
View File
@@ -49,7 +49,7 @@ member-store/
├── housekeeper.json ├── housekeeper.json
├── rules/ ├── rules/
├── templates/ ├── templates/
│ ├── Forderung.fodt │ ├── Forderung mit Positionen.fodt
│ ├── Mahnung.fodt │ ├── Mahnung.fodt
│ └── Mitglied.fodt │ └── Mitglied.fodt
└── members/ └── members/
@@ -97,6 +97,20 @@ available when a reminder row is selected before opening the document dialog.
Unknown or unavailable placeholders stop generation with a clear error rather Unknown or unavailable placeholders stop generation with a clear error rather
than producing an incomplete letter. than producing an incomplete letter.
To repeat a formatted table row for every claim item, place both loop markers
inside the same template row:
```text
{{#claim.items}}
{{item.description}} | {{item.type}} | {{item.quantity}} | {{item.unit_price}} | {{item.amount}}
{{/claim.items}}
```
The opening marker may share the first cell with its value and the closing
marker may share the last cell. CCMA removes both markers and clones the whole
row, including its formatting, once per item. With no items, the template row
is removed. A loop that is not closed in the same row is rejected.
## Housekeeper rules ## Housekeeper rules
The housekeeper runs every rule for every member. Built-in Python rules live in The housekeeper runs every rule for every member. Built-in Python rules live in
+1
View File
@@ -27,6 +27,7 @@
"Positionen, Zahlungen und Mahnungen einer Forderung werden gemeinsam in einer farblich gruppierten Übersicht dargestellt.", "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.", "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.", "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.", "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.", "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.", "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"?> <?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> <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="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> <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>Anschrift: {{member.address_line}}</text:p>
<text:p>Fällig am: {{claim.due_date}}</text:p> <text:p>Fällig am: {{claim.due_date}}</text:p>
<text:p text:style-name="Heading">Positionen</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>Gesamtbetrag: {{claim.total}}</text:p>
<text:p>Bereits bezahlt: {{claim.paid}}</text:p> <text:p>Bereits bezahlt: {{claim.paid}}</text:p>
<text:p text:style-name="Heading">Offener Betrag: {{claim.balance}}</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 from __future__ import annotations
import copy
import hashlib import hashlib
import os import os
import re import re
@@ -27,6 +28,7 @@ from ccma.storage.repository import MemberRepository
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"} SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE) 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): class DocumentError(RuntimeError):
@@ -119,7 +121,7 @@ class DocumentService:
organization = self.repository.get_configuration().get("organization") or {} organization = self.repository.get_configuration().get("organization") or {}
if not isinstance(organization, dict): if not isinstance(organization, dict):
organization = {} organization = {}
values = _template_values( values, repeats = _template_values(
member, member,
data=data, data=data,
claim=claim, claim=claim,
@@ -133,7 +135,7 @@ class DocumentService:
with tempfile.TemporaryDirectory(prefix="ccma-document-") as temporary_name: with tempfile.TemporaryDirectory(prefix="ccma-document-") as temporary_name:
temporary = Path(temporary_name) temporary = Path(temporary_name)
rendered = temporary / f"rendered{template_path.suffix.casefold()}" 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) converted = _convert_to_pdf(rendered, temporary)
temporary_destination = destination.with_name(f".{destination.name}.tmp") temporary_destination = destination.with_name(f".{destination.name}.tmp")
try: try:
@@ -178,7 +180,7 @@ def _template_values(
claim: dict | None = None, claim: dict | None = None,
reminder: dict | None = None, reminder: dict | None = None,
organization: dict | None = None, organization: dict | None = None,
) -> dict[str, str]: ) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
organization = organization or {} organization = organization or {}
organization_address = " ".join( organization_address = " ".join(
part part
@@ -231,6 +233,7 @@ def _template_values(
"organization.bic": str(organization.get("bic", "")), "organization.bic": str(organization.get("bic", "")),
"organization.creditor_id": str(organization.get("creditor_id", "")), "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: if claim is not None and data is not None:
claim_id = str(claim.get("claim_id", "")) claim_id = str(claim.get("claim_id", ""))
item_lines = [ item_lines = [
@@ -250,6 +253,25 @@ def _template_values(
"claim.items": "; ".join(item_lines), "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: if reminder is not None:
reminder_status_labels = { reminder_status_labels = {
"draft": "Entwurf", "draft": "Entwurf",
@@ -277,23 +299,28 @@ def _template_values(
"reminder.channel": channel_labels.get(channel, channel), "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": if source.suffix.casefold() == ".fodt":
try: try:
content = source.read_bytes() content = source.read_bytes()
except OSError as exc: except OSError as exc:
raise DocumentError(f"Template konnte nicht gelesen werden: {exc}") from 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 return
try: try:
with zipfile.ZipFile(source, "r") as archive, zipfile.ZipFile(destination, "w") as output: with zipfile.ZipFile(source, "r") as archive, zipfile.ZipFile(destination, "w") as output:
for info in archive.infolist(): for info in archive.infolist():
content = archive.read(info.filename) content = archive.read(info.filename)
if info.filename in {"content.xml", "styles.xml"}: 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) output.writestr(info, content)
except (OSError, zipfile.BadZipFile) as exc: except (OSError, zipfile.BadZipFile) as exc:
raise DocumentError(f"OpenDocument-Template ist beschädigt: {exc}") from 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"}: if _local_name(paragraph.tag) in {"p", "h"}:
combined = "".join(value for _node, _attribute, value in _text_slots(paragraph)) 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(1) for match in PLACEHOLDER_PATTERN.finditer(combined))
fields.update(match.group(2) for match in REPEAT_PATTERN.finditer(combined))
return fields 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: try:
root = ElementTree.fromstring(content) root = ElementTree.fromstring(content)
except ElementTree.ParseError as exc: except ElementTree.ParseError as exc:
raise DocumentError(f"Template-XML ist beschädigt: {exc}") from exc raise DocumentError(f"Template-XML ist beschädigt: {exc}") from exc
_expand_repeating_rows(root, repeats or {})
unknown: set[str] = set() unknown: set[str] = set()
for paragraph in root.iter(): for paragraph in root.iter():
if _local_name(paragraph.tag) not in {"p", "h"}: 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) 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]]: def _text_slots(element) -> list[tuple[object, str, str]]:
slots: 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 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]] = [] boundaries: list[tuple[int, int]] = []
offset = 0 offset = 0
for _element, _attribute, text in slots: for _element, _attribute, text in slots:
boundaries.append((offset, offset + len(text))) boundaries.append((offset, offset + len(text)))
offset += len(text) offset += len(text)
for match in reversed(matches): for match in reversed(matches):
key = match.group(1) key = match.group(key_group)
if key not in values: if key not in values:
continue continue
start_index, start_offset = _slot_at(boundaries, match.start()) 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" builtin_templates = Path(__file__).resolve().parent.parent / "assets" / "templates"
if builtin_templates.is_dir(): if builtin_templates.is_dir():
for source in builtin_templates.iterdir(): 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(): if source.is_file() and not destination.exists():
shutil.copyfile(source, destination) shutil.copyfile(source, destination)
config_path = self.root / "repository.json" config_path = self.root / "repository.json"
+43 -3
View File
@@ -32,6 +32,42 @@ def test_unknown_placeholder_is_reported() -> None:
_replace_xml_placeholders(source, {}) _replace_xml_placeholders(source, {})
def test_claim_item_loop_clones_formatted_table_row() -> None:
source = b"""<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
<office:body><office:text><table:table>
<table:table-row table:style-name="item-row">
<table:table-cell><text:p>{{#claim.items}}{{item.description}}</text:p></table:table-cell>
<table:table-cell><text:p>{{item.amount}}{{/claim.items}}</text:p></table:table-cell>
</table:table-row>
</table:table></office:text></office:body></office:document>"""
items = [
{"item.description": "Beitrag", "item.amount": "75.00"},
{"item.description": "Gebühr", "item.amount": "5.00"},
]
rendered = _replace_xml_placeholders(source, {}, {"claim.items": items})
root = ElementTree.fromstring(rendered)
rows = [element for element in root.iter() if element.tag.endswith("table-row")]
row_text = ["".join("".join(row.itertext()).split()) for row in rows]
assert row_text == ["Beitrag75.00", "Gebühr5.00"]
assert all(next(iter(row.attrib.values())) == "item-row" for row in rows)
def test_claim_item_loop_removes_row_for_empty_collection() -> None:
source = b"""<table:table xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
<table:table-row><table:table-cell><text:p>{{#claim.items}}{{item.description}}
{{/claim.items}}</text:p></table:table-cell></table:table-row></table:table>"""
rendered = _replace_xml_placeholders(source, {}, {"claim.items": []})
assert not any(element.tag.endswith("table-row") for element in ElementTree.fromstring(rendered).iter())
def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path) -> None: def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()
@@ -42,7 +78,7 @@ def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path
assert template.read_text(encoding="utf-8") == "custom" assert template.read_text(encoding="utf-8") == "custom"
assert {path.name for path in (repository.root / "templates").iterdir()} == { assert {path.name for path in (repository.root / "templates").iterdir()} == {
"Forderung.fodt", "Forderung mit Positionen.fodt",
"Mahnung.fodt", "Mahnung.fodt",
"Mitglied.fodt", "Mitglied.fodt",
} }
@@ -64,8 +100,12 @@ def test_templates_are_filtered_by_available_context(tmp_path) -> None:
} }
assert member_templates == {"Mitglied.fodt"} assert member_templates == {"Mitglied.fodt"}
assert claim_templates == {"Forderung.fodt", "Mitglied.fodt"} assert claim_templates == {"Forderung mit Positionen.fodt", "Mitglied.fodt"}
assert reminder_templates == {"Forderung.fodt", "Mahnung.fodt", "Mitglied.fodt"} assert reminder_templates == {
"Forderung mit Positionen.fodt",
"Mahnung.fodt",
"Mitglied.fodt",
}
def test_generated_reminder_pdf_is_stored_audited_and_linked(tmp_path, monkeypatch) -> None: def test_generated_reminder_pdf_is_stored_audited_and_linked(tmp_path, monkeypatch) -> None: