gaeb 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gaeb/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ """gaeb – Python library for reading and writing GAEB files (AVA).
2
+
3
+ Note on language
4
+ ----------------
5
+ GAEB is a German construction-industry standard (Gemeinsamer Ausschuss
6
+ Elektronik im Bauwesen). A mixed naming convention is used:
7
+
8
+ - **Methods and functions** use English names (``read_xml``, ``write_gaeb90``,
9
+ ``validate``, ``compare``).
10
+ - **Domain fields** on model classes use German identifiers that directly mirror
11
+ the official GAEB specification:
12
+
13
+ Leistungsverzeichnis – bill of quantities (BoQ)
14
+ Titel – section / chapter
15
+ Position – line item
16
+ Kurztext – short description
17
+ Langtext – long description
18
+ Einheit – unit of measure
19
+ Menge – quantity
20
+ Einheitspreis – unit price
21
+ Gesamtpreis – total price
22
+ Phase – exchange phase (e.g. 83 = tender, 84 = bid)
23
+ """
24
+
25
+ from .builder import BoQBuilder
26
+ from .diff import DiffResult, FieldChange, PositionChange, compare
27
+ from .io.excel.reader import read as read_excel
28
+ from .io.excel.writer import write as write_excel
29
+ from .io.gaeb90.reader import read as read_gaeb90
30
+ from .io.gaeb90.writer import write as write_gaeb90
31
+ from .io.gaeb2000.reader import read as read_gaeb2000
32
+ from .io.gaeb2000.writer import write as write_gaeb2000
33
+ from .io.xml.reader import read as read_xml
34
+ from .io.xml.validator import ValidationError, validate, validate_or_raise
35
+ from .io.xml.writer import write as write_xml
36
+ from .models import (
37
+ Bieter,
38
+ BieterEP,
39
+ BoQBkdn,
40
+ Leistungsverzeichnis,
41
+ Phase,
42
+ Position,
43
+ PositionTyp,
44
+ Preisspiegel,
45
+ Raum,
46
+ Raumbuch,
47
+ Raumgruppe,
48
+ RaumPosition,
49
+ Titel,
50
+ )
51
+
52
+ __all__ = [
53
+ "Bieter",
54
+ "BieterEP",
55
+ "BoQBkdn",
56
+ "BoQBuilder",
57
+ "DiffResult",
58
+ "FieldChange",
59
+ "Leistungsverzeichnis",
60
+ "Phase",
61
+ "Position",
62
+ "PositionChange",
63
+ "PositionTyp",
64
+ "Preisspiegel",
65
+ "Raum",
66
+ "RaumPosition",
67
+ "Raumbuch",
68
+ "Raumgruppe",
69
+ "Titel",
70
+ "ValidationError",
71
+ "compare",
72
+ "read_excel",
73
+ "read_gaeb90",
74
+ "read_gaeb2000",
75
+ "read_xml",
76
+ "validate",
77
+ "validate_or_raise",
78
+ "write_excel",
79
+ "write_gaeb90",
80
+ "write_gaeb2000",
81
+ "write_xml",
82
+ ]
83
+
84
+ __version__ = "0.5.2"
gaeb/builder.py ADDED
@@ -0,0 +1,307 @@
1
+ """BoQ Builder – programmatischer Aufbau von Leistungsverzeichnissen.
2
+
3
+ Beispiel::
4
+
5
+ from decimal import Decimal
6
+ import gaeb
7
+
8
+ lv = (
9
+ gaeb.BoQBuilder(phase=gaeb.Phase.ANGEBOTSAUFFORDERUNG, titel_lv="Testprojekt")
10
+ .titel("Erdarbeiten")
11
+ .position("Bodenaushub", einheit="m³", menge=100, ep="12.50")
12
+ .position("Bodentransport", einheit="m³", menge=100, ep="8.00")
13
+ .end()
14
+ .build()
15
+ )
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import warnings
21
+ from decimal import Decimal, InvalidOperation
22
+
23
+ from .models import BoQBkdn, Leistungsverzeichnis, Phase, Position, PositionTyp, Titel
24
+
25
+ # Phasen in denen Einheitspreise sinnvoll sind
26
+ _PREIS_PHASEN = {
27
+ "84", "84Z", "85", "86", "86ZR", "86ZE",
28
+ "87", "88", "89",
29
+ "94", "95", "96", "97",
30
+ }
31
+
32
+ _DEFAULT_SCHEMA = [
33
+ BoQBkdn(typ="BoQLevel", laenge=2),
34
+ BoQBkdn(typ="BoQLevel", laenge=2),
35
+ BoQBkdn(typ="Item", laenge=4),
36
+ ]
37
+
38
+
39
+ def _to_decimal(wert: float | str | Decimal | None) -> Decimal | None:
40
+ if wert is None:
41
+ return None
42
+ if isinstance(wert, Decimal):
43
+ return wert
44
+ try:
45
+ return Decimal(str(wert))
46
+ except InvalidOperation as exc:
47
+ raise ValueError(f"Ungültiger Dezimalwert: {wert!r}") from exc
48
+
49
+
50
+ def _to_langtext(wert: str | list[str] | None) -> list[str]:
51
+ if wert is None:
52
+ return []
53
+ if isinstance(wert, list):
54
+ return wert
55
+ return wert.splitlines()
56
+
57
+
58
+ def _oz_pad(nummer: int, laenge: int) -> str:
59
+ return str(nummer).zfill(laenge)
60
+
61
+
62
+ class TitelBuilder:
63
+ """Builder für einen einzelnen Titel (Kapitel/Unterkapitel)."""
64
+
65
+ def __init__(
66
+ self,
67
+ parent: BoQBuilder | TitelBuilder,
68
+ kurztext: str,
69
+ oz: str,
70
+ langtext: str | list[str] = "",
71
+ ) -> None:
72
+ self._parent = parent
73
+ self._kurztext = kurztext
74
+ self._oz = oz
75
+ self._langtext = _to_langtext(langtext)
76
+ self._positionen: list[Position] = []
77
+ self._untertitel: list[TitelBuilder] = []
78
+ self._pos_zaehler = 0
79
+ self._ut_zaehler = 0
80
+
81
+ # ------------------------------------------------------------------
82
+ # Positions-OZ generieren
83
+ # ------------------------------------------------------------------
84
+
85
+ def _naechste_pos_oz(self) -> str:
86
+ """Generiert die OZ der nächsten Position basierend auf dem Schema.
87
+
88
+ Fehlende Zwischenebenen werden mit '0' aufgefüllt, sodass die OZ
89
+ immer die volle Schema-Länge hat.
90
+ """
91
+ self._pos_zaehler += 1
92
+ schema = self._root._schema
93
+ item_ebene = schema[-1]
94
+ # Gesamtlänge aller BoQLevel-Ebenen
95
+ boqlevel_laenge = sum(e.laenge for e in schema if e.typ == "BoQLevel")
96
+ # Titel-OZ auf BoQLevel-Gesamtlänge mit '0' auffüllen
97
+ oz_prefix = self._oz.ljust(boqlevel_laenge, "0")
98
+ return oz_prefix + _oz_pad(self._pos_zaehler, item_ebene.laenge)
99
+
100
+ def _naechste_ut_oz(self) -> str:
101
+ """Generiert die OZ des nächsten Untertitels.
102
+
103
+ Bestimmt die eigene Tiefe anhand der OZ-Länge und sucht die nächste
104
+ BoQLevel-Ebene im Schema. Nutzt ``enumerate`` um bei mehreren Ebenen
105
+ gleicher Länge den richtigen Index zu finden.
106
+ """
107
+ self._ut_zaehler += 1
108
+ schema = self._root._schema
109
+ eigene_laenge = len(self._oz)
110
+ kumuliert = 0
111
+ for idx, ebene in enumerate(schema):
112
+ kumuliert += ebene.laenge
113
+ if kumuliert == eigene_laenge:
114
+ if idx + 1 < len(schema) and schema[idx + 1].typ == "BoQLevel":
115
+ naechste_laenge = schema[idx + 1].laenge
116
+ return self._oz + _oz_pad(self._ut_zaehler, naechste_laenge)
117
+ break
118
+ # Fallback: 2 Zeichen
119
+ return self._oz + _oz_pad(self._ut_zaehler, 2)
120
+
121
+ @property
122
+ def _root(self) -> BoQBuilder:
123
+ node: TitelBuilder | BoQBuilder = self
124
+ while isinstance(node, TitelBuilder):
125
+ node = node._parent
126
+ return node # type: ignore[return-value]
127
+
128
+ # ------------------------------------------------------------------
129
+ # Fluent API
130
+ # ------------------------------------------------------------------
131
+
132
+ def position(
133
+ self,
134
+ kurztext: str,
135
+ *,
136
+ oz: str | None = None,
137
+ einheit: str = "",
138
+ menge: float | str | Decimal | None = None,
139
+ ep: float | str | Decimal | None = None,
140
+ einheitspreis: float | str | Decimal | None = None,
141
+ langtext: str | list[str] = "",
142
+ typ: PositionTyp = PositionTyp.NORMAL,
143
+ pauschale: bool = False,
144
+ stundenlohn: bool = False,
145
+ entfaellt: bool = False,
146
+ nicht_angeboten: bool = False,
147
+ schwerpunkt: bool = False,
148
+ provis: bool = False,
149
+ # Zeitvertrag-Felder (nur Z-Phasen)
150
+ periodqty: float | str | Decimal | None = None,
151
+ min_menge: float | str | Decimal | None = None,
152
+ max_menge: float | str | Decimal | None = None,
153
+ ) -> TitelBuilder:
154
+ """Fügt eine Position zu diesem Titel hinzu. Gibt ``self`` zurück."""
155
+ # ep ist Alias für einheitspreis
156
+ ep_wert = _to_decimal(ep if ep is not None else einheitspreis)
157
+ pos_oz = oz if oz is not None else self._naechste_pos_oz()
158
+ pos = Position(
159
+ oz=pos_oz,
160
+ kurztext=kurztext,
161
+ langtext=_to_langtext(langtext),
162
+ einheit=einheit,
163
+ menge=_to_decimal(menge),
164
+ einheitspreis=ep_wert,
165
+ typ=typ,
166
+ pauschale=pauschale,
167
+ stundenlohn=stundenlohn,
168
+ entfaellt=entfaellt,
169
+ nicht_angeboten=nicht_angeboten,
170
+ schwerpunkt=schwerpunkt,
171
+ provis=provis,
172
+ periodqty=_to_decimal(periodqty),
173
+ min_menge=_to_decimal(min_menge),
174
+ max_menge=_to_decimal(max_menge),
175
+ )
176
+ self._positionen.append(pos)
177
+ return self
178
+
179
+ def untertitel(
180
+ self,
181
+ kurztext: str,
182
+ *,
183
+ oz: str | None = None,
184
+ langtext: str | list[str] = "",
185
+ ) -> TitelBuilder:
186
+ """Fügt einen Untertitel hinzu und gibt dessen Builder zurück."""
187
+ ut_oz = oz if oz is not None else self._naechste_ut_oz()
188
+ ut = TitelBuilder(parent=self, kurztext=kurztext, oz=ut_oz, langtext=langtext)
189
+ self._untertitel.append(ut)
190
+ return ut
191
+
192
+ def end(self) -> BoQBuilder | TitelBuilder:
193
+ """Closes this Titel and returns to the parent builder."""
194
+ return self._parent
195
+
196
+ def _build(self) -> Titel:
197
+ return Titel(
198
+ nummer=self._oz,
199
+ kurztext=self._kurztext,
200
+ langtext=self._langtext,
201
+ positionen=self._positionen,
202
+ untertitel=[ut._build() for ut in self._untertitel],
203
+ )
204
+
205
+
206
+ class BoQBuilder:
207
+ """Builder für ein vollständiges Leistungsverzeichnis.
208
+
209
+ Parameters
210
+ ----------
211
+ phase:
212
+ Datenaustauschphase. Beeinflusst Warnungen bei Preisangaben.
213
+ titel_lv:
214
+ Kurzbezeichnung des LV.
215
+ projekt:
216
+ Projektname / Bauvorhaben.
217
+ auftraggeber:
218
+ Name des Auftraggebers.
219
+ waehrung:
220
+ Währungscode (Standard: ``"EUR"``).
221
+ vorbemerkungen:
222
+ Allgemeine Vorbemerkungen (Liste von Zeilen oder einzelner String).
223
+ boq_bkdn:
224
+ OZ-Schema. Standard: 2 BoQLevel-Ebenen (je 2 Zeichen) + 1 Item-Ebene (4 Zeichen).
225
+ """
226
+
227
+ def __init__(
228
+ self,
229
+ *,
230
+ phase: Phase | str | None = None,
231
+ titel_lv: str = "",
232
+ projekt: str = "",
233
+ auftraggeber: str = "",
234
+ auftragnehmer: str = "",
235
+ waehrung: str = "EUR",
236
+ vorbemerkungen: str | list[str] = "",
237
+ boq_bkdn: list[BoQBkdn] | None = None,
238
+ ) -> None:
239
+ if isinstance(phase, str) and not isinstance(phase, Phase):
240
+ phase = Phase(phase)
241
+ self._phase = phase
242
+ self._titel_lv = titel_lv
243
+ self._projekt = projekt
244
+ self._auftraggeber = auftraggeber
245
+ self._auftragnehmer = auftragnehmer
246
+ self._waehrung = waehrung
247
+ self._vorbemerkungen = _to_langtext(vorbemerkungen)
248
+ self._schema: list[BoQBkdn] = boq_bkdn if boq_bkdn is not None else list(_DEFAULT_SCHEMA)
249
+ self._titel_builder: list[TitelBuilder] = []
250
+ self._titel_zaehler = 0
251
+
252
+ def _naechste_titel_oz(self) -> str:
253
+ self._titel_zaehler += 1
254
+ # Erste BoQLevel-Ebene
255
+ erste_laenge = next(
256
+ (e.laenge for e in self._schema if e.typ == "BoQLevel"), 2
257
+ )
258
+ return _oz_pad(self._titel_zaehler, erste_laenge)
259
+
260
+ def titel(
261
+ self,
262
+ kurztext: str,
263
+ *,
264
+ oz: str | None = None,
265
+ langtext: str | list[str] = "",
266
+ ) -> TitelBuilder:
267
+ """Fügt einen obersten Titel hinzu und gibt dessen Builder zurück."""
268
+ t_oz = oz if oz is not None else self._naechste_titel_oz()
269
+ tb = TitelBuilder(parent=self, kurztext=kurztext, oz=t_oz, langtext=langtext)
270
+ self._titel_builder.append(tb)
271
+ return tb
272
+
273
+ def build(self) -> Leistungsverzeichnis:
274
+ """Erzeugt das fertige :class:`~gaeb.models.Leistungsverzeichnis`.
275
+
276
+ Gibt eine Warnung aus wenn Einheitspreise in einer Phase gesetzt wurden,
277
+ in der sie nicht üblich sind.
278
+ """
279
+ if self._phase is not None:
280
+ phase_wert = self._phase.value if isinstance(self._phase, Phase) else str(self._phase)
281
+ if phase_wert not in _PREIS_PHASEN:
282
+ # Prüfe ob irgendwo EPs gesetzt wurden
283
+ lv_tmp = Leistungsverzeichnis(
284
+ phase=self._phase,
285
+ titel=[tb._build() for tb in self._titel_builder],
286
+ )
287
+ if any(
288
+ p.einheitspreis is not None for p in lv_tmp.alle_positionen
289
+ ):
290
+ warnings.warn(
291
+ f"Einheitspreise wurden gesetzt, aber Phase {phase_wert!r} "
292
+ "ist keine Angebotsphase. Einheitspreise werden beim "
293
+ "Schreiben möglicherweise ignoriert.",
294
+ stacklevel=2,
295
+ )
296
+
297
+ return Leistungsverzeichnis(
298
+ phase=self._phase,
299
+ titel_lv=self._titel_lv,
300
+ projekt=self._projekt,
301
+ auftraggeber=self._auftraggeber,
302
+ auftragnehmer=self._auftragnehmer,
303
+ waehrung=self._waehrung,
304
+ vorbemerkungen=self._vorbemerkungen,
305
+ boq_bkdn=self._schema,
306
+ titel=[tb._build() for tb in self._titel_builder],
307
+ )
gaeb/diff.py ADDED
@@ -0,0 +1,208 @@
1
+ """GAEB Leistungsverzeichnis Diff.
2
+
3
+ Compares two :class:`Leistungsverzeichnis` objects and returns structural,
4
+ content-related and financial differences.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import warnings
10
+ from collections import Counter
11
+ from dataclasses import dataclass, field
12
+ from decimal import Decimal
13
+ from typing import Any
14
+
15
+ from .models.lv import Leistungsverzeichnis, Titel
16
+
17
+ # Skalare Felder die beim Positionsvergleich direkt verglichen werden
18
+ _COMPARE_FIELDS = (
19
+ "kurztext", "einheit", "menge", "einheitspreis",
20
+ "typ", "entfaellt", "pauschale", "stundenlohn",
21
+ "schwerpunkt", "provis", "nicht_angeboten",
22
+ "periodqty", "min_menge", "max_menge",
23
+ )
24
+
25
+ # Felder mit Listen-Werten (werden über Gleichheit verglichen, aber separat formatiert)
26
+ _LIST_FIELDS = ("langtext",)
27
+
28
+
29
+ @dataclass
30
+ class FieldChange:
31
+ """A single field change on a position."""
32
+ field: str
33
+ old: Any
34
+ new: Any
35
+
36
+
37
+ @dataclass
38
+ class PositionChange:
39
+ """Changes on a specific position (identified by OZ)."""
40
+ oz: str
41
+ kurztext: str
42
+ changes: list[FieldChange] = field(default_factory=list)
43
+ financial_diff: Decimal | None = None
44
+ """Difference of the total price (new − old). None if not calculable."""
45
+
46
+
47
+ @dataclass
48
+ class TitleChange:
49
+ """Changes to a title (name, hierarchy, or structure)."""
50
+ oz: str
51
+ alt_name: str | None = None
52
+ neu_name: str | None = None
53
+ change_type: str = "" # "name", "removed", "added"
54
+ changes: list[FieldChange] = field(default_factory=list)
55
+
56
+
57
+ @dataclass
58
+ class DiffResult:
59
+ """Result of a Leistungsverzeichnis comparison."""
60
+ only_in_old: list[str] = field(default_factory=list)
61
+ """OZ numbers of positions only present in the older LV (deleted)."""
62
+
63
+ only_in_new: list[str] = field(default_factory=list)
64
+ """OZ numbers of positions only present in the newer LV (added)."""
65
+
66
+ changed: list[PositionChange] = field(default_factory=list)
67
+ """Positions present in both LVs but with differences."""
68
+
69
+ title_changes: list[TitleChange] = field(default_factory=list)
70
+ """Title name or structure changes between the two LVs."""
71
+
72
+ gesamtsumme_alt: Decimal | None = None
73
+ gesamtsumme_neu: Decimal | None = None
74
+
75
+ @property
76
+ def gesamtsumme_diff(self) -> Decimal | None:
77
+ """Difference of the totals (new − old)."""
78
+ if self.gesamtsumme_alt is not None and self.gesamtsumme_neu is not None:
79
+ return self.gesamtsumme_neu - self.gesamtsumme_alt
80
+ return None
81
+
82
+ @property
83
+ def has_changes(self) -> bool:
84
+ return bool(self.only_in_old or self.only_in_new or self.changed)
85
+
86
+
87
+ def _sammle_titel(titel_liste: list[Titel]) -> dict[str, Titel]:
88
+ """Sammelt alle Titel mit ihrer OZ als Key."""
89
+ ergebnis: dict[str, Titel] = {}
90
+ def _rekursiv(t: Titel) -> None:
91
+ ergebnis[t.nummer] = t
92
+ for ut in t.untertitel:
93
+ _rekursiv(ut)
94
+ for t in titel_liste:
95
+ _rekursiv(t)
96
+ return ergebnis
97
+
98
+
99
+ def compare(
100
+ lv_alt: Leistungsverzeichnis,
101
+ lv_neu: Leistungsverzeichnis,
102
+ ) -> DiffResult:
103
+ """Compares two Leistungsverzeichnisse by their OZ numbers.
104
+
105
+ Args:
106
+ lv_alt: The older / original Leistungsverzeichnis.
107
+ lv_neu: The newer / changed Leistungsverzeichnis.
108
+
109
+ Returns:
110
+ :class:`DiffResult` with deleted, new and changed positions.
111
+ """
112
+ alt_pos_list = lv_alt.alle_positionen
113
+ neu_pos_list = lv_neu.alle_positionen
114
+
115
+ # Duplicate OZ detection
116
+ alt_oz_counter = Counter(p.oz for p in alt_pos_list)
117
+ neu_oz_counter = Counter(p.oz for p in neu_pos_list)
118
+ for oz, count in alt_oz_counter.items():
119
+ if count > 1:
120
+ warnings.warn(f"Duplicate OZ '{oz}' in old LV ({count} occurrences). Later entries will overwrite earlier ones.", UserWarning)
121
+ for oz, count in neu_oz_counter.items():
122
+ if count > 1:
123
+ warnings.warn(f"Duplicate OZ '{oz}' in new LV ({count} occurrences). Later entries will overwrite earlier ones.", UserWarning)
124
+
125
+ alt_pos = {p.oz: p for p in alt_pos_list}
126
+ neu_pos = {p.oz: p for p in neu_pos_list}
127
+
128
+ only_in_old = sorted(set(alt_pos) - set(neu_pos))
129
+ only_in_new = sorted(set(neu_pos) - set(alt_pos))
130
+ changed: list[PositionChange] = []
131
+
132
+ for oz in sorted(set(alt_pos) & set(neu_pos)):
133
+ p_alt = alt_pos[oz]
134
+ p_neu = neu_pos[oz]
135
+ field_changes: list[FieldChange] = []
136
+
137
+ for f in _COMPARE_FIELDS:
138
+ v_alt = getattr(p_alt, f)
139
+ v_neu = getattr(p_neu, f)
140
+ if v_alt != v_neu:
141
+ field_changes.append(FieldChange(field=f, old=v_alt, new=v_neu))
142
+
143
+ for f in _LIST_FIELDS:
144
+ v_alt = getattr(p_alt, f)
145
+ v_neu = getattr(p_neu, f)
146
+ if v_alt != v_neu:
147
+ field_changes.append(FieldChange(field=f, old=v_alt, new=v_neu))
148
+
149
+ if field_changes:
150
+ gp_alt = p_alt.gesamtbetrag if p_alt.gesamtbetrag is not None else p_alt.gesamtpreis
151
+ gp_neu = p_neu.gesamtbetrag if p_neu.gesamtbetrag is not None else p_neu.gesamtpreis
152
+ fin = (gp_neu - gp_alt) if (gp_alt is not None and gp_neu is not None) else None
153
+ changed.append(PositionChange(
154
+ oz=oz,
155
+ kurztext=p_neu.kurztext,
156
+ changes=field_changes,
157
+ financial_diff=fin,
158
+ ))
159
+
160
+ # Title comparison
161
+ alt_titel = _sammle_titel(lv_alt.titel)
162
+ neu_titel = _sammle_titel(lv_neu.titel)
163
+ title_changes: list[TitleChange] = []
164
+
165
+ # Removed titles
166
+ for oz in sorted(set(alt_titel) - set(neu_titel)):
167
+ title_changes.append(TitleChange(
168
+ oz=oz,
169
+ alt_name=alt_titel[oz].kurztext,
170
+ change_type="removed",
171
+ ))
172
+
173
+ # Added titles
174
+ for oz in sorted(set(neu_titel) - set(alt_titel)):
175
+ title_changes.append(TitleChange(
176
+ oz=oz,
177
+ neu_name=neu_titel[oz].kurztext,
178
+ change_type="added",
179
+ ))
180
+
181
+ # Changed title names
182
+ for oz in sorted(set(alt_titel) & set(neu_titel)):
183
+ t_alt = alt_titel[oz]
184
+ t_neu = neu_titel[oz]
185
+ if t_alt.kurztext != t_neu.kurztext:
186
+ title_changes.append(TitleChange(
187
+ oz=oz,
188
+ alt_name=t_alt.kurztext,
189
+ neu_name=t_neu.kurztext,
190
+ change_type="name",
191
+ ))
192
+ if t_alt.langtext != t_neu.langtext:
193
+ title_changes.append(TitleChange(
194
+ oz=oz,
195
+ alt_name=t_alt.kurztext,
196
+ neu_name=t_neu.kurztext,
197
+ change_type="langtext",
198
+ changes=[FieldChange(field="langtext", old=t_alt.langtext, new=t_neu.langtext)],
199
+ ))
200
+
201
+ return DiffResult(
202
+ only_in_old=only_in_old,
203
+ only_in_new=only_in_new,
204
+ changed=changed,
205
+ title_changes=title_changes,
206
+ gesamtsumme_alt=lv_alt.gesamtsumme,
207
+ gesamtsumme_neu=lv_neu.gesamtsumme,
208
+ )
gaeb/io/__init__.py ADDED
File without changes
File without changes