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 +84 -0
- gaeb/builder.py +307 -0
- gaeb/diff.py +208 -0
- gaeb/io/__init__.py +0 -0
- gaeb/io/excel/__init__.py +0 -0
- gaeb/io/excel/reader.py +222 -0
- gaeb/io/excel/writer.py +261 -0
- gaeb/io/gaeb2000/__init__.py +0 -0
- gaeb/io/gaeb2000/reader.py +256 -0
- gaeb/io/gaeb2000/writer.py +196 -0
- gaeb/io/gaeb90/__init__.py +0 -0
- gaeb/io/gaeb90/reader.py +389 -0
- gaeb/io/gaeb90/writer.py +224 -0
- gaeb/io/utils.py +31 -0
- gaeb/io/xml/__init__.py +0 -0
- gaeb/io/xml/reader.py +396 -0
- gaeb/io/xml/validator.py +131 -0
- gaeb/io/xml/writer.py +516 -0
- gaeb/models/__init__.py +20 -0
- gaeb/models/enums.py +51 -0
- gaeb/models/lv.py +300 -0
- gaeb/models/preisspiegel.py +79 -0
- gaeb/models/raumbuch.py +95 -0
- gaeb/py.typed +0 -0
- gaeb-0.5.2.dist-info/METADATA +180 -0
- gaeb-0.5.2.dist-info/RECORD +27 -0
- gaeb-0.5.2.dist-info/WHEEL +4 -0
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
|