Initial commit – AufmaßCreater v2.35

This commit is contained in:
2026-06-10 11:03:43 +02:00
commit 84c933ea9c
2823 changed files with 490495 additions and 0 deletions
@@ -0,0 +1,150 @@
import json
import re
from app.extensions import db
from app.models.lv import LVPosition
def execute_rules(form_data, rules_json, company_id):
rules = json.loads(rules_json) if isinstance(rules_json, str) else rules_json
positions = []
for rule in rules:
if not _evaluate_conditions(rule.get('conditions', {}), form_data):
continue
for action in rule.get('actions', []):
pos = _create_position(action, form_data, company_id)
if pos:
positions.append(pos)
return positions
def _evaluate_conditions(conditions, form_data):
if not conditions or not conditions.get('items'):
return True
operator = conditions.get('operator', 'and')
items = conditions.get('items', [])
results = []
for cond in items:
field = cond.get('field', '')
op = cond.get('operator', 'eq')
value = cond.get('value', '')
value2 = cond.get('value2', '')
raw = form_data.get(field, '')
if op == 'is_checked':
results.append(raw == 'an')
elif op == 'is_empty':
results.append(raw == '' or raw is None)
elif op == 'not_empty':
results.append(raw != '' and raw is not None)
else:
try:
f_val = _to_float(raw)
f_cond = _to_float(value)
if op == 'eq':
results.append(f_val == f_cond)
elif op == 'neq':
results.append(f_val != f_cond)
elif op == 'gt':
results.append(f_val > f_cond)
elif op == 'gte':
results.append(f_val >= f_cond)
elif op == 'lt':
results.append(f_val < f_cond)
elif op == 'lte':
results.append(f_val <= f_cond)
elif op == 'between':
f_val2 = _to_float(value2)
results.append(f_cond <= f_val <= f_val2)
else:
results.append(str(raw) == str(value))
except (ValueError, TypeError):
results.append(str(raw) == str(value))
if operator == 'or':
return any(results)
return all(results)
def _create_position(action, form_data, company_id):
pos_nr = action.get('pos_nr', '')
if not pos_nr:
return None
lv_lookup = action.get('lv_lookup', True)
columns = action.get('columns', {})
# Default values
pos = {
'pos_nr': pos_nr,
'kurztext': '',
'einheit': 'ST',
'menge': 1,
'faktor': 1,
'laenge': 0,
'breite': 0,
'tiefe': 0,
'bemerkung': '',
'einzelpreis': 0,
}
# LV lookup
if lv_lookup:
lv_pos = LVPosition.query.filter_by(company_id=company_id, pos_nr=pos_nr).first()
if lv_pos:
pos['kurztext'] = lv_pos.kurztext or ''
pos['einheit'] = lv_pos.einheit or 'ST'
pos['einzelpreis'] = lv_pos.einzelpreis or 0
# Apply overrides
for col_key, col_def in columns.items():
if col_key not in pos and col_key != 'pos_nr':
continue
val = _resolve_value(col_def, form_data)
if val is not None:
if col_key in ('menge', 'faktor', 'laenge', 'breite', 'tiefe', 'einzelpreis'):
pos[col_key] = _to_float(val)
else:
pos[col_key] = str(val)
return pos
def _resolve_value(col_def, form_data):
if not col_def:
return None
ctype = col_def.get('type', 'fixed')
value = col_def.get('value', '')
if ctype == 'fixed':
return value
elif ctype == 'field':
return form_data.get(value, '')
elif ctype == 'formula':
return _eval_formula(value, form_data)
return value
def _eval_formula(expr, form_data):
expr = str(expr)
def _replace_field(m):
fname = m.group(1)
raw = form_data.get(fname, '0')
return str(raw).replace(',', '.')
expr = re.sub(r'\[([^\]]+)\]', _replace_field, expr)
safe = re.sub(r'[^\d\+\-\*\/\(\)\. ]', '', expr)
if not safe.strip():
return 0
try:
return eval(safe, {'__builtins__': {}}, {})
except Exception:
return 0
def _to_float(val):
if val is None or val == '':
return 0.0
return float(str(val).replace(',', '.').replace(' ', ''))
@@ -0,0 +1,134 @@
import json
from flask import url_for
def render_form(form_json, module_id, aufmass_id=None):
fields = json.loads(form_json) if isinstance(form_json, str) else form_json
parts = []
berechnen_url = url_for("custom_modules.berechnen", module_id=module_id)
if aufmass_id:
berechnen_url += f'?aufmass_id={aufmass_id}'
parts.append(f'<form hx-post="{berechnen_url}" hx-target="#modul-modal-body" hx-swap="innerHTML" class="box custom-module-form">')
in_group = False
for f in fields:
ftype = f.get('type', 'text')
name = f.get('name', f.get('id', ''))
label = f.get('label', '')
placeholder = f.get('placeholder', '')
default = f.get('default', '')
required = f.get('required', False)
cond = f.get('conditional_show')
col_size = f.get('columns', '12')
cond_attrs = ''
if cond and cond.get('field') and cond.get('value'):
cond_attrs = f' data-cond-field="{cond["field"]}" data-cond-value="{cond["value"]}" style="display:none"'
if ftype == 'group_start':
if in_group:
parts.append('</div>')
collapsible = f.get('collapsible', False)
title = f.get('title', 'Gruppe')
parts.append(f'<div class="box" style="padding:10px;background:#f8faff;margin-bottom:10px"{cond_attrs}>')
parts.append(f'<h5 class="title is-6 mb-2">{title}</h5>')
parts.append('<div class="columns is-multiline mb-0">')
in_group = True
continue
if ftype == 'group_end':
if in_group:
parts.append('</div>')
parts.append('</div>')
in_group = False
continue
if ftype == 'separator':
parts.append(f'<hr{cond_attrs}>')
continue
if ftype == 'label':
parts.append(f'<p{cond_attrs} style="margin-bottom:6px">{_esc(f.get("text", ""))}</p>')
continue
req_mark = ' <span class="has-text-danger">*</span>' if required else ''
field_html = ''
if ftype == 'text':
field_html = f'<input class="input" type="text" name="{_esc(name)}" placeholder="{_esc(placeholder)}" value="{_esc(default)}" style="font-size:0.9rem">'
elif ftype == 'number':
inputmode = f.get('inputmode', 'decimal')
min_attr = f' min="{f.get("min")}"' if f.get('min') != '' else ''
max_attr = f' max="{f.get("max")}"' if f.get('max') != '' else ''
step_attr = f' step="{f.get("step")}"' if f.get('step') and f.get('step') != 'any' else ''
field_html = f'<input class="input" type="text" inputmode="{inputmode}" name="{_esc(name)}" placeholder="{_esc(placeholder)}" value="{_esc(default)}"{min_attr}{max_attr}{step_attr} style="font-size:0.9rem">'
elif ftype == 'checkbox':
checked = 'checked' if default else ''
field_html = f'<label class="checkbox"><input type="checkbox" name="{_esc(name)}" value="an" {checked}> {_esc(label)}</label>'
elif ftype == 'dropdown':
opts = f.get('options', [])
options_html = ''
for o in opts:
val = o.get('value', '')
lbl = o.get('label', val)
sel = 'selected' if default == val else ''
options_html += f'<option value="{_esc(val)}" {sel}>{_esc(lbl)}</option>'
field_html = f'<div class="select is-fullwidth"><select name="{_esc(name)}">{options_html}</select></div>'
elif ftype == 'radio':
opts = f.get('options', [])
radio_html = ''
for o in opts:
val = o.get('value', '')
lbl = o.get('label', val)
checked = 'checked' if default == val else ''
radio_html += f'<label class="radio"><input type="radio" name="{_esc(name)}" value="{_esc(val)}" {checked}> {_esc(lbl)}</label>'
field_html = radio_html
if field_html:
if col_size != '12' and in_group:
parts.append(f'<div class="column is-{col_size}"{cond_attrs}><div class="field"><label class="label" style="font-size:0.85rem">{_esc(label)}{req_mark}</label><div class="control">{field_html}</div></div></div>')
else:
parts.append(f'<div class="field"{cond_attrs}><label class="label" style="font-size:0.85rem">{_esc(label)}{req_mark}</label><div class="control">{field_html}</div></div>')
if in_group:
parts.append('</div>')
parts.append(f'<button class="button is-primary mt-3" type="submit">Berechnen &amp; ins Aufmaß übernehmen</button>')
parts.append('</form>')
cond_js = '''
<script>
document.querySelectorAll('.custom-module-form [data-cond-field]').forEach(function(el) {
var fieldName = el.dataset.condField;
var condValue = el.dataset.condValue;
var input = document.querySelector('[name="'+fieldName+'"]');
if (input) {
function toggle() {
var show = false;
if (input.type === 'checkbox') {
show = input.checked;
} else {
show = (input.value === condValue);
}
el.style.display = show ? '' : 'none';
}
toggle();
input.addEventListener('change', toggle);
if (input.type !== 'checkbox') input.addEventListener('input', toggle);
}
});
document.querySelectorAll('.custom-module-form input[inputmode="decimal"]').forEach(function(el) {
if (typeof sanitizeNum === 'function') {
el.addEventListener('input', function() { sanitizeNum(el); });
}
});
</script>'''
parts.append(cond_js)
return '\n'.join(parts)
def _esc(s):
return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
@@ -0,0 +1,332 @@
import re
import time
import logging
from urllib.parse import quote, urlencode
import requests
logger = logging.getLogger(__name__)
EV_HOST = "https://evergabe.telekom.de"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0"
class EVergabeError(Exception):
pass
class EVergabeClient:
"""Python-Port der AutoIt EVvergabeWebobj.au3 Funktionen."""
def __init__(self, username, password, name=""):
self.username = username
self.password = password
self.name = name
self.session = requests.Session()
self.session.headers.update({
"User-Agent": USER_AGENT,
})
self.session.verify = True
def _url_encode(self, s):
return quote(str(s), safe="")
def _extract_csrf(self, html):
meta = re.search(
r'(?i)<meta[^>]+name="csrf-token"[^>]+content="([^"]+)"', html
)
if meta:
return "_csrf", meta.group(1)
inp = re.search(
r'(?i)name="([^"]*csrf[^"]*)"[^>]*value="([^"]+)"', html
)
if inp:
return inp.group(1), inp.group(2)
raise EVergabeError("CSRF-Token nicht gefunden")
def login(self):
html = self._get(f"{EV_HOST}/public/login")
csrf_field, csrf_value = self._extract_csrf(html)
body = (
f"LoginForm[username]={self._url_encode(self.username)}"
f"&LoginForm[password]={self._url_encode(self.password)}"
f"&{csrf_field}={self._url_encode(csrf_value)}"
)
resp = self.session.post(
f"{EV_HOST}/public/login",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
allow_redirects=False,
)
if resp.status_code not in (301, 302, 303) and "Logout" not in resp.text:
raise EVergabeError(f"Login fehlgeschlagen, HTTP {resp.status_code}")
if resp.status_code in (301, 302, 303):
loc = resp.headers.get('Location', '/')
if loc.startswith('http'):
follow = self.session.get(loc, allow_redirects=True)
else:
follow = self.session.get(f"{EV_HOST}{loc}", allow_redirects=True)
logger.info("EVergabe Login erfolgreich")
return True
def _get(self, url, referer=""):
headers = {}
if referer:
headers["Referer"] = referer
resp = self.session.get(url, headers=headers, timeout=30)
text = resp.text.replace("amp;", "")
with open(r'E:\_Coding\__Deepseek_coding\AufmassCreater v2.35 (20260309)\_aufmass_web\ev_debug.log', 'a', encoding='utf-8') as f:
f.write(f"\n{'='*80}\nGET {url}\nReferer: {referer}\nStatus: {resp.status_code}\n{'='*80}\n{text}\n\n")
return text
def _post(self, url, body, referer=""):
headers = {"Content-Type": "application/x-www-form-urlencoded"}
if referer:
headers["Referer"] = referer
resp = self.session.post(url, data=body, headers=headers, timeout=30)
return resp.text.replace("amp;", "")
# === _EV_Search_SM_obj ===
def search_sm(self, sm_nr):
url = (
f"{EV_HOST}/framework-agreement-call"
f"?OrderRecallSearch%5Bhead_line%5D={self._url_encode(sm_nr)}"
f"&OrderRecallSearch%5Bdocument_no%5D="
f"&OrderRecallSearch%5Bincoming_date%5D="
f"&OrderRecallSearch%5Border_date%5D="
f"&OrderRecallSearch%5Bgeneral_agreement_id%5D="
f"&OrderRecallSearch%5Bdocument_state%5D=-1"
)
referer = f"{EV_HOST}/framework-agreement-call"
html = self._get(url, referer)
if sm_nr not in html:
return []
results = []
titles = re.findall(r'(?m)no-wrap" title="([^"]+)', html)
bedarfnr = re.findall(r'Bedarfsnr.: 0([^<]+)<', html)
belegnr = re.findall(r'(42[^\/]+)\/0001', html)
detail_ids = re.findall(r'call/details\?id=([^&]+)&c=1', html)
rv = re.findall(r'/framework-agreement/details\?id=[^"]+">([^<]+)', html)
dates_all = re.findall(r'(\d{2}\.\d{2}\.\d{4})', html)
ausfuehrungsfrist = re.findall(r'(\d{2}\.\d{2}\.\d{4})\s*</td>\s*<td>\s*<a href', html)
status = re.findall(r'/framework-agreement/details\?id=[^"]+">[^<]+', html)
beleg_eingang = dates_all if dates_all else []
max_len = max(len(titles), len(bedarfnr), len(belegnr), len(detail_ids))
logger.info(f"search_sm: titles={len(titles)}, bedarfnr={len(bedarfnr)}, belegnr={len(belegnr)}, detail_ids={len(detail_ids)}")
logger.info(f"search_sm: detail_ids={detail_ids}")
logger.info(f"search_sm: titles_sample={titles[0][:100] if titles else 'none'}")
for i in range(max_len):
results.append({
"title": (titles[i].replace("SM Auftragsnummer: 000", " SM ").replace("\r", " ").replace("\n", " ") if i < len(titles) else ""),
"bedarf_nr": bedarfnr[i] if i < len(bedarfnr) else "",
"beleg_nr": belegnr[i] if i < len(belegnr) else "",
"detail_id": detail_ids[i] if i < len(detail_ids) else "",
"rv": rv[i] if i < len(rv) else "",
"beleg_eingang": beleg_eingang[i] if i < len(beleg_eingang) else "",
"ausfuehrungsfrist": ausfuehrungsfrist[i] if i < len(ausfuehrungsfrist) else "",
"status": status[i] if i < len(status) else "",
})
return results
# === _EV_Aspa_obj ===
def get_aspa(self, details_id):
url = f"{EV_HOST}/framework-agreement-call/details?id={details_id}&c=1"
referer = f"{EV_HOST}/framework-agreement-call/"
html = self._get(url, referer)
name = re.search(r'Name\s*</th>\s*<td>\s*([^<]+?)\s*</td>', html)
emails = re.findall(r'mailto:([^"?\s&]+)', html)
tel = re.search(r'th>Telefon</th><td>([^<]+)', html)
name_val = name.group(1).strip() if name else ""
email_val = emails[1].strip() if len(emails) > 1 else (emails[0].strip() if emails else "")
tel_val = tel.group(1).strip() if tel else ""
return {
"name": name_val,
"email": email_val,
"tel": tel_val,
}
# === _EV_Hole_Kopfdaten_obj (kombiniert) ===
def hole_kopfdaten(self, sm_nr):
self.login()
results = self.search_sm(sm_nr)
if not results:
raise EVergabeError(f"Kein Treffer für SM-Nr: {sm_nr}")
first = results[0]
logger.info(f"SM-Suche: title={first['title'][:50]}, detail_id={first['detail_id']}, beleg_eingang={first['beleg_eingang']}, ausfuehrungsfrist={first['ausfuehrungsfrist']}")
aspa = self.get_aspa(first["detail_id"])
return {
"bezeichnung": first["title"].strip(),
"sm_nr": first["bedarf_nr"],
"abruf_nr": first["beleg_nr"],
"detail_id": first["detail_id"],
"rv": first["rv"],
"beleg_eingang": first["beleg_eingang"],
"ausfuehrungsfrist": first["ausfuehrungsfrist"],
"status": first["status"],
"ansprechpartner_name": aspa["name"],
"ansprechpartner_email": aspa["email"],
"ansprechpartner_tel": aspa["tel"],
}
# === _EV_Pos_eintragen_obj (Aufmaß übertragen) ===
def aufmass_uebertragen(self, sm_nr, positionen, bauabschnitt="", leist_zeitv="", leist_zeitb="", leistungsort="", teilaufmass=False, schlussaufmass=False):
self.login()
results = self.search_sm(sm_nr)
if not results:
raise EVergabeError(f"Kein Treffer für SM-Nr: {sm_nr}")
if len(results) > 1:
details_id = results[0]["detail_id"]
else:
details_id = results[0]["detail_id"]
logger.info(f"DetailsID: {details_id}")
html = self._get(
f"{EV_HOST}/framework-agreement-call/details?id={details_id}&c=1",
f"{EV_HOST}/framework-agreement-call"
)
if "LERF nicht möglich" in html:
raise EVergabeError("LERF nicht möglich")
html = self._get(
f"{EV_HOST}/sheet/index?c=1&importId={details_id}",
f"{EV_HOST}/framework-agreement-call/details?id={details_id}&c=1"
)
html = self._get(
f"{EV_HOST}/sheet/create-sheet?c=1&id={details_id}",
f"{EV_HOST}/sheet/index?c=1&importId={details_id}"
)
csrf_field, csrf_value = self._extract_csrf(html)
leistungsort1 = leistungsort[:25] if len(leistungsort) >= 25 else leistungsort
leistungsort_so = leistungsort
sachbearbeiter = self.name[:12] if len(self.name) >= 12 else self.name
bauabschnitt_clean = bauabschnitt[:39] if len(bauabschnitt) >= 40 else bauabschnitt
kurztext = f"SM {sm_nr}"[:39]
langtext = f"{leistungsort} {bauabschnitt} SM {sm_nr}"
final = "X" if schlussaufmass else ""
body = (
f"_csrf={self._url_encode(csrf_value)}%3D%3D"
f"&BapiEssr%5Bfinal%5D={final}"
f"&BapiEssr%5Bfinal%5D={final}"
f"&BapiEssr%5Blzvon%5D={self._url_encode(leist_zeitv)}"
f"&BapiEssr%5Blzbis%5D={self._url_encode(leist_zeitb)}"
f"&BapiEssr%5Bdlort%5D={self._url_encode(leistungsort1)}"
f"&BapiEssr%5Bsbnaman%5D={self._url_encode(sachbearbeiter)}"
f"&BapiEssr%5Btxz01%5D={self._url_encode(bauabschnitt_clean)}"
f"&BapiEssr%5Bdescription%5D={self._url_encode(langtext)}"
f"&save+sheets="
)
html = self._post(
f"{EV_HOST}/sheet/create-sheet?c=1&iid={details_id}",
body,
f"{EV_HOST}/sheet/create-sheet?c=1&iid={details_id}"
)
sheet_id_match = re.search(r'sheetId=(.*)">Kopfdaten', html)
if not sheet_id_match:
raise EVergabeError(f"sheetID nicht gefunden. Response: {html[:200]}")
sheet_id = sheet_id_match.group(1)
logger.info(f"SheetID: {sheet_id}")
ergebnisse = []
for idx, pos in enumerate(positionen):
pos_html = self._get(
f"{EV_HOST}/sheet-position/index?c=1&sheetId={sheet_id}",
f"{EV_HOST}/sheet/index?c=1&importId={details_id}"
)
csrf_field2, csrf_value2 = self._extract_csrf(pos_html)
pos_nr = pos.get("pos_nr", "")
pos_body = f"_csrf={self._url_encode(csrf_value2)}%3D%3D&ServicePosition%5Bnumber%5D={self._url_encode(pos_nr)}&insertPosition=0"
pos_html2 = self._post(
f"{EV_HOST}/sheet-position/index?c=1&sheetId={sheet_id}",
pos_body,
f"{EV_HOST}/sheet-position/index?c=1&sheetId={sheet_id}"
)
pos_id_match = re.search(r'positionId=([^&]+)', pos_html2)
if not pos_id_match:
ergebnisse.append({"pos_nr": pos_nr, "status": "error", "msg": "positionId nicht gefunden"})
continue
pos_id = pos_id_match.group(1)
create_url = f"{EV_HOST}/sheet-position/create?insertPosition=0&positionId={pos_id}&c=1&sheetId={sheet_id}"
create_html = self._get(
f"{EV_HOST}/sheet-position/create?insertPosition=0&positionId={pos_id}&c=1&sheetId={sheet_id}",
f"{EV_HOST}/sheet/index?c=1&importId={details_id}"
)
csrf_field3, csrf_value3 = self._extract_csrf(create_html)
einheit = pos.get("einheit", "ST")
abschnitt = pos.get("bauabschnitt", bauabschnitt_clean)[:25]
langtext_pos = pos.get("langtext", "")
pos_kurztext = f"{pos_nr}|{pos.get('bezeichnung', '')}"
if einheit == "ST":
menge = str(pos.get("menge", "")).replace(",", "%2C")
post_body = (
f"_csrf={self._url_encode(csrf_value3)}%3D%3D"
f"&ServicePosition%5B0%5D%5BsectionText%5D={self._url_encode(abschnitt)}"
f"&ServicePosition%5B0%5D%5Bquantity%5D={menge}"
f"&ServicePosition%5B0%5D%5BlongText%5D={self._url_encode(langtext_pos)}"
f"&ServicePosition%5B0%5D%5Bid%5D={pos_id}"
f"&clientId=1&sheetId={sheet_id}&insertPosition={idx}&save="
)
elif einheit == "M":
faktor = str(pos.get("faktor", "1")).replace(",", "%2C")
meter = str(pos.get("menge", "")).replace(",", "%2C")
post_body = (
f"_csrf={self._url_encode(csrf_value3)}%3D%3D"
f"&ServicePosition%5B0%5D%5BsectionText%5D={self._url_encode(abschnitt)}"
f"&ServicePosition%5B0%5D%5BformulaSymbol%5D=ME"
f"&ServicePosition%5B0%5D%5BformulaValueMultiplier%5D={faktor}"
f"&ServicePosition%5B0%5D%5BformulaValueLength%5D={meter}"
f"&ServicePosition%5B0%5D%5BlongText%5D={self._url_encode(langtext_pos)}"
f"&ServicePosition%5B0%5D%5Bid%5D={pos_id}"
f"&clientId=1&sheetId={sheet_id}&insertPosition={idx}&save="
)
elif einheit == "M2":
faktor = str(pos.get("faktor", "1")).replace(",", "%2C")
laenge = str(pos.get("laenge", "")).replace(",", "%2C")
breite = str(pos.get("breite", "")).replace(",", "%2C")
post_body = (
f"_csrf={self._url_encode(csrf_value3)}%3D%3D"
f"&ServicePosition%5B0%5D%5BsectionText%5D={self._url_encode(abschnitt)}"
f"&ServicePosition%5B0%5D%5BformulaSymbol%5D=MF"
f"&ServicePosition%5B0%5D%5BformulaValueMultiplier%5D={faktor}"
f"&ServicePosition%5B0%5D%5BformulaValueLength%5D={laenge}"
f"&ServicePosition%5B0%5D%5BformulaValueWidth%5D={breite}"
f"&ServicePosition%5B0%5D%5BlongText%5D={self._url_encode(langtext_pos)}"
f"&ServicePosition%5B0%5D%5Bid%5D={pos_id}"
f"&clientId=1&sheetId={sheet_id}&insertPosition={idx}&save="
)
elif einheit == "M3":
faktor = str(pos.get("faktor", "1")).replace(",", "%2C")
laenge = str(pos.get("laenge", "")).replace(",", "%2C")
breite = str(pos.get("breite", "")).replace(",", "%2C")
tiefe = str(pos.get("tiefe", "")).replace(",", "%2C")
post_body = (
f"_csrf={self._url_encode(csrf_value3)}%3D%3D"
f"&ServicePosition%5B0%5D%5BsectionText%5D={self._url_encode(abschnitt)}"
f"&ServicePosition%5B0%5D%5BformulaSymbol%5D=MV"
f"&ServicePosition%5B0%5D%5BformulaValueMultiplier%5D={faktor}"
f"&ServicePosition%5B0%5D%5BformulaValueLength%5D={laenge}"
f"&ServicePosition%5B0%5D%5BformulaValueWidth%5D={breite}"
f"&ServicePosition%5B0%5D%5BformulaValueDepth%5D={tiefe}"
f"&ServicePosition%5B0%5D%5BlongText%5D={self._url_encode(langtext_pos)}"
f"&ServicePosition%5B0%5D%5Bid%5D={pos_id}"
f"&clientId=1&sheetId={sheet_id}&insertPosition={idx}&save="
)
elif einheit in ("STD", "LE"):
stueck = str(pos.get("menge", "")).replace(",", "%2C")
post_body = (
f"_csrf={self._url_encode(csrf_value3)}%3D%3D"
f"&ServicePosition%5B0%5D%5BsectionText%5D={self._url_encode(abschnitt)}"
f"&ServicePosition%5B0%5D%5Bquantity%5D={stueck}"
f"&ServicePosition%5B0%5D%5BlongText%5D={self._url_encode(langtext_pos)}"
f"&ServicePosition%5B0%5D%5Bid%5D={pos_id}"
f"&clientId=1&sheetId={sheet_id}&insertPosition={idx}&save="
)
else:
ergebnisse.append({"pos_nr": pos_nr, "status": "error", "msg": f"Unbekannte Einheit: {einheit}"})
continue
result_html = self._post(
f"{EV_HOST}/sheet-position/create?insertPosition={idx}&positionId={pos_id}&c=1&sheetId={sheet_id}",
post_body,
f"{EV_HOST}/sheet-position/create?insertPosition=0&positionId={pos_id}&c=1&sheetId={sheet_id}"
)
if "erfolgreich gespeichert" in result_html:
ergebnisse.append({"pos_nr": pos_nr, "status": "ok", "msg": "eingetragen"})
else:
ergebnisse.append({"pos_nr": pos_nr, "status": "error", "msg": "Fehlgeschlagen"})
time.sleep(0.5)
return ergebnisse
@@ -0,0 +1,300 @@
import os
from collections import defaultdict
from datetime import datetime
from flask import current_app, render_template
from fpdf import FPDF
# ── helpers ─────────────────────────────────────────────────────────────────
def _val(v):
return None if v is None or v == 0 or v == '' else str(v)
def _fmt_date(d):
if d is None:
return None
if isinstance(d, str):
d = d[:10]
for fmt in ('%Y-%m-%d', '%d.%m.%Y'):
try:
return datetime.strptime(d, fmt).strftime('%d.%m.%Y')
except ValueError:
continue
return d
return d.strftime('%d.%m.%Y')
def _ist_trenner(pos):
return (not pos.pos_nr or pos.pos_nr == '') and (pos.faktor == 0 or pos.faktor is None) and (pos.laenge == 0 or pos.laenge is None) and (pos.breite == 0 or pos.breite is None) and (pos.tiefe == 0 or pos.tiefe is None) and (pos.menge == 0 or pos.menge is None) and (pos.einzelpreis == 0 or pos.einzelpreis is None) and (pos.gesamtpreis == 0 or pos.gesamtpreis is None)
# ── Farben ──────────────────────────────────────────────────────────────────
BLUE = (47, 84, 150)
LIGHT_GRAY = (0.93, 0.93, 0.93)
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
# ── PDF-Klasse ──────────────────────────────────────────────────────────────
class AufmassPDF(FPDF):
def footer(self):
self.set_y(-8)
self.set_font('DJ', 'I', 7)
self.set_text_color(128, 128, 128)
self.cell(0, 6, f'Seite {self.page_no()}/{{nb}}', align='C', new_x='LMARGIN', new_y='NEXT')
# ── Hauptfunktion ───────────────────────────────────────────────────────────
def _render_pdf_html(project, aufmass, positionen, company=None):
ap_name = _name(project)
groups, seen_pos = _build_summary(positionen)
return render_template(
'aufmass/export_pdf.html',
project=project, aufmass=aufmass, positionen=positionen,
company=company, ap_name=ap_name, groups=dict(groups),
seen_pos=seen_pos, _val=_val, _fmt_date=_fmt_date,
_ist_trenner=_ist_trenner,
)
def _name(project):
return f'{_val(project.ansprechpartner_vorname) or ""} {_val(project.ansprechpartner_nachname) or ""}'.strip()
def _build_summary(positionen):
groups = defaultdict(lambda: {'kurztext': '', 'menge': 0.0, 'ep': 0.0, 'gp': 0.0})
seen = []
for p in positionen:
if _ist_trenner(p) or not p.pos_nr:
continue
key = p.pos_nr
if key not in groups:
seen.append(key)
groups[key]['kurztext'] = p.kurztext or ''
groups[key]['menge'] += p.menge_hinten if p.menge_hinten else (p.menge or 0)
groups[key]['ep'] = p.einzelpreis or 0
groups[key]['gp'] += p.gesamtpreis or 0
return groups, seen
def _val_or(v, default=''):
return str(v) if v is not None and v != 0 and v != '' else default
def _fmt(v):
return f'{v:.2f}'.replace('.',',') if v is not None else ''
# ── Spaltenbreiten (mm) Verhältnis wie Excel max_widths ───────────────────
# A4 quer = 297mm, margin 5mm → 287mm verfügbar, aber wir nutzen 282mm für die Tabelle
W_POS = [20, 17, 12, 14, 14, 14, 16, 9, 63, 52, 16, 16, 19] # sum = 282mm
# 20+17+12+14+14+14+16+9+63+52+16+16+19 = 282 ✓
W_SUM = [20, 172, 28, 28, 34] # Zusammenfassung: PNr, Kurztext, Menge, EP, GP
# 20+172+28+28+34 = 282mm (gleiche Breite wie Positionstabelle)
def _font_path(name, fallback=None):
win = f'C:/Windows/Fonts/{name}'
lin = f'/usr/share/fonts/truetype/dejavu/{name}'
if os.path.exists(win):
return win
if os.path.exists(lin):
return lin
return _font_path(fallback) if fallback else name
DJV = _font_path('DejaVuSans.ttf')
DJV_B = _font_path('DejaVuSans-Bold.ttf')
DJV_O = _font_path('DejaVuSans-Oblique.ttf', fallback='DejaVuSans.ttf')
DJV_BI = _font_path('DejaVuSans-BoldOblique.ttf', fallback='DejaVuSans-Bold.ttf')
def export_project_to_pdf(project, aufmass, positionen, output_path, company=None):
pdf = AufmassPDF(orientation='L', format='A4')
pdf.alias_nb_pages()
pdf.set_auto_page_break(auto=True, margin=10)
pdf.add_page()
pdf.set_margins(7, 5, 7) # 7mm links/rechts → 283mm verfügbar
# Aber wir nutzen 282mm passend zu den Tabellen
pdf.set_margins(7.5, 5, 7.5) # 7.5mm links/rechts → 282mm verfügbar
pdf.add_font('DJ', '', DJV, uni=True)
pdf.add_font('DJ', 'B', DJV_B, uni=True)
pdf.add_font('DJ', 'I', DJV_O, uni=True)
pdf.add_font('DJ', 'BI', DJV_BI, uni=True)
ap_name = _name(project)
groups, seen_pos = _build_summary(positionen)
total_gp = sum(g['gp'] for g in groups.values())
# ── Logo / Titel ─────────────────────────────────────────────────────────
logo_shown = False
if company and company.logo:
lp = company.logo
if not os.path.isabs(lp):
lp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), lp)
if os.path.exists(lp):
try:
pdf.image(lp, x=5, y=5, w=30)
logo_shown = True
except Exception:
pass
if not logo_shown:
if company and company.name:
pdf.set_font('DJ', 'B', 12)
pdf.set_text_color(*BLUE)
pdf.cell(0, 8, company.name, align='L', new_x='LMARGIN', new_y='NEXT')
pdf.set_font('DJ', 'B', 16)
pdf.set_text_color(*BLUE)
pdf.cell(0, 8, 'Aufmaß', align='C', new_x='LMARGIN', new_y='NEXT')
else:
pdf.set_font('DJ', 'B', 16)
pdf.set_text_color(*BLUE)
pdf.cell(0, 8, 'Aufmaß', align='C', new_x='LMARGIN', new_y='NEXT')
pdf.set_text_color(*BLACK)
pdf.ln(3)
# ── Kopfzeilen (8 Spalten zu je 35.25mm = 282mm) ───────────────────────────
CW = 35.25 # jede Spalte gleich breit
LH = 5.5
pdf.set_font('DJ', '', 7.5)
def cell_lbl(txt, w=CW):
pdf.set_fill_color(242, 242, 242)
pdf.set_font('DJ', 'B', 7.5)
pdf.cell(w, LH, txt, border=1, fill=True, align='L')
def cell_val(txt, w=CW):
pdf.set_font('DJ', '', 7.5)
pdf.cell(w, LH, txt, border=1, align='L')
def nl():
pdf.set_x(7.5)
# Row 1: Vertrag(2) / LV-Name(4) / Aufmaß-Datum(2)
cell_lbl('Vertrag:'); cell_val(_val_or(project.vertrag), CW)
cell_lbl('LV-Name:'); cell_val(_val_or(project.lv_name), CW*3)
cell_lbl('Aufmaß-Datum:'); cell_val(_fmt_date(project.datum) or '', CW*2)
nl(); pdf.ln(LH)
# Row 2: Projekt(2) / Baustelle(6)
cell_lbl('Projekt:'); cell_val(_val_or(project.bezeichnung), CW)
cell_lbl('Baustelle:'); cell_val(_val_or(project.baustelle), CW*5)
nl(); pdf.ln(LH)
# Row 3: Typ(2) / Bauabschnitt(6)
cell_lbl('Typ:'); cell_val(_val_or(aufmass.typ if aufmass else None), CW)
cell_lbl('Bauabschnitt:'); cell_val(_val_or(project.bauabschnitt), CW*5)
nl(); pdf.ln(LH)
# Row 4: SM-Nr(2) / Startdatum(2) / Name(1) / Wert(1) / Tel(2)
cell_lbl('SM-Nr.:'); cell_val(_val_or(project.sm_nr), CW)
cell_lbl('Startdatum:'); cell_val(_fmt_date(project.datum_start) or '', CW)
cell_lbl('Name:'); cell_val(_val_or(ap_name))
cell_lbl('Tel:'); cell_val(_val_or(project.ansprechpartner_tel), CW)
nl(); pdf.ln(LH)
# Row 5: Abruf-Nr(2) / Enddatum(2) / Email(4)
cell_lbl('Abruf-Nr.:'); cell_val(_val_or(project.abruf_nr), CW)
cell_lbl('Enddatum:'); cell_val(_fmt_date(project.datum_ende) or '', CW)
cell_lbl('Email:'); cell_val(_val_or(project.ansprechpartner_email), CW*4)
nl(); pdf.ln(LH + 2)
# ── Positionstabelle ─────────────────────────────────────────────────────
headers = ['Abschn.', 'Pos-Nr', 'Fakt.', 'Länge', 'Breite', 'Tiefe',
'Menge', 'EH', 'Kurztext', 'Bemerkung', 'Menge', 'EP (€)', 'GP (€)']
# Tabellenkopf
pdf.set_fill_color(*BLUE)
pdf.set_text_color(*WHITE)
pdf.set_font('DJ', 'B', 6)
x0 = pdf.get_x()
y0 = pdf.get_y()
for i, h in enumerate(headers):
pdf.cell(W_POS[i], 5, h, border=1, fill=True, align='C')
pdf.ln()
pdf.set_text_color(*BLACK)
# Datenzeilen
pdf.set_font('DJ', '', 6)
row_h = 4.5
gesamt = 0.0
pos_counter = 0
for pos in positionen:
if _ist_trenner(pos):
pdf.set_fill_color(245, 245, 245)
pdf.cell(sum(W_POS), 3, '', border=1, fill=True)
pdf.ln()
continue
pos_counter += 1
menge = pos.menge if pos.menge else None
menge_h = pos.menge_hinten if pos.menge_hinten else None
if pos.einheit in ('ST', 'LE', 'STD', 'h', 'Psch'):
menge = pos.faktor * 1 if pos.faktor else None
gp = pos.gesamtpreis or 0
gesamt += gp
y0 = pdf.get_y()
x0 = pdf.get_x()
# Kurztext & Bemerkung: MultiCell für Wrap, dann Höhe bestimmen
kurz = _val_or(pos.kurztext)
bemerk = _val_or(pos.bemerkung)
# Einfache Zeilenhöhe (kein MultiCell zu komplex)
# Stattdessen: Text auf eine Zeile beschränken (abschneiden bei Überlauf)
MAX_KURZTEXT = 95
MAX_BEMERK = 28
if len(kurz) > MAX_KURZTEXT:
kurz = kurz[:MAX_KURZTEXT-3] + '...'
if len(bemerk) > MAX_BEMERK:
bemerk = bemerk[:MAX_BEMERK-3] + '...'
vals = [
pos.abschnitt or '', pos.pos_nr or '',
_fmt(pos.faktor), _fmt(pos.laenge), _fmt(pos.breite), _fmt(pos.tiefe),
_fmt(menge), pos.einheit or '', kurz, bemerk,
_fmt(menge_h), _fmt(pos.einzelpreis), _fmt(gp)
]
for i, v in enumerate(vals):
a = 'L' if i in (8, 9) else ('C' if i in (0, 7) else 'R')
pdf.cell(W_POS[i], row_h, v, border=1, align=a)
pdf.ln()
# Summenzeile
pdf.set_font('DJ', 'B', 6)
pdf.cell(sum(W_POS[:11]), row_h, '', border=1, align='R')
pdf.cell(W_POS[11], row_h, 'Summe:', border=1, align='R')
pdf.cell(W_POS[12], row_h, f'{gesamt:.2f}', border=1, align='R')
pdf.ln()
# ── Mengen-Zusammenfassung ─────────────────────────────────────────────
if seen_pos:
pdf.ln(4)
pdf.set_fill_color(214, 228, 240)
pdf.set_text_color(*BLUE)
pdf.set_font('DJ', 'B', 9)
pdf.cell(sum(W_SUM), 6, 'Mengen- und Positions-Zusammenfassung', border=1, fill=True, align='C')
pdf.ln()
pdf.set_text_color(*WHITE)
pdf.set_fill_color(*BLUE)
pdf.set_font('DJ', 'B', 6)
for i, h in enumerate(['Pos-Nr', 'Kurztext', 'Menge', 'EP (€)', 'GP (€)']):
pdf.cell(W_SUM[i], 5, h, border=1, fill=True, align='C')
pdf.ln()
pdf.set_text_color(*BLACK)
total = 0.0
pdf.set_font('DJ', '', 7)
MAX_KURZTEXT_SUM = 120
for key in seen_pos:
g = groups[key]
total += g['gp']
kurz_sum = g['kurztext']
if len(kurz_sum) > MAX_KURZTEXT_SUM:
kurz_sum = kurz_sum[:MAX_KURZTEXT_SUM-3] + '...'
vals = [key, kurz_sum, _fmt(g['menge']), _fmt(g['ep']), _fmt(g['gp'])]
for i, v in enumerate(vals):
a = 'L' if i == 1 else 'C' if i == 0 else 'R'
pdf.cell(W_SUM[i], 5, v, border=1, align=a)
pdf.ln()
pdf.set_font('DJ', 'B', 7)
pdf.cell(W_SUM[0] + W_SUM[1], 5, '', border=1)
pdf.cell(W_SUM[2], 5, 'Summe:', border=1, align='R')
pdf.cell(W_SUM[3], 5, '', border=1)
pdf.cell(W_SUM[4], 5, f'{total:.2f}', border=1, align='R')
pdf.ln()
pdf.output(output_path)
return output_path
+359
View File
@@ -0,0 +1,359 @@
import os
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image as XlImage
from collections import defaultdict
def _val(v):
if v is None or v == 0 or v == '':
return None
return v
def _ist_trenner(pos):
return (not pos.pos_nr or pos.pos_nr == '') and (pos.faktor == 0 or pos.faktor is None) and (pos.laenge == 0 or pos.laenge is None) and (pos.breite == 0 or pos.breite is None) and (pos.tiefe == 0 or pos.tiefe is None) and (pos.menge == 0 or pos.menge is None) and (pos.einzelpreis == 0 or pos.einzelpreis is None) and (pos.gesamtpreis == 0 or pos.gesamtpreis is None)
def _tb():
return Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
def _fmt_date(d):
if d is None:
return None
if isinstance(d, str):
d = d[:10]
for fmt in ('%Y-%m-%d', '%d.%m.%Y'):
try:
return datetime.strptime(d, fmt).strftime('%d.%m.%Y')
except ValueError:
continue
return d
return d.strftime('%d.%m.%Y')
def export_project_to_excel(project, aufmass, positionen, output_path, company=None):
wb = Workbook()
ws = wb.active
ws.title = "Aufmaß"
ws.page_setup.paperSize = ws.PAPERSIZE_A4
ws.page_setup.orientation = 'landscape'
ws.page_setup.fitToWidth = 1
ws.page_setup.fitToHeight = 0
ws.sheet_properties.pageSetUpPr.fitToPage = True
ws.page_margins.left = 0.3
ws.page_margins.right = 0.3
ws.page_margins.top = 0.4
ws.page_margins.bottom = 0.7
ws.oddFooter.center.text = ""
ws.oddFooter.right.text = "&P/&N"
ws.oddFooter.right.font = "Calibri,11"
header_fill = PatternFill(start_color='2F5496', end_color='2F5496', fill_type='solid')
header_font = Font(name='Calibri', size=11, bold=True, color='FFFFFF')
label_font = Font(name='Calibri', size=10, bold=True)
value_font = Font(name='Calibri', size=10)
value_font_bold = Font(name='Calibri', size=10, bold=True)
title_font = Font(name='Calibri', size=16, bold=True)
header_box_fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid')
trenner_fill = PatternFill(start_color='F5F5F5', end_color='F5F5F5', fill_type='solid')
row = 1
firmen_name = company.name if company else 'Aufmaß'
logo_placed = False
if company and company.logo:
logo_path = company.logo
if not os.path.isabs(logo_path):
logo_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), logo_path)
if os.path.exists(logo_path):
try:
img = XlImage(logo_path)
img.width = 120
img.height = 60
ws.add_image(img, 'A1')
ws.row_dimensions[1].height = 60
ws.merge_cells(start_row=1, start_column=4, end_row=1, end_column=13)
ws.cell(row=1, column=4, value='Aufmaß').font = title_font
ws.cell(row=1, column=4).alignment = Alignment(horizontal='center', vertical='center')
logo_placed = True
except Exception:
pass
if not logo_placed:
ws.cell(row=1, column=1, value=firmen_name).font = title_font
row = 2
row += 1
label_align = Alignment(horizontal='left', vertical='center')
shrink_val = Alignment(horizontal='left', vertical='center', shrink_to_fit=True)
line = row
for ci in range(1, 14):
ws.cell(row=line, column=ci).border = _tb()
pairs = [
('Vertrag:', _val(project.vertrag), (1, 2, 4)),
('LV-Name:', _val(project.lv_name), (5, 6, 9)),
('Aufmaß-Datum:', _fmt_date(project.datum), (10, 11, 13)),
]
for label, val, (ls, vs, ve) in pairs:
c = ws.cell(row=line, column=ls, value=label)
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=vs, value=val)
c.font = value_font; c.alignment = shrink_val
if ve > vs:
ws.merge_cells(start_row=line, start_column=vs, end_row=line, end_column=ve)
row += 1
line = row
for ci in range(1, 14):
ws.cell(row=line, column=ci).border = _tb()
c = ws.cell(row=line, column=1, value='Projekt:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=2, value=_val(project.bezeichnung))
c.font = value_font_bold; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=2, end_row=line, end_column=4)
c = ws.cell(row=line, column=5, value='Baustelle:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=6, value=_val(project.baustelle))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=6, end_row=line, end_column=13)
row += 1
line = row
for ci in range(1, 14):
ws.cell(row=line, column=ci).border = _tb()
c = ws.cell(row=line, column=1, value='Typ:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=2, value=_val(aufmass.typ if aufmass else None))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=2, end_row=line, end_column=4)
c = ws.cell(row=line, column=5, value='Bauabschnitt:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=6, value=_val(project.bauabschnitt))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=6, end_row=line, end_column=13)
row += 1
ap_name = f'{_val(project.ansprechpartner_vorname)} {_val(project.ansprechpartner_nachname)}'.strip()
# Row A: SM-Nr + Startdatum + Ansprechpartner Name + Tel
line = row
for ci in range(1, 14):
ws.cell(row=line, column=ci).border = _tb()
c = ws.cell(row=line, column=1, value='SM-Nr.:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=2, value=_val(project.sm_nr))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=2, end_row=line, end_column=3)
c = ws.cell(row=line, column=4, value='Startdatum:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=5, value=_fmt_date(project.datum_start))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=5, end_row=line, end_column=6)
c = ws.cell(row=line, column=7, value='Name:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=8, value=_val(ap_name))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=8, end_row=line, end_column=10)
c = ws.cell(row=line, column=11, value='Tel:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=12, value=_val(project.ansprechpartner_tel))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=12, end_row=line, end_column=13)
row += 1
# Row B: Abruf-Nr + Enddatum + Email
line = row
for ci in range(1, 14):
ws.cell(row=line, column=ci).border = _tb()
c = ws.cell(row=line, column=1, value='Abruf-Nr.:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=2, value=_val(project.abruf_nr))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=2, end_row=line, end_column=3)
c = ws.cell(row=line, column=4, value='Enddatum:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=5, value=_fmt_date(project.datum_ende))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=5, end_row=line, end_column=6)
c = ws.cell(row=line, column=7, value='Email:')
c.font = label_font; c.fill = header_box_fill; c.alignment = label_align
c = ws.cell(row=line, column=8, value=_val(project.ansprechpartner_email))
c.font = value_font; c.alignment = shrink_val
ws.merge_cells(start_row=line, start_column=8, end_row=line, end_column=13)
row += 1
row += 1
headers = [
'Abschnitt', 'Pos-Nr', 'Faktor', 'Länge', 'Breite', 'Tiefe',
'Menge', 'EH', 'Kurztext', 'Bemerkung', 'Menge', 'EP (€)', 'GP (€)'
]
header_row = row
for col, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=h)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = _tb()
data_last_row = header_row + len(positionen)
if positionen:
ws.auto_filter.ref = f'A{header_row}:M{data_last_row}'
start_row = row
pos_counter = 0
col_widths = [0.0] * 14
for i, pos in enumerate(positionen):
row = start_row + 1 + i
menge = pos.menge if pos.menge else None
menge_hinten = pos.menge_hinten if pos.menge_hinten else None
if pos.einheit in ('ST', 'LE', 'STD', 'h', 'Psch'):
menge = pos.faktor * 1 if pos.faktor else None
l = pos.laenge if pos.laenge else None
b = pos.breite if pos.breite else None
t = pos.tiefe if pos.tiefe else None
ep = pos.einzelpreis if pos.einzelpreis else None
gp = pos.gesamtpreis if pos.gesamtpreis else None
faktor = pos.faktor if pos.faktor else None
ist_trenner = _ist_trenner(pos)
if ist_trenner:
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=13)
for c in range(1, 14):
cell = ws.cell(row=row, column=c)
cell.fill = trenner_fill
cell.border = _tb()
continue
pos_counter += 1
values = [
_val(pos.abschnitt),
pos.pos_nr or None,
faktor,
l, b, t,
menge,
pos.einheit or None,
_val(pos.kurztext),
_val(pos.bemerkung),
menge_hinten,
ep, gp,
]
for col, val in enumerate(values, 1):
cell = ws.cell(row=row, column=col, value=val) if val is not None else ws.cell(row=row, column=col, value='')
cell.font = value_font
cell.border = _tb()
if val is not None:
if col in (7, 11):
cell.number_format = '#,##0.00'
display = '{:,.2f}'.format(val)
elif col in (12, 13):
cell.number_format = '#,##0.00'
display = '{:,.2f}'.format(val)
elif col == 3:
display = '{:.2f}'.format(val) if isinstance(val, float) else str(val)
elif col in (4, 5, 6):
display = '{:.2f}'.format(val) if isinstance(val, float) else str(val)
else:
display = str(val)
else:
display = ''
col_widths[col] = max(col_widths[col], len(display))
sum_row = None
if positionen:
sum_row = start_row + 1 + len(positionen) + 1
ws.merge_cells(start_row=sum_row, start_column=1, end_row=sum_row, end_column=11)
ws.cell(row=sum_row, column=12, value='Summe:').font = Font(name='Calibri', size=11, bold=True)
ws.cell(row=sum_row, column=12).alignment = Alignment(horizontal='right')
gesamt = sum(p.gesamtpreis or 0 for p in positionen if not _ist_trenner(p))
cell = ws.cell(row=sum_row, column=13, value=gesamt)
cell.font = Font(name='Calibri', size=11, bold=True)
cell.number_format = '#,##0.00'
for c in range(1, 14):
ws.cell(row=sum_row, column=c).border = _tb()
if pos_counter > 0:
sum_end = sum_row if sum_row else (start_row + 1 + len(positionen))
summary_start = sum_end + 2
# Title row merged across all 13 cols
for ci in range(1, 14):
ws.cell(row=summary_start, column=ci).border = _tb()
ws.cell(row=summary_start, column=ci).fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0', fill_type='solid')
sc = ws.cell(row=summary_start, column=1, value='Mengen- und Positions-Zusammenfassung')
sc.font = Font(name='Calibri', size=12, bold=True, color='2F5496')
sc.alignment = Alignment(horizontal='center', vertical='center')
ws.merge_cells(start_row=summary_start, start_column=1, end_row=summary_start, end_column=13)
# Summary header borders on 13 cols, gap cols 6-10 merged
shr = summary_start + 1
for ci in range(1, 14):
ws.cell(row=shr, column=ci).border = _tb()
ws.cell(row=shr, column=ci).fill = header_fill
sum_headers = {'Pos-Nr': (1, 1), 'Kurztext': (2, 10), 'Menge': (11, 11), 'EP (€)': (12, 12), 'GP (€)': (13, 13)}
for h, (cs, ce) in sum_headers.items():
cell = ws.cell(row=shr, column=cs, value=h)
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
if ce > cs:
ws.merge_cells(start_row=shr, start_column=cs, end_row=shr, end_column=ce)
ws.merge_cells(start_row=shr, start_column=2, end_row=shr, end_column=10)
groups = defaultdict(lambda: {'kurztext': '', 'menge': 0.0, 'ep': 0.0, 'gp': 0.0})
seen_pos = []
for pos in positionen:
if _ist_trenner(pos) or not pos.pos_nr:
continue
key = pos.pos_nr
if key not in groups:
seen_pos.append(key)
groups[key]['kurztext'] = pos.kurztext or ''
groups[key]['menge'] += pos.menge_hinten if pos.menge_hinten else (pos.menge or 0)
groups[key]['ep'] = pos.einzelpreis or 0
groups[key]['gp'] += pos.gesamtpreis or 0
# Summary data rows borders on 13 cols, gap cols 6-10 merged
r = shr + 1
for key in seen_pos:
g = groups[key]
for ci in range(1, 14):
ws.cell(row=r, column=ci).border = _tb()
vals = [(1, 1, key), (2, 10, g['kurztext']), (11, 11, g['menge']), (12, 12, g['ep']), (13, 13, g['gp'])]
for cs, ce, v in vals:
cell = ws.cell(row=r, column=cs, value=v)
cell.font = value_font
if cs >= 11:
cell.number_format = '#,##0.00'
if ce > cs:
ws.merge_cells(start_row=r, start_column=cs, end_row=r, end_column=ce)
# No gap merge needed since Kurztext goes to column 10
r += 1
# Summary sum row borders on 13 cols, gap cols 1-10 merged
for ci in range(1, 14):
ws.cell(row=r, column=ci).border = _tb()
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=10)
ws.cell(row=r, column=12, value='Summe:').font = Font(name='Calibri', size=10, bold=True)
ws.cell(row=r, column=12).alignment = Alignment(horizontal='right')
total_gp = sum(g['gp'] for g in groups.values())
cell = ws.cell(row=r, column=13, value=total_gp)
cell.font = Font(name='Calibri', size=10, bold=True)
cell.number_format = '#,##0.00'
min_widths = [0, 17, 9, 10, 10, 9, 11, 10, 6, 9, 14, 10, 8, 10]
max_widths = [0, 17, 15, 10, 12, 12, 12, 14, 8, 55, 45, 14, 14, 16]
for i in range(1, 14):
raw = col_widths[i]
w = max(raw * 1.2 + 1, min_widths[i])
w = min(w, max_widths[i])
ws.column_dimensions[get_column_letter(i)].width = w
ws.print_area = f'A1:M{r}'
os.makedirs(os.path.dirname(output_path), exist_ok=True)
wb.save(output_path)
return output_path
@@ -0,0 +1,485 @@
import re, uuid, io
from datetime import datetime
_g_id_counter = 1000001
def _next_id():
global _g_id_counter
rv = _g_id_counter
_g_id_counter += 1
return rv
def _xe(s):
s = s.replace('&', '&amp;')
s = s.replace('<', '&lt;')
s = s.replace('>', '&gt;')
s = s.replace('"', '&quot;')
s = s.replace("'", '&apos;')
return s
def _to_float(s):
if not s:
return 0.0
s = s.strip()
while s and s[-1] in (',', '.'):
s = s[:-1]
s = s.replace(',', '.')
try:
return float(s)
except ValueError:
return 0.0
def _fmt_qty(fval):
fabs = abs(fval)
ganz = int(fabs)
dez = fabs - ganz
d3 = int(dez * 1000 + 0.5)
if d3 >= 1000:
ganz += 1
d3 = 0
vorz = '-' if fval < 0 else ''
if d3 == 0:
return f'{vorz}{ganz}'
sdez = str(d3).zfill(3).rstrip('0')
return f'{vorz}{ganz},{sdez}'
def _oz_info(oz):
result = {'typ': '?', 'e1': '', 'e2': '', 'e3': '', 'pos': oz,
'original': oz, 'len_e1': 0, 'len_e2': 0, 'len_e3': 0, 'len_pos': 0}
if not oz:
return result
if re.match(r'^\d{6,10}$', oz):
result['typ'] = 'C'
result['pos'] = oz
result['len_pos'] = len(oz)
return result
parts = oz.split('.')
n = len(parts)
if n == 3:
result['typ'] = 'A'
result['e1'] = parts[0]
result['e2'] = parts[1]
result['pos'] = parts[2]
result['len_e1'] = len(parts[0])
result['len_e2'] = len(parts[1])
result['len_pos'] = len(parts[2])
elif n == 4:
result['typ'] = 'B'
result['e1'] = parts[0]
result['e2'] = parts[1]
result['e3'] = parts[2]
result['pos'] = parts[3]
result['len_e1'] = len(parts[0])
result['len_e2'] = len(parts[1])
result['len_e3'] = len(parts[2])
result['len_pos'] = len(parts[3])
return result
def _is_valid_oz(oz):
if not oz:
return False
if re.match(r'^\d{1,4}\.\d{1,4}\.\d{1,6}$', oz):
return True
if re.match(r'^\d{1,4}\.\d{1,4}\.\d{1,4}\.\d{1,6}$', oz):
return True
if re.match(r'^\d{6,10}$', oz):
return True
return False
def _reboz_code(idx):
blatt = 1000 + idx // 26
zeile = chr(65 + idx % 26)
return f'{blatt}{zeile}0'
def _k_zeile(ort, oz_code):
ort_pad = (ort + ' ' * 56)[:56]
return f' *{ort_pad}{oz_code} '
def _l_zeile(menge, oz_code):
menge_k = menge.strip().replace('.', ',')
formel = f'100091{menge_k}='
formel = formel[:44].ljust(44)
return f' {formel}{oz_code} '
def _make_qdeterm_pair(ort, qty, oz_cnt):
oz1 = _reboz_code(oz_cnt)
oz_cnt += 1
oz2 = _reboz_code(oz_cnt)
oz_cnt += 1
out = ''
out += '<QDetermItem>'
out += f'<QTakeoff Row="{_xe(_k_zeile(ort, oz1))}"/>'
out += f'<BVBS:Explanation>{_xe(ort.strip())}</BVBS:Explanation>'
out += '</QDetermItem>'
out += '<QDetermItem>'
sqty = _fmt_qty(qty)
out += f'<QTakeoff Row="{_xe(_l_zeile(sqty, oz2))}"/>'
out += '</QDetermItem>'
return out, oz_cnt
def _gen_uuid():
return str(uuid.uuid4()).lower()
def _datum_iso(d):
if d is None:
return ''
if isinstance(d, str):
d = d[:10]
for fmt in ('%Y-%m-%d', '%d.%m.%Y'):
try:
return datetime.strptime(d, fmt).strftime('%Y-%m-%d')
except ValueError:
continue
return d
return d.strftime('%Y-%m-%d')
def export_to_x31(project, aufmass, positionen):
global _g_id_counter
_g_id_counter = 1000001
s_datum = _datum_iso(project.datum) if project.datum else datetime.now().strftime('%Y-%m-%d')
s_baustelle = project.baustelle or ''
s_bauabs = project.bauabschnitt or ''
s_vertrag = project.lv_name or ''
ap_vorname = project.ansprechpartner_vorname or ''
ap_nachname = project.ansprechpartner_nachname or ''
s_askan = f'{ap_vorname} {ap_nachname}'.strip()
s_askatel = project.ansprechpartner_tel or ''
s_iso_datum = _datum_iso(project.datum) if project.datum else datetime.now().strftime('%Y-%m-%d')
s_uid = _gen_uuid()
s_boq_uid = _gen_uuid()
s_lv_name = s_vertrag or f'{s_baustelle} {s_bauabs}'.strip()
s_lv_name_20 = s_lv_name[:20]
s_baust50 = s_baustelle[:50]
pos_data = []
for p in positionen:
if not p.pos_nr or not _is_valid_oz(p.pos_nr.strip()):
continue
qty = p.menge_hinten or 0.0
pos_data.append({
'oz': p.pos_nr.strip(),
'qty': qty,
'ort': p.abschnitt or '',
'beschr': p.kurztext or '',
'bemerk': p.bemerkung or '',
'einh': p.einheit or '',
'ep': p.einzelpreis or 0.0,
})
if not pos_data:
return None
oz_typ = 'A'
max_pos_len = 4
for pd in pos_data:
info = _oz_info(pd['oz'])
if info['typ'] == 'B':
oz_typ = 'B'
if info['typ'] == 'C' and oz_typ == 'A':
oz_typ = 'C'
if info['len_pos'] > max_pos_len:
max_pos_len = info['len_pos']
now = datetime.now()
s_time = now.strftime('%H:%M:%S')
x = '<?xml version="1.0" encoding="UTF-8"?>\n'
x += '<!-- REB 23.003 (2009) - X31 Export AutoIt v8 (Dataflor-kompatibel) -->\n'
x += '<GAEB xmlns="http://www.gaeb.de/GAEB_DA_XML/DA31/3.3" xmlns:BVBS="BVBS">'
x += '<GAEBInfo>'
x += '<Version>3.3</Version>'
x += '<VersDate>2023-01</VersDate>'
x += f'<Date>{s_iso_datum}</Date>'
x += f'<Time>{s_time}</Time>'
x += '<ProgSystem>AutoIt REB Engine V1.2</ProgSystem>'
x += '<ProgName>AutoIt REB X31 Export</ProgName>'
x += '</GAEBInfo>'
x += '<QtyDeterm>'
x += '<PrjInfo>'
x += f'<RefPrjName>{_xe(s_baust50)}</RefPrjName>'
x += f'<RefPrjID>{_xe(s_lv_name_20)}</RefPrjID>'
x += '</PrjInfo>'
x += f'<QtyDetermInfo ID="{s_uid}">'
x += '<MethodDescription>REB23003-2009</MethodDescription>'
x += '</QtyDetermInfo>'
x += '<DP>31</DP>'
x += '<OWN><Address>'
x += f'<Name1>{_xe(s_askan)}</Name1>'
x += '<Name2></Name2><Name3/><Name4/>'
x += '<Street></Street><PCode></PCode><City></City>'
x += f'<Contact/><Phone>{_xe(s_askatel)}</Phone><Fax/><Email/>'
x += '</Address></OWN>'
x += '<CTR><Address><Name1/><Name2></Name2><Name3/><Name4/>'
x += '<Street></Street><PCode></PCode><City></City>'
x += '<Contact/><Phone/><Fax/><Email/></Address></CTR>'
x += f'<BoQ ID="DF_{_next_id()}">'
x += f'<RefBoQName>{_xe(s_lv_name_20)}</RefBoQName>'
x += f'<RefBoQID>{s_boq_uid}</RefBoQID>'
if oz_typ == 'A':
x += '<BoQBkdn><Type>BoQLevel</Type><LblBoQBkdn>Titel</LblBoQBkdn><Length>2</Length><Num>No</Num></BoQBkdn>'
x += '<BoQBkdn><Type>BoQLevel</Type><LblBoQBkdn>Bauteil</LblBoQBkdn><Length>2</Length><Num>No</Num></BoQBkdn>'
x += f'<BoQBkdn><Type>Item</Type><LblBoQBkdn>Position</LblBoQBkdn><Length>{max_pos_len}</Length><Num>No</Num></BoQBkdn>'
x += '<BoQBkdn><Type>Index</Type><LblBoQBkdn>Index</LblBoQBkdn><Length>1</Length><Num>No</Num></BoQBkdn>'
elif oz_typ == 'B':
x += '<BoQBkdn><Type>BoQLevel</Type><LblBoQBkdn>Titel</LblBoQBkdn><Length>1</Length><Num>No</Num></BoQBkdn>'
x += '<BoQBkdn><Type>BoQLevel</Type><LblBoQBkdn>Bauteil</LblBoQBkdn><Length>1</Length><Num>No</Num></BoQBkdn>'
x += '<BoQBkdn><Type>BoQLevel</Type><LblBoQBkdn>Abschnitt</LblBoQBkdn><Length>2</Length><Num>No</Num></BoQBkdn>'
x += f'<BoQBkdn><Type>Item</Type><LblBoQBkdn>Position</LblBoQBkdn><Length>{max_pos_len}</Length><Num>No</Num></BoQBkdn>'
x += '<BoQBkdn><Type>Index</Type><LblBoQBkdn>Index</LblBoQBkdn><Length>1</Length><Num>No</Num></BoQBkdn>'
else:
x += '<BoQBkdn><Type>BoQLevel</Type><LblBoQBkdn>Titel</LblBoQBkdn><Length>2</Length><Num>No</Num></BoQBkdn>'
x += f'<BoQBkdn><Type>Item</Type><LblBoQBkdn>Position</LblBoQBkdn><Length>{max_pos_len}</Length><Num>No</Num></BoQBkdn>'
x += '<BoQBkdn><Type>Index</Type><LblBoQBkdn>Index</LblBoQBkdn><Length>1</Length><Num>No</Num></BoQBkdn>'
x += '<Ctlg><CtlgID>idDIN276_1993</CtlgID>'
x += '<CtlgType>cost group DIN 276-93</CtlgType>'
x += '<CtlgName>DIN 276-93</CtlgName></Ctlg>'
x += '<BoQBody>'
oz_cnt = 0
oz_order = []
seen_keys = set()
for pd in pos_data:
info = _oz_info(pd['oz'])
key = (info['e1'], info['e2'], info['e3'], info['pos'])
if key not in seen_keys:
seen_keys.add(key)
oz_order.append(key)
cur_e1 = ''
cur_e2 = ''
cur_e3 = ''
b_il = False
for oi, (s_e1, s_e2, s_e3, s_pos) in enumerate(oz_order):
same_e1 = (s_e1 == cur_e1)
same_e2 = (s_e2 == cur_e2) and same_e1
same_e3 = (s_e3 == cur_e3) and same_e2
if oz_typ == 'B' and not same_e3 and b_il:
x += '</Itemlist></BoQBody>'
x += '</BoQCtgy>'
b_il = False
cur_e3 = ''
if not same_e2 and b_il:
x += '</Itemlist></BoQBody>'
x += '</BoQCtgy>'
b_il = False
cur_e3 = ''
if oz_typ == 'B' and not same_e2 and cur_e2 != '':
x += '</BoQBody>'
x += '</BoQCtgy>'
cur_e2 = ''
cur_e3 = ''
if not same_e1 and cur_e1 != '':
x += '</BoQBody>'
x += '</BoQCtgy>'
cur_e1 = ''
if s_e1 != cur_e1:
x += f'<BoQCtgy RNoPart="{_xe(s_e1)}" ID="DF_{_next_id()}">'
x += '<BoQBody>'
cur_e1 = s_e1
if oz_typ == 'B' and s_e2 != cur_e2:
x += f'<BoQCtgy RNoPart="{_xe(s_e2)}" ID="DF_{_next_id()}">'
x += '<BoQBody>'
cur_e2 = s_e2
cur_e3 = ''
if oz_typ != 'B':
cur_e2 = s_e2
if oz_typ == 'B':
cur_e3 = s_e3
if not b_il:
if oz_typ == 'B':
x += f'<BoQCtgy RNoPart="{_xe(cur_e3)}" ID="DF_{_next_id()}">'
else:
x += f'<BoQCtgy RNoPart="{_xe(cur_e2)}" ID="DF_{_next_id()}">'
x += '<BoQBody><Itemlist>'
b_il = True
f_qty_sum = 0.0
for pd in pos_data:
ick = _oz_info(pd['oz'])
if ick['e1'] != s_e1 or ick['e2'] != s_e2:
continue
if oz_typ == 'B' and ick['e3'] != s_e3:
continue
if ick['pos'] != s_pos:
continue
f_qty_sum += pd['qty']
x += f'<Item ID="DF_{_next_id()}" RNoPart="{_xe(s_pos)}">'
x += '<QtyDeterm>'
x += f'<Qty>{_fmt_qty(f_qty_sum).replace(",", ".")}</Qty>'
for pd in pos_data:
ian = _oz_info(pd['oz'])
if ian['e1'] != s_e1 or ian['e2'] != s_e2:
continue
if oz_typ == 'B' and ian['e3'] != s_e3:
continue
if ian['pos'] != s_pos:
continue
pair, oz_cnt = _make_qdeterm_pair(pd['ort'], pd['qty'], oz_cnt)
x += pair
x += '</QtyDeterm>'
x += '</Item>'
if b_il:
x += '</Itemlist></BoQBody>'
x += '</BoQCtgy>'
if oz_typ == 'B' and cur_e2 != '':
x += '</BoQBody>'
x += '</BoQCtgy>'
if cur_e1 != '':
x += '</BoQBody>'
x += '</BoQCtgy>'
x += '</BoQBody>'
x += '</BoQ>'
x += '</QtyDeterm>'
x += '</GAEB>'
bom = b'\xef\xbb\xbf'
return bom + x.encode('utf-8')
def convert_to_california(xml_bytes, ref_prj_name='', ref_prj_id='', owner_name=''):
content = xml_bytes.decode('utf-8')
if content.startswith('\ufeff'):
content = content[1:]
content = content.replace('\r\n', '\n')
content = content.replace('\r', '\n')
content = content.replace('\n', '\r\n')
m = re.search(r'(<Street>)([\s\S]*?)(</Street>)', content)
if m:
street_alt = m.group(0)
street_neu = m.group(1) + m.group(2).replace('\r\n', '&#10;') + m.group(3)
content = content.replace(street_alt, street_neu)
content = re.sub(r'<!--[\s\S]*?-->', '<!-- REB 23.003 (2009) - X31 Export AutoIt v5 -->', content)
heute = datetime.now().strftime('%Y-%m-%d')
zeit = datetime.now().strftime('%H:%M:%S')
content = re.sub(r'(<Date>)[^<]*(</Date>)', rf'\g<1>{heute}\g<2>', content)
content = re.sub(r'(<Time>)[^<]*(</Time>)', rf'\g<1>{zeit}\g<2>', content)
content = re.sub(r'(<ProgSystem>)[^<]*(</ProgSystem>)', r'\g<1>Python REB Engine V1.2\g<2>', content)
content = re.sub(r'(<ProgName>)[^<]*(</ProgName>)', r'\g<1>Python REB X31 Export\g<2>', content)
if ref_prj_name:
content = re.sub(r'(<RefPrjName>)[^<]*(</RefPrjName>)', rf'\g<1>{_xe(ref_prj_name)}\g<2>', content)
if ref_prj_id:
content = re.sub(r'(<RefPrjID>)[^<]*(</RefPrjID>)', rf'\g<1>{_xe(ref_prj_id)}\g<2>', content)
neue_guid = str(uuid.uuid4()).lower()
content = re.sub(
r'(QtyDetermInfo\s+ID=")[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(")',
rf'\g<1>{neue_guid}\g<2>',
content
)
if owner_name:
m_own = re.search(r'(<OWN>[\s\S]*?</OWN>)', content)
if m_own:
own_alt = m_own.group(1)
own_neu = re.sub(r'(<Name1>)[^<]*(</Name1>)', rf'\g<1>{_xe(owner_name)}\g<2>', own_alt)
content = content.replace(own_alt, own_neu)
content = re.sub(r'\s*<CtlgAssignType\s*/>', '', content)
content = re.sub(r'\s*<CtlgAssignType>[^<]*</CtlgAssignType>', '', content)
content = re.sub(r'<BVBS:Explanation>[^<]*</BVBS:Explanation>', '', content)
imax = 30
while imax > 0:
vorher = content
content = re.sub(
r'<Item\s[^>]+>\s*<QtyDeterm>\s*<Qty>0[,.]000</Qty>\s*</QtyDeterm>\s*</Item>',
'',
content
)
if content == vorher:
break
imax -= 1
imax = 10
while imax > 0:
vorher = content
content = re.sub(r'<Itemlist>\s*</Itemlist>', '', content)
content = re.sub(r'<BoQBody>\s*</BoQBody>', '', content)
content = re.sub(r'<BoQCtgy[^>]*>\s*</BoQCtgy>', '', content)
if content == vorher:
break
imax -= 1
content = _renumber_ids(content)
content = _renumber_zeilen_ids(content)
content = _format_xml(content)
bom = b'\xef\xbb\xbf'
return bom + content.encode('utf-8')
def _renumber_ids(xml_str):
all_ids = re.findall(r'ID="(DF_\d+)"', xml_str)
if not all_ids:
return xml_str
unique = sorted(set(all_ids))
next_id = 1000001
for old_id in unique:
new_id = f'DF_{next_id}'
next_id += 1
xml_str = xml_str.replace(f'ID="{old_id}"', f'ID="{new_id}"')
return xml_str
def _renumber_zeilen_ids(xml_str):
blatt = 1
pos = 0
while True:
m = re.search(r'QTakeoff Row="', xml_str[pos:])
if not m:
break
i_start = pos + m.start()
i_row_start = i_start + 14
i_row_end = xml_str.index('"', i_row_start)
row_len = i_row_end - i_row_start
if row_len == 80:
neue_id = f'{blatt:04d}A0'
row_old = xml_str[i_row_start:i_row_end]
row_new = row_old[:69] + neue_id + row_old[75:]
xml_str = xml_str[:i_row_start] + row_new + xml_str[i_row_end:]
blatt += 1
pos = i_row_start + 1
return xml_str
def _format_xml(xml_str):
xml_str = re.sub(r'>\s+<', '>\r\n<', xml_str)
xml_str = xml_str.replace('<BoQBody>', '<BoQBody>\r\n')
xml_str = xml_str.replace('<Itemlist>', '<Itemlist>\r\n')
xml_str = re.sub(r'(<BoQCtgy[^>]*>)', r'\g<1>\r\n', xml_str)
xml_str = re.sub(r'(<Item [^>]*>)', r'\g<1>\r\n', xml_str)
close_tags = ['</BoQBody>', '</Itemlist>', '</BoQCtgy>', '</Item>', '</QtyDeterm>']
for tag in close_tags:
xml_str = xml_str.replace(tag, '\r\n' + tag + '\r\n')
xml_str = xml_str.replace('<QtyDeterm>', '\r\n<QtyDeterm>\r\n')
while '\r\n\r\n\r\n' in xml_str:
xml_str = xml_str.replace('\r\n\r\n\r\n', '\r\n\r\n')
while xml_str.startswith('\r\n'):
xml_str = xml_str[2:]
return xml_str
@@ -0,0 +1,92 @@
"""Mathematical expression evaluator with precedence (Punkt vor Strich).
Supports +, -, *, /, (, ), and decimal numbers. Safe no eval()."""
import re
from decimal import Decimal, InvalidOperation
TOKEN_NUM = 'NUM'
TOKEN_PLUS = '+'
TOKEN_MINUS = '-'
TOKEN_MUL = '*'
TOKEN_DIV = '/'
TOKEN_LPAR = '('
TOKEN_RPAR = ')'
def tokenize(expr):
tokens = []
i = 0
while i < len(expr):
c = expr[i]
if c in ' \t':
i += 1
continue
if c in '+-*/()':
tokens.append(c)
i += 1
elif c.isdigit() or c == '.':
start = i
had_dot = False
while i < len(expr) and (expr[i].isdigit() or (expr[i] == '.' and not had_dot)):
if expr[i] == '.':
had_dot = True
i += 1
tokens.append(('NUM', expr[start:i]))
else:
raise ValueError(f"Ungültiges Zeichen: '{c}'")
return tokens
def parse_primary(tokens, pos):
if pos >= len(tokens):
raise ValueError("Erwarte Zahl oder (")
tok = tokens[pos]
if isinstance(tok, tuple) and tok[0] == 'NUM':
return Decimal(tok[1]), pos + 1
if tok == '(':
val, pos = parse_expr(tokens, pos + 1)
if pos >= len(tokens) or tokens[pos] != ')':
raise ValueError("Fehlende schließende Klammer")
return val, pos + 1
raise ValueError(f"Unerwartetes Token: {tok}")
def parse_mul(tokens, pos):
val, pos = parse_primary(tokens, pos)
while pos < len(tokens):
tok = tokens[pos]
if tok in ('*', '/'):
right, pos = parse_primary(tokens, pos + 1)
if tok == '*':
val *= right
else:
if right == 0:
raise ValueError("Division durch Null")
val /= right
else:
break
return val, pos
def parse_expr(tokens, pos):
val, pos = parse_mul(tokens, pos)
while pos < len(tokens):
tok = tokens[pos]
if tok in ('+', '-'):
right, pos = parse_mul(tokens, pos + 1)
if tok == '+':
val += right
else:
val -= right
else:
break
return val, pos
def berechne_formel(expr_str):
if not expr_str or not expr_str.strip():
return 0
# Deutsche Dezimaltrenner ersetzen
expr_str = expr_str.replace(',', '.')
tokens = tokenize(expr_str.strip())
if not tokens:
return 0
val, pos = parse_expr(tokens, 0)
if pos != len(tokens):
raise ValueError("Überflüssige Zeichen am Ende")
return float(val)
@@ -0,0 +1,90 @@
from app.extensions import db
from app.models.license import License, LicenseModule
from app.models.module import Module
def check_company_module_access(company_id, module_name):
"""Prüft, ob eine Firma ein Modul nutzen darf (License + CompanyModule)."""
module = Module.query.filter_by(name=module_name).first()
if not module:
return False
if module.standard:
return True
license = License.query.filter_by(company_id=company_id, aktiv=True).first()
if license:
if LicenseModule.query.filter_by(
license_id=license.id, module_id=module.id, aktiv=True
).first():
return True
from app.models.company_module import CompanyModule
if CompanyModule.query.filter_by(company_id=company_id, module_id=module.id, aktiv=True).first():
return True
return False
def check_module_access(company_id, module_name):
"""Prüft, ob eine Firma ein Modul nutzen darf (ohne User-Check)."""
return check_company_module_access(company_id, module_name)
def check_user_module_access(user, module_name):
"""Prüft, ob ein bestimmter Benutzer ein Modul nutzen darf."""
if user.is_superadmin():
return True
module = Module.query.filter_by(name=module_name).first()
if not module:
return False
if module.standard:
return True
if not check_company_module_access(user.company_id, module_name):
return False
if user.is_firmadmin():
return True
from app.models.user_module import UserModulePermission
if UserModulePermission.query.filter_by(user_id=user.id, module_id=module.id, aktiv=True).first():
return True
return False
def get_aktive_module(company_id, user=None):
"""Liefert alle Module, die die Firma (bzw. der Benutzer) nutzen darf."""
all_modules = Module.query.order_by(Module.sortierung).all()
license = License.query.filter_by(company_id=company_id, aktiv=True).first()
aktiv_module_ids = set()
if license:
for lm in LicenseModule.query.filter_by(license_id=license.id, aktiv=True).all():
aktiv_module_ids.add(lm.module_id)
from app.models.company_module import CompanyModule
company_module_ids = {
cm.module_id for cm in CompanyModule.query.filter_by(company_id=company_id, aktiv=True).all()
}
from app.models.user_module import UserModulePermission
user_module_ids = set()
if user and not user.is_firmadmin():
user_module_ids = {
um.module_id for um in UserModulePermission.query.filter_by(user_id=user.id, aktiv=True).all()
}
result = []
for m in all_modules:
if m.standard:
result.append(m)
elif m.id in aktiv_module_ids:
result.append(m)
elif m.id in company_module_ids:
if user is None or user.is_firmadmin():
result.append(m)
elif m.id in user_module_ids:
result.append(m)
return result