diff --git a/README.md b/README.md index adc9217..f474a9c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ member-store/ ├── housekeeper.json ├── rules/ ├── templates/ -│ ├── Forderung.fodt +│ ├── Forderung mit Positionen.fodt │ ├── Mahnung.fodt │ └── Mitglied.fodt └── 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 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 The housekeeper runs every rule for every member. Built-in Python rules live in diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 3e23f1c..437fe1a 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -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.", diff --git a/src/ccma/assets/templates/Forderung.fodt b/src/ccma/assets/templates/Forderung.fodt index 2f884e8..26d817c 100644 --- a/src/ccma/assets/templates/Forderung.fodt +++ b/src/ccma/assets/templates/Forderung.fodt @@ -1,5 +1,5 @@ - + @@ -15,7 +15,22 @@ Anschrift: {{member.address_line}} Fällig am: {{claim.due_date}} Positionen - {{claim.items}} + + + Beschreibung + Typ + Menge + Einzelpreis + Betrag + + + {{#claim.items}}{{item.description}} + {{item.type}} + {{item.quantity}} + {{item.unit_price}} EUR + {{item.amount}} EUR{{/claim.items}} + + Gesamtbetrag: {{claim.total}} Bereits bezahlt: {{claim.paid}} Offener Betrag: {{claim.balance}} diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index 70dad3a..5b7b9a8 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -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()) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 7ace5e5..dc5031d 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -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" diff --git a/tests/test_documents.py b/tests/test_documents.py index 5e41046..2bdc8fb 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -32,6 +32,42 @@ def test_unknown_placeholder_is_reported() -> None: _replace_xml_placeholders(source, {}) +def test_claim_item_loop_clones_formatted_table_row() -> None: + source = b""" + + + {{#claim.items}}{{item.description}} + {{item.amount}}{{/claim.items}} + + """ + 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""" + {{#claim.items}}{{item.description}} + {{/claim.items}}""" + + 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: repository = MemberRepository(tmp_path) 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 {path.name for path in (repository.root / "templates").iterdir()} == { - "Forderung.fodt", + "Forderung mit Positionen.fodt", "Mahnung.fodt", "Mitglied.fodt", } @@ -64,8 +100,12 @@ def test_templates_are_filtered_by_available_context(tmp_path) -> None: } assert member_templates == {"Mitglied.fodt"} - assert claim_templates == {"Forderung.fodt", "Mitglied.fodt"} - assert reminder_templates == {"Forderung.fodt", "Mahnung.fodt", "Mitglied.fodt"} + assert claim_templates == {"Forderung mit Positionen.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: