from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, json from flask_login import login_required, current_user from app.extensions import db from app.models.project import Project from app.models.company import Company from app.models.aufmass import Aufmass from app.models.aufmass_typ import AufmassTyp from app.models.position import Position from app.models.lv import LVPosition from app.models.contract import Contract from app.models.project_access import ProjectAccess from app.models.user import User from app.models.aufmass_history import AufmassHistory from app.services.license_service import get_aktive_module from app.models.custom_module import CustomModule, CustomModuleAssignment from datetime import datetime aufmass_bp = Blueprint('aufmass', __name__) def _check_zugriff(project, required='lesen'): if not current_user.hat_zugriff(project, required): return False return True def _get_aufmass_or_404(aufmass_id, project_id): a = Aufmass.query.get_or_404(aufmass_id) if a.project_id != project_id: return None return a def _build_history_description(action_type, pos_data_list, user_name=''): """Baut detaillierte History-Beschreibung. Args: action_type: 'add', 'delete', 'edit', 'move', 'copy' pos_data_list: Liste von Dict mit position_id, pos_nr, kurztext, old, new user_name: Name des Benutzers Returns: Tuple (kurze_beschreibung, lange_beschreibung) """ from datetime import datetime now = datetime.now().strftime('%d.%m.%Y %H:%M') def _format_field_diff(old_val, new_val, field): """Formatiert Unterschied für ein Feld.""" old_str = str(old_val) if old_val is not None else 'leer' new_str = str(new_val) if new_val is not None else 'leer' if old_str == new_str: return None return f"{field}: {old_str} → {new_str}" if not pos_data_list: return ('', '') short_parts = [] long_parts = [] for pd in pos_data_list: pos_label = f"Zeile ?, Pos {pd.get('pos_nr', pd.get('position_id', '?'))}" row_idx = pd.get('row_idx', 0) + 1 if pd.get('row_idx') is not None else '?' kurztext = pd.get('kurztext', '')[:30] if kurztext: kurztext = f'"{kurztext}"' if action_type == 'add': short_parts.append(f"+ Pos {pd.get('pos_nr', '?')} ({kurztext})") long_parts.append(f"+ Zeile {row_idx}: Pos {pd.get('pos_nr', '?')} {kurztext}") elif action_type == 'delete': short_parts.append(f"- Pos {pd.get('pos_nr', '?')} ({kurztext})") long_parts.append(f"- Zeile {row_idx}: Pos {pd.get('pos_nr', '?')} {kurztext}") elif action_type == 'edit': old_vals = pd.get('old', {}) new_vals = pd.get('new', {}) field_labels = { 'faktor': 'Faktor', 'laenge': 'Länge', 'breite': 'Breite', 'tiefe': 'Tiefe', 'menge': 'Menge', 'menge_hinten': 'Menge hinten', 'einheit': 'EH', 'abschnitt': 'Abschnitt', 'bemerkung': 'Bemerkung', 'formel': 'Formel', 'formel_typ': 'Z-Art', 'kurztext': 'Kurztext' } changes = [] for field in set(list(old_vals.keys()) + list(new_vals.keys())): if field.startswith('_') or field == 'id': continue label = field_labels.get(field, field) old_v = old_vals.get(field) new_v = new_vals.get(field) diff = _format_field_diff(old_v, new_v, label) if diff: changes.append(diff) if changes: short_parts.append(f"✎ Pos {pd.get('pos_nr', '?')}: {', '.join(changes[:2])}") long_parts.append(f"✎ Zeile {row_idx}: Pos {pd.get('pos_nr', '?')} {kurztext} - {'; '.join(changes)}") elif action_type == 'move': old_idx = pd.get('old_idx', pd.get('old', {}).get('sort_order', '?')) new_idx = pd.get('new_idx', pd.get('new', {}).get('sort_order', '?')) if old_idx != new_idx: direction = '↓' if new_idx > old_idx else '↑' short_parts.append(f"↔ Pos {pd.get('pos_nr', '?')}: {old_idx} {direction} {new_idx}") long_parts.append(f"↔ Zeile {old_idx}→{new_idx}: Pos {pd.get('pos_nr', '?')} {kurztext} {direction}") elif action_type == 'copy': short_parts.append(f"⎘ Pos {pd.get('pos_nr', '?')} kopiert") long_parts.append(f"⎘ Zeile {row_idx}: Pos {pd.get('pos_nr', '?')} {kurztext} kopiert") short = ', '.join(short_parts[:3]) if len(short_parts) > 3: short += f' (+{len(short_parts)-3})' long = f"[{now}] {user_name}\n" + '\n'.join(long_parts) return (short, long) # === Projekt-Liste (alle Aufmaß-Projekte) === @aufmass_bp.route('/') @login_required def index(): if current_user.is_firmadmin(): projekte = Project.query.filter_by( company_id=current_user.company_id ).order_by(db.func.coalesce(Project.bezeichnung, '').asc()).all() elif current_user.is_superadmin(): projekte = Project.query.order_by(db.func.coalesce(Project.bezeichnung, '').asc()).all() else: zugriff_ids = db.session.query(ProjectAccess.project_id).filter_by( user_id=current_user.id ).subquery() projekte = Project.query.filter(Project.id.in_(zugriff_ids)).order_by(db.func.coalesce(Project.bezeichnung, '').asc()).all() preise_sichtbar = current_user.is_superadmin() or current_user.is_firmadmin() or current_user.darf_preise_sehen data = [] from app.models.position import Position gesamt_summe = 0.0 gesamt_positionen = 0 for p in projekte: aufmass_liste = Aufmass.query.filter_by(project_id=p.id).order_by(Aufmass.sortierung).all() projekt_summe = 0.0 projekt_positionen = 0 aufmass_data = [] for a in aufmass_liste: summe = db.session.query(db.func.coalesce(db.func.sum(Position.gesamtpreis), 0)).filter( Position.aufmass_id == a.id ).scalar() or 0.0 projekt_summe += summe anzahl = Position.query.filter_by(aufmass_id=a.id).count() projekt_positionen += anzahl aufmass_data.append({'aufmass': a, 'summe': summe, 'positionen': anzahl}) gesamt_summe += projekt_summe gesamt_positionen += projekt_positionen data.append({'project': p, 'aufmass_liste': aufmass_data, 'summe': projekt_summe, 'positionen': projekt_positionen}) typen = AufmassTyp.query.order_by(AufmassTyp.sortierung).all() if current_user.company_id: contracts = Contract.query.filter_by( company_id=current_user.company_id ).order_by(Contract.name).all() company = Company.query.get(current_user.company_id) else: contracts = Contract.query.order_by(Contract.name).all() company = None return render_template('aufmass/index.html', projekte=data, titel='Aufmaß-Projekte', gesamt_summe=gesamt_summe, gesamt_positionen=gesamt_positionen, preise_sichtbar=preise_sichtbar, typen=typen, contracts=contracts, company=company) @aufmass_bp.route('/neu', methods=['GET', 'POST']) @login_required def neu(): if not current_user.is_firmadmin() and not current_user.darf_projekte_anlegen: flash('Keine Berechtigung.', 'danger') return redirect(url_for('aufmass.index')) contracts = Contract.query.filter_by( company_id=current_user.company_id ).order_by(Contract.name).all() if request.method == 'POST': contract_id = request.form.get('contract_id', type=int) contract = Contract.query.get(contract_id) if contract_id else None bezeichnung = request.form.get('bezeichnung', '').strip() project = Project( company_id=current_user.company_id, contract_id=contract_id, sm_nr='', bezeichnung=bezeichnung, vertrag=contract.name if contract else request.form.get('vertrag', '').strip(), lv_name=request.form.get('lv_name', '').strip(), status='aktiv', erstellt_von=current_user.id, ) db.session.add(project) db.session.commit() flash(f'Projekt "{bezeichnung or project.id}" angelegt.', 'success') return redirect(url_for('aufmass.aufmass_list', project_id=project.id)) return render_template('aufmass/neu.html', contracts=contracts, titel='Neues Aufmaß') # === Aufmaß-Liste innerhalb eines Projekts === @aufmass_bp.route('/') @login_required def aufmass_list(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'lesen'): return 'Zugriff verweigert', 403 aufmass_liste = Aufmass.query.filter_by(project_id=project_id).order_by(Aufmass.sortierung).all() typen = AufmassTyp.query.order_by(AufmassTyp.sortierung).all() contracts = Contract.query.filter_by(company_id=project.company_id).order_by(Contract.name).all() preise_sichtbar = current_user.is_superadmin() or current_user.is_firmadmin() or current_user.darf_preise_sehen # Preise pro Aufmaß berechnen from app.models.position import Position aufmass_preise = {} for a in aufmass_liste: summe = db.session.query(db.func.coalesce(db.func.sum(Position.gesamtpreis), 0)).filter( Position.aufmass_id == a.id ).scalar() or 0.0 aufmass_preise[a.id] = summe lv_names = [r[0] for r in db.session.query(LVPosition.lv_name).filter_by( company_id=project.company_id ).distinct().order_by(LVPosition.lv_name).all()] return render_template('aufmass/list.html', project=project, aufmass_liste=aufmass_liste, typen=typen, preise_sichtbar=preise_sichtbar, aufmass_preise=aufmass_preise, contracts=contracts, lv_names=lv_names, company=current_user.company, titel=f'Aufmaße – {project.bezeichnung or project.sm_nr}') # === Aufmaß CRUD === @aufmass_bp.route('//aufmass/neu', methods=['POST']) @login_required def aufmass_neu(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 name = request.form.get('name', '').strip() or 'Neues Aufmaß' typ = request.form.get('typ', '').strip() max_sort = db.session.query(db.func.max(Aufmass.sortierung)).filter_by(project_id=project_id).scalar() or 0 a = Aufmass(project_id=project_id, name=name, typ=typ, sortierung=max_sort + 1, erstellt_von=current_user.id) db.session.add(a) db.session.commit() flash(f'Aufmaß "{name}" angelegt.', 'success') return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) @aufmass_bp.route('//aufmass/neu-voll', methods=['POST']) @login_required def aufmass_neu_voll(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 contract_id = request.form.get('contract_id', type=int) contract = Contract.query.get(contract_id) if contract_id else None bauabschnitt = request.form.get('bauabschnitt', '').strip() import re invalid_chars = r'[<>:"/\\|?*&#%{}~\[\]]' if re.search(invalid_chars, bauabschnitt): flash('Bauabschnitt enthält ungültige Zeichen.', 'danger') return redirect(url_for('aufmass.index')) project.baustelle = request.form.get('baustelle', '').strip() project.sm_nr = request.form.get('sm_nr', '').strip() project.vertrag = contract.name if contract else request.form.get('vertrag', '').strip() project.abruf_nr = request.form.get('abruf_nr', '').strip() project.lv_name = request.form.get('lv_name', '').strip() project.datum_start = _parse_date(request.form.get('datum_start')) project.datum_ende = _parse_date(request.form.get('datum_ende')) project.datum = _parse_date(request.form.get('datum')) project.ev_details_id = request.form.get('ev_details_id', '').strip() project.ansprechpartner_vorname = request.form.get('ansprechpartner_vorname', '').strip() project.ansprechpartner_nachname = request.form.get('ansprechpartner_nachname', '').strip() project.ansprechpartner_tel = request.form.get('ansprechpartner_tel', '').strip() project.ansprechpartner_email = request.form.get('ansprechpartner_email', '').strip() project.bauabschnitt = bauabschnitt typ = request.form.get('typ', '').strip() name_parts = [s for s in [project.bezeichnung, project.bauabschnitt, project.sm_nr, project.abruf_nr] if s] name = ' - '.join(name_parts) if name_parts else 'Neues Aufmass' name = re.sub(r'\s+', ' ', name).strip() max_sort = db.session.query(db.func.max(Aufmass.sortierung)).filter_by(project_id=project_id).scalar() or 0 a = Aufmass(project_id=project_id, name=name, typ=typ, sortierung=max_sort + 1, erstellt_von=current_user.id) db.session.add(a) db.session.commit() flash(f'Aufmaß "{name}" für "{project.bezeichnung or project.sm_nr}" angelegt.', 'success') return redirect(url_for('aufmass.index')) @aufmass_bp.route('//aufmass/import', methods=['POST']) @login_required def aufmass_import(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 if 'file' not in request.files: flash('Keine Datei ausgewählt.', 'danger') return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) f = request.files['file'] if f.filename == '': flash('Keine Datei ausgewählt.', 'danger') return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) try: content = f.read().decode('utf-8-sig').splitlines() except Exception: content = f.read().decode('latin-1').splitlines() lines = [l.strip() for l in content if l.strip()] if len(lines) < 16 or lines[0] != '[Kopfdaten]': flash('Ungültiges Dateiformat.', 'danger') return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) def kv(val): return val.split('=', 1)[1] if '=' in val else '' teilaufma = kv(lines[1]) schlussaufma = kv(lines[2]) datum_str = kv(lines[3]) baustelle = kv(lines[4]) abruf_nr = kv(lines[5]) sm_nr = kv(lines[6]) startz_str = kv(lines[8]) endz_str = kv(lines[9]) aspa_name = kv(lines[10]) aspa_tel = kv(lines[11]) bauabschnitt = kv(lines[12]) # Typ ermitteln typ = '' if schlussaufma == 'X': typ = 'Schlussaufmaß' elif teilaufma == 'X': typ = 'Teilaufmaß/AZ' aspa_vorname = aspa_name aspa_nachname = '' if ' ' in aspa_name.strip(): p = aspa_name.strip().rsplit(' ', 1) aspa_vorname = p[0] aspa_nachname = p[1] project.baustelle = baustelle or project.baustelle project.bauabschnitt = bauabschnitt or project.bauabschnitt project.abruf_nr = abruf_nr or project.abruf_nr project.sm_nr = sm_nr or project.sm_nr project.datum = _parse_date(datum_str) or project.datum project.datum_start = _parse_date(startz_str) or project.datum_start project.datum_ende = _parse_date(endz_str) or project.datum_ende project.ansprechpartner_vorname = aspa_vorname or project.ansprechpartner_vorname project.ansprechpartner_nachname = aspa_nachname or project.ansprechpartner_nachname project.ansprechpartner_tel = aspa_tel or project.ansprechpartner_tel import re _tofloat = lambda s: float(s.replace(',', '.')) if s else 0.0 positions_data = [] errors = [] in_data = False for line in lines: if line == '[Aufmaßdaten]': in_data = True continue if in_data and '|' in line: cols = line.split('|') if len(cols) < 13: continue pos_nr = cols[1].strip() faktor = _tofloat(cols[2]) laenge = _tofloat(cols[3]) breite = _tofloat(cols[4]) tiefe = _tofloat(cols[5]) menge_aus_cols = _tofloat(cols[6]) einheit = cols[7].strip() or 'ST' kurztext = cols[8].strip() bemerkung = cols[9].strip() menge2 = _tofloat(cols[10]) einzelpreis = _tofloat(cols[11]) gesamtpreis = _tofloat(cols[12]) if not pos_nr: # Separatorzeilen (nur Pipes) leise überspringen if not any(cols[i].strip() for i in range(1, len(cols))): continue errors.append('Position ohne Nummer übersprungen.') continue if not kurztext: errors.append(f'Position {pos_nr}: Kein Kurztext.') menge = menge_aus_cols or menge2 if menge == 0.0 and einheit in ('M', 'M2', 'M3'): if einheit == 'M': menge = laenge elif einheit == 'M2': menge = laenge * breite elif einheit == 'M3': menge = laenge * breite * tiefe menge *= faktor else: menge *= faktor berechneter_gp = round(menge * einzelpreis, 2) if gesamtpreis > 0 and abs(berechneter_gp - gesamtpreis) > 0.05: errors.append(f'Position {pos_nr}: GP-Differenz {berechneter_gp} vs {gesamtpreis}') positions_data.append({ 'pos_nr': pos_nr, 'kurztext': kurztext, 'einheit': einheit, 'einzelpreis': einzelpreis, 'menge': menge, 'gesamtpreis': gesamtpreis or berechneter_gp, 'laenge': laenge, 'breite': breite, 'tiefe': tiefe, 'faktor': faktor, 'bemerkung': bemerkung, }) if not positions_data: flash('Keine gültigen Positionen in der Datei gefunden.', 'warning') return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) # Aufmaß-Name name_parts = [s for s in [baustelle or project.bezeichnung, bauabschnitt, sm_nr, abruf_nr] if s] name = ' - '.join(name_parts) if name_parts else 'Importiertes Aufmaß' name = re.sub(r'\s+', ' ', name).strip() name = re.sub(r'[<>:"/\\|?*&#%{}~\[\]]', '', name) # Doppelten Namen vermeiden existing = {n[0] for n in db.session.query(Aufmass.name).filter_by(project_id=project_id).all()} if name in existing: i = 1 while f'{name} ({i})' in existing: i += 1 name = f'{name} ({i})' max_sort = db.session.query(db.func.max(Aufmass.sortierung)).filter_by(project_id=project_id).scalar() or 0 a = Aufmass(project_id=project_id, name=name, typ=typ, sortierung=max_sort + 1, erstellt_von=current_user.id) db.session.add(a) db.session.flush() for idx, pd in enumerate(positions_data): pos = Position( project_id=project_id, aufmass_id=a.id, pos_nr=pd['pos_nr'], sortierung=idx + 1, kurztext=pd['kurztext'], einheit=pd['einheit'] or 'ST', einzelpreis=pd['einzelpreis'], menge=pd['menge'], faktor=pd['faktor'], laenge=pd['laenge'], breite=pd['breite'], tiefe=pd['tiefe'], gesamtpreis=pd['gesamtpreis'], formel_typ='standard', bemerkung=pd['bemerkung'], ) pos.menge_hinten = pos.faktor * pos.menge db.session.add(pos) db.session.commit() msg = f'Aufmaß "{name}" mit {len(positions_data)} Positionen importiert.' flash(msg, 'success') ref = request.referrer or '' if ref.rstrip('/').endswith('/projekt'): return redirect(url_for('aufmass.index')) return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) @aufmass_bp.route('///positionen/import-txt', methods=['POST']) @login_required def positionen_import_txt(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: flash('Aufmaß nicht gefunden.', 'danger') return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id)) if 'file' not in request.files: flash('Keine Datei ausgewählt.', 'danger') return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id)) f = request.files['file'] if f.filename == '': flash('Keine Datei ausgewählt.', 'danger') return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id)) try: content = f.read().decode('utf-8-sig').splitlines() except Exception: content = f.read().decode('latin-1').splitlines() lines = [l.strip() for l in content if l.strip()] import re _tofloat = lambda s: float(s.replace(',', '.')) if s else 0.0 max_sort = db.session.query(db.func.max(Position.sortierung)).filter_by(aufmass_id=aufmass_id).scalar() or 0 positions_added = 0 errors = [] in_data = False for line in lines: if line == '[Aufmaßdaten]': in_data = True continue if in_data and '|' in line: cols = line.split('|') if len(cols) < 13: continue pos_nr = cols[1].strip() faktor = _tofloat(cols[2]) laenge = _tofloat(cols[3]) breite = _tofloat(cols[4]) tiefe = _tofloat(cols[5]) menge_aus_cols = _tofloat(cols[6]) einheit = cols[7].strip() or 'ST' kurztext = cols[8].strip() bemerkung = cols[9].strip() menge2 = _tofloat(cols[10]) einzelpreis = _tofloat(cols[11]) gesamtpreis = _tofloat(cols[12]) if not pos_nr: continue menge = menge_aus_cols or menge2 if menge == 0.0 and einheit in ('M', 'M2', 'M3'): if einheit == 'M': menge = laenge elif einheit == 'M2': menge = laenge * breite elif einheit == 'M3': menge = laenge * breite * tiefe menge *= faktor else: menge *= faktor max_sort += 1 pos = Position( project_id=project_id, aufmass_id=aufmass_id, pos_nr=pos_nr, sortierung=max_sort, kurztext=kurztext, einheit=einheit or 'ST', einzelpreis=einzelpreis, menge=menge, faktor=faktor, laenge=laenge, breite=breite, tiefe=tiefe, gesamtpreis=gesamtpreis or round(menge * einzelpreis, 2), formel_typ='standard', bemerkung=bemerkung, ) pos.menge_hinten = pos.faktor * pos.menge db.session.add(pos) positions_added += 1 db.session.commit() msg = f'{positions_added} Positionen importiert.' if errors: msg += ' Hinweise: ' + '; '.join(errors[:3]) flash(msg, 'success' if not errors else 'warning') return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id)) @aufmass_bp.route('//kopfdaten-ev-holen', methods=['POST']) @login_required def kopfdaten_ev_holen(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 if not current_user.darf_evergabe_nutzen or not current_user.darf_kopfdaten_holen: return jsonify({'error': 'Keine Berechtigung'}), 403 company = Company.query.get(current_user.company_id) if not company or not company.evergabe_aktiviert: return jsonify({'error': 'E-Vergabe nicht freigeschaltet'}), 403 if not company.evergabe_benutzer or not company.evergabe_passwort: return jsonify({'error': 'Keine Logindaten hinterlegt'}), 400 data = request.get_json(silent=True) or {} sm_nr = data.get('sm_nr', '').strip() if not sm_nr: sm_nr = project.sm_nr.strip() if not sm_nr: return jsonify({'error': 'Keine SM-Nr angegeben'}), 400 try: from app.services.evergabe_service import EVergabeClient client = EVergabeClient( username=company.evergabe_benutzer, password=company.evergabe_passwort, name=company.evergabe_name or '' ) ev_data = client.hole_kopfdaten(sm_nr) import logging logging.getLogger(__name__).info(f'EV-Daten: {ev_data}') def _convert_date(d): if not d: return '' parts = d.strip().split('.') if len(parts) == 3: return f'{parts[2]}-{parts[1]}-{parts[0]}' return d ap_name = ev_data.get('ansprechpartner_name', '') ap_parts = ap_name.split() ap_vorname = ap_parts[0] if len(ap_parts) > 1 else '' ap_nachname = ' '.join(ap_parts[1:]) if len(ap_parts) > 1 else ap_name return jsonify({ 'bezeichnung': ev_data.get('bezeichnung', ''), 'bauabschnitt': '', 'sm_nr': ev_data.get('sm_nr', ''), 'abruf_nr': ev_data.get('abruf_nr', ''), 'datum_start': _convert_date(ev_data.get('beleg_eingang', '')), 'datum_ende': _convert_date(ev_data.get('ausfuehrungsfrist', '')), 'datum': _convert_date(ev_data.get('beleg_eingang', '')), 'ev_details_id': ev_data.get('detail_id', ''), 'ansprechpartner_vorname': ap_vorname, 'ansprechpartner_nachname': ap_nachname, 'ansprechpartner_tel': ev_data.get('ansprechpartner_tel', ''), 'ansprechpartner_email': ev_data.get('ansprechpartner_email', ''), }) except Exception as e: return jsonify({'error': str(e)}), 500 @aufmass_bp.route('//aufmass//evergabe-uebertragen', methods=['POST']) @login_required def aufmass_evergabe_uebertragen(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 if not current_user.darf_evergabe_nutzen or not current_user.darf_aufmass_uebertragen: return jsonify({'error': 'Keine Berechtigung'}), 403 company = Company.query.get(current_user.company_id) if not company or not company.evergabe_aktiviert: return jsonify({'error': 'E-Vergabe nicht freigeschaltet'}), 403 if not company.evergabe_benutzer or not company.evergabe_passwort: return jsonify({'error': 'Keine Logindaten hinterlegt'}), 400 aufmass = Aufmass.query.get_or_404(aufmass_id) if aufmass.project_id != project_id: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 positionen = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() if not positionen: return jsonify({'error': 'Keine Positionen vorhanden'}), 400 pos_liste = [] for p in positionen: pos_liste.append({ 'pos_nr': str(p.pos_nr or ''), 'bezeichnung': str(p.bezeichnung or ''), 'einheit': str(p.einheit or 'ST'), 'menge': str(p.menge or ''), 'faktor': str(p.faktor or '1'), 'laenge': str(getattr(p, 'laenge', '') or ''), 'breite': str(getattr(p, 'breite', '') or ''), 'tiefe': str(getattr(p, 'tiefe', '') or ''), 'langtext': str(p.langtext or ''), 'bauabschnitt': str(project.bauabschnitt or ''), }) try: from app.services.evergabe_service import EVergabeClient client = EVergabeClient( username=company.evergabe_benutzer, password=company.evergabe_passwort, name=company.evergabe_name or '' ) ergebnisse = client.aufmass_uebertragen( sm_nr=project.sm_nr, positionen=pos_liste, bauabschnitt=project.bauabschnitt or '', leist_zeitv=str(project.datum_start or ''), leist_zeitb=str(project.datum_ende or ''), leistungsort=project.bezeichnung or '', schlussaufmass=(aufmass.status == 'abgeschlossen'), ) ok_count = sum(1 for e in ergebnisse if e['status'] == 'ok') return jsonify({ 'success': True, 'gesamt': len(ergebnisse), 'ok': ok_count, 'fehler': len(ergebnisse) - ok_count, 'details': ergebnisse }) except Exception as e: return jsonify({'error': str(e)}), 500 @aufmass_bp.route('//aufmass//umbenennen', methods=['POST']) @login_required def aufmass_umbenennen(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 err = _lock_error(aufmass_id) if err: return err data = request.get_json(silent=True) or {} if 'name' in data: a.name = data['name'] if 'typ' in data: a.typ = data['typ'] if 'status' in data and data['status'] in ('aktiv', 'abgeschlossen', 'storniert'): a.status = data['status'] db.session.commit() return jsonify({'status': 'ok', 'name': a.name, 'typ': a.typ, 'status': a.status}) @aufmass_bp.route('//aufmass//loeschen', methods=['POST']) @login_required def aufmass_loeschen(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 db.session.delete(a) db.session.commit() flash('Aufmaß gelöscht.', 'success') ref = request.referrer or '' if ref.rstrip('/').endswith('/projekt'): return redirect(url_for('aufmass.index')) return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) @aufmass_bp.route('///duplizieren', methods=['POST']) @login_required def aufmass_duplizieren(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 orig = _get_aufmass_or_404(aufmass_id, project_id) if not orig: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 import re name = orig.name or 'Neues Aufmass' name = re.sub(r'\s*\(Kopie\)\s*$', '', name) name = name.strip() + ' (Kopie)' max_sort = db.session.query(db.func.max(Aufmass.sortierung)).filter_by(project_id=project_id).scalar() or 0 new_a = Aufmass( project_id=project_id, name=name, typ=orig.typ, status='aktiv', sortierung=max_sort + 1, bemerkung=orig.bemerkung, erstellt_von=current_user.id ) db.session.add(new_a) db.session.flush() for pos in orig.positionen.order_by(Position.sortierung).all(): new_pos = Position( project_id=project_id, aufmass_id=new_a.id, lv_position_id=pos.lv_position_id, pos_nr=pos.pos_nr, sortierung=pos.sortierung, rsa=pos.rsa, abschnitt=pos.abschnitt, kurztext=pos.kurztext, langtext=pos.langtext, einheit=pos.einheit, einzelpreis=pos.einzelpreis, menge=pos.menge, gesamtpreis=pos.gesamtpreis, faktor=pos.faktor, laenge=pos.laenge, breite=pos.breite, tiefe=pos.tiefe, formel_typ=pos.formel_typ, formel=pos.formel, bemerkung=pos.bemerkung, menge_hinten=pos.menge_hinten ) db.session.add(new_pos) db.session.commit() flash(f'Aufmaß "{name}" dupliziert.', 'success') ref = request.referrer or '' if ref.rstrip('/').endswith('/projekt'): return redirect(url_for('aufmass.index')) return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) # === Aufmaß-Typen verwalten === @aufmass_bp.route('/typen') @login_required def typen_liste(): if not current_user.is_firmadmin() and not current_user.is_superadmin(): flash('Keine Berechtigung.', 'danger') return redirect(url_for('aufmass.index')) typen = AufmassTyp.query.order_by(AufmassTyp.sortierung).all() return render_template('aufmass/typen.html', typen=typen, titel='Aufmaß-Typen') @aufmass_bp.route('/typen/neu', methods=['POST']) @login_required def typ_neu(): if not current_user.is_firmadmin() and not current_user.is_superadmin(): return jsonify({'error': 'Keine Berechtigung'}), 403 name = request.form.get('name', '').strip() if not name: flash('Name erforderlich.', 'danger') return redirect(url_for('aufmass.typen_liste')) if AufmassTyp.query.filter_by(name=name).first(): flash('Typ existiert bereits.', 'danger') return redirect(url_for('aufmass.typen_liste')) t = AufmassTyp(name=name, company_id=current_user.company_id, sortierung=(db.session.query(db.func.max(AufmassTyp.sortierung)).scalar() or 0) + 1) db.session.add(t) db.session.commit() flash(f'Typ "{name}" angelegt.', 'success') return redirect(url_for('aufmass.typen_liste')) @aufmass_bp.route('/typen//edit', methods=['POST']) @login_required def typ_edit(typ_id): if not current_user.is_firmadmin() and not current_user.is_superadmin(): flash('Keine Berechtigung.', 'danger') return redirect(url_for('aufmass.typen_liste')) t = AufmassTyp.query.get_or_404(typ_id) name = request.form.get('name', '').strip() if name: t.name = name db.session.commit() flash('Typ aktualisiert.', 'success') return redirect(url_for('aufmass.typen_liste')) @aufmass_bp.route('/typen//loeschen', methods=['POST']) @login_required def typ_loeschen(typ_id): if not current_user.is_firmadmin() and not current_user.is_superadmin(): flash('Keine Berechtigung.', 'danger') return redirect(url_for('aufmass.typen_liste')) t = AufmassTyp.query.get_or_404(typ_id) db.session.delete(t) db.session.commit() flash('Typ gelöscht.', 'success') return redirect(url_for('aufmass.typen_liste')) # === Editor (Positionen verwalten) === @aufmass_bp.route('//') @login_required def bearbeiten(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'lesen'): return 'Zugriff verweigert', 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: flash('Aufmaß nicht gefunden.', 'danger') return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) positionen = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() lv_names = db.session.query(LVPosition.lv_name).filter_by( company_id=current_user.company_id ).distinct().order_by(LVPosition.lv_name).all() lv_names = [r[0] for r in lv_names] lv_positionen = [] if project.lv_name: lv_positionen = LVPosition.query.filter_by( company_id=current_user.company_id, lv_name=project.lv_name ).order_by(LVPosition.order_index).all() modules = get_aktive_module(current_user.company_id, user=current_user) custom_modules = [] all_cm = CustomModule.query.filter( CustomModule.is_active == True, CustomModule.is_template == False, CustomModule.company_id == current_user.company_id ).order_by(CustomModule.sort_index).all() for cm in all_cm: if current_user.is_superadmin() or current_user.is_firmadmin(): custom_modules.append(cm) elif CustomModuleAssignment.query.filter_by( module_id=cm.id, user_id=current_user.id ).first(): custom_modules.append(cm) hidden_mods = current_user.get_hidden_modules() hidden_set = {(h['type'], str(h['id'])) for h in hidden_mods} # Lock-Prüfung locked, holder_id = a.is_locked() locked_by_name = None if locked and holder_id == current_user.id: a.refresh_lock(current_user.id) db.session.commit() elif locked: lh = User.query.get(holder_id) locked_by_name = lh.full_name if lh else 'Unbekannt' else: a.try_lock(current_user.id) db.session.commit() return render_template('aufmass/bearbeiten.html', project=project, aufmass=a, positionen=positionen, lv_names=lv_names, lv_positionen=lv_positionen, modules=modules, custom_modules=custom_modules, hidden_set=hidden_set, locked_by_name=locked_by_name, titel=f'Aufmaß {project.sm_nr} – {a.name}') # === Lock-Management === def _lock_error(aufmass_id): a = Aufmass.query.get(aufmass_id) if not a: return None locked, holder_id = a.is_locked() if locked and holder_id != current_user.id: holder = User.query.get(holder_id) name = holder.full_name if holder else 'Unbekannt' return jsonify({'locked': True, 'holder': name}), 423 return None @aufmass_bp.route('///lock', methods=['POST']) @login_required def lock_aufmass(project_id, aufmass_id): a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 locked, holder_id = a.is_locked() if locked and holder_id != current_user.id: holder = User.query.get(holder_id) return jsonify({'locked': True, 'holder': holder.full_name if holder else 'Unbekannt'}), 423 a.try_lock(current_user.id) db.session.commit() return jsonify({'locked': False, 'ok': True}) @aufmass_bp.route('///unlock', methods=['POST']) @login_required def unlock_aufmass(project_id, aufmass_id): a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 if a.locked_by == current_user.id: a.unlock() db.session.commit() return jsonify({'ok': True}) @aufmass_bp.route('///heartbeat', methods=['POST']) @login_required def heartbeat_aufmass(project_id, aufmass_id): a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 a.refresh_lock(current_user.id) db.session.commit() return jsonify({'ok': True}) # === Position CRUD (innerhalb eines Aufmaßes) === @aufmass_bp.route('///position/hinzufuegen', methods=['POST']) @login_required def position_hinzufuegen(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return 'Zugriff verweigert', 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return 'Aufmaß nicht gefunden', 404 err = _lock_error(aufmass_id) if err: return err lv_pos_id = request.form.get('lv_position_id') if lv_pos_id: lv_pos = LVPosition.query.get(int(lv_pos_id)) if not lv_pos or lv_pos.company_id != current_user.company_id: return 'Ungültige LV-Position', 400 max_sort = db.session.query(db.func.max(Position.sortierung)).filter_by( aufmass_id=aufmass_id ).scalar() or 0 pos = Position( project_id=project_id, aufmass_id=aufmass_id, lv_position_id=lv_pos.id, pos_nr=lv_pos.pos_nr, sortierung=max_sort + 1, rsa=lv_pos.rsa, abschnitt=lv_pos.abschnitt, kurztext=lv_pos.kurztext, langtext=lv_pos.langtext, einheit=lv_pos.einheit, einzelpreis=lv_pos.einzelpreis, faktor=1.0, laenge=0, breite=0, tiefe=0, menge=0, gesamtpreis=0, formel_typ='standard', ) pos.berechne_menge() else: max_sort = db.session.query(db.func.max(Position.sortierung)).filter_by( aufmass_id=aufmass_id ).scalar() or 0 pos = Position( project_id=project_id, aufmass_id=aufmass_id, pos_nr=request.form.get('pos_nr', ''), sortierung=max_sort + 1, kurztext=request.form.get('kurztext', ''), einheit=request.form.get('einheit', 'ST'), einzelpreis=float(request.form.get('einzelpreis', 0)), faktor=1.0, laenge=0, breite=0, tiefe=0, menge=0, gesamtpreis=0, formel_typ='standard', ) db.session.add(pos); db.session.commit() return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id)) @aufmass_bp.route('///position//aktualisieren', methods=['POST']) @login_required def position_aktualisieren(project_id, aufmass_id, pos_id): pos = Position.query.get_or_404(pos_id) if pos.project.company_id != current_user.company_id: return 'Zugriff verweigert', 403 if not _check_zugriff(pos.project, 'schreiben'): return 'Zugriff verweigert', 403 err = _lock_error(aufmass_id) if err: return err from app.utils import capture_diff new_state = { 'pos_nr': request.form.get('pos_nr', pos.pos_nr), 'kurztext': request.form.get('kurztext', pos.kurztext), 'einheit': request.form.get('einheit', pos.einheit), 'einzelpreis': float(request.form.get('einzelpreis', pos.einzelpreis)), 'faktor': float(request.form.get('faktor', pos.faktor)), 'laenge': float(request.form.get('laenge', pos.laenge)), 'breite': float(request.form.get('breite', pos.breite)), 'tiefe': float(request.form.get('tiefe', pos.tiefe)), 'bemerkung': request.form.get('bemerkung', pos.bemerkung), 'abschnitt': request.form.get('abschnitt', pos.abschnitt), 'formel_typ': request.form.get('formel_typ', pos.formel_typ or 'standard'), 'formel': request.form.get('formel', pos.formel) } diff = capture_diff(pos, new_state) for k, v in new_state.items(): setattr(pos, k, v) pos.berechne_menge() from app.models.aufmass_history import AufmassHistory hist = AufmassHistory(aufmass_id=aufmass_id, changed_by=current_user.id, action='change', diff=json.dumps(diff)) db.session.add(hist) db.session.commit() return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id)) FIELDS = {'pos_nr','kurztext','einheit','einzelpreis','faktor','laenge','breite','tiefe','menge','abschnitt','bemerkung','formel_typ','formel','menge_hinten'} @aufmass_bp.route('///position//update-cell', methods=['POST']) @login_required def position_update_cell(project_id, aufmass_id, pos_id): pos = Position.query.get_or_404(pos_id) if pos.project.company_id != current_user.company_id: return jsonify({'error': 'Zugriff verweigert'}), 403 if not _check_zugriff(pos.project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 err = _lock_error(aufmass_id) if err: return err data = request.get_json() or {} field = data.get('field') value = data.get('value') if field not in FIELDS: return jsonify({'error': 'Ungültiges Feld'}), 400 old_val = getattr(pos, field) setattr(pos, field, value) if field in ('faktor','laenge','breite','tiefe','formel_typ','formel','einheit','menge','menge_hinten'): pos.berechne_menge(recalc_hinten=(field != 'menge_hinten'), skip_menge_recalc=(field == 'menge')) # Create history entry for inline cell edit field_labels = { 'kurztext': 'Kurztext', 'formel_typ': 'Z-Art', 'einheit': 'EH', 'einzelpreis': 'EP', 'faktor': 'Faktor', 'laenge': 'Länge', 'breite': 'Breite', 'tiefe': 'Tiefe', 'abschnitt': 'Abschnitt', 'bemerkung': 'Bemerkung', 'menge_hinten': 'Menge hinten', 'menge': 'Menge', 'formel': 'Formel' } old_str = str(old_val) if old_val is not None else 'leer' new_str = str(value) if value is not None else 'leer' if old_str != new_str: desc = f"✎ Pos {pos.pos_nr or pos.id}: {field_labels.get(field, field)}: {old_str} → {new_str}" hist = AufmassHistory( aufmass_id=aufmass_id, position_id=pos.id, changed_by=current_user.id, action='change', description=desc, diff=json.dumps([{ 'position_id': pos.id, 'pos_nr': pos.pos_nr or '', 'kurztext': pos.kurztext or '', 'row_idx': pos.sortierung - 1, 'old': {field: old_val}, 'new': {field: value} }]) ) db.session.add(hist) db.session.commit() resp = {'status':'ok','menge':pos.menge,'gesamtpreis':pos.gesamtpreis,'menge_hinten':pos.menge_hinten} if field == 'formel_typ': resp['formel_typ_changed'] = True return jsonify(resp) @aufmass_bp.route('///position//loeschen', methods=['POST']) @login_required def position_loeschen(project_id, aufmass_id, pos_id): pos = Position.query.get_or_404(pos_id) if pos.project.company_id != current_user.company_id: return 'Zugriff verweigert', 403 if not _check_zugriff(pos.project, 'schreiben'): return 'Zugriff verweigert', 403 err = _lock_error(aufmass_id) if err: return err db.session.delete(pos) db.session.commit() return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id)) @aufmass_bp.route('///positionen/leeren', methods=['POST']) @login_required def positionen_leeren(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 err = _lock_error(aufmass_id) if err: return err positions = Position.query.filter_by(aufmass_id=aufmass_id).all() changes = [] for p in positions: changes.append({ 'position_id': p.id, 'pos_nr': p.pos_nr or '', 'old': { 'id': p.id, 'lv_pos_id': p.lv_position_id, 'pos_nr': p.pos_nr or '', 'kurztext': p.kurztext or '', 'faktor': p.faktor, 'laenge': p.laenge, 'breite': p.breite, 'tiefe': p.tiefe, 'menge': p.menge, 'einheit': p.einheit or '', 'formel_typ': p.formel_typ or 'standard', 'formel': p.formel or '', 'abschnitt': p.abschnitt or '', 'bemerkung': p.bemerkung or '', 'sort_order': p.sortierung }, 'new': {'_action': 'delete', 'id': p.id} }) Position.query.filter_by(aufmass_id=aufmass_id).delete() if changes: hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='change', description=f'Alle {len(changes)} Position(en) gelöscht', diff=json.dumps(changes) ) db.session.add(hist) db.session.commit() return jsonify({'status': 'ok'}) @aufmass_bp.route('///positionen/batch-loeschen', methods=['POST']) @login_required def positionen_batch_loeschen(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 err = _lock_error(aufmass_id) if err: return err data = request.get_json() or {} ids = data.get('ids', []) if not ids: return jsonify({'error': 'Keine IDs'}), 400 positions = Position.query.filter(Position.id.in_(ids), Position.aufmass_id == aufmass_id).all() changes = [] for p in positions: changes.append({ 'position_id': p.id, 'pos_nr': p.pos_nr or '', 'old': { 'id': p.id, 'lv_pos_id': p.lv_position_id, 'pos_nr': p.pos_nr or '', 'kurztext': p.kurztext or '', 'faktor': p.faktor, 'laenge': p.laenge, 'breite': p.breite, 'tiefe': p.tiefe, 'menge': p.menge, 'einheit': p.einheit or '', 'formel_typ': p.formel_typ or 'standard', 'formel': p.formel or '', 'abschnitt': p.abschnitt or '', 'bemerkung': p.bemerkung or '', 'sort_order': p.sortierung }, 'new': {'_action': 'delete', 'id': p.id} }) Position.query.filter(Position.id.in_(ids), Position.aufmass_id == aufmass_id).delete(synchronize_session=False) if changes: hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='change', description=f'{len(changes)} Position(en) gelöscht', diff=json.dumps(changes) ) db.session.add(hist) db.session.commit() return jsonify({'status': 'ok'}) @aufmass_bp.route('///positionen/kopieren', methods=['POST']) @login_required def positionen_kopieren(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 data = request.get_json() or {} ids = data.get('ids', []) insert_idx = data.get('insert_idx') if not ids: return jsonify({'error': 'Keine IDs'}), 400 originals = Position.query.filter(Position.id.in_(ids), Position.aufmass_id == aufmass_id).order_by(Position.sortierung).all() if not originals: return jsonify({'error': 'Positionen nicht gefunden'}), 404 existing = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() if insert_idx is not None and insert_idx >= 0: if insert_idx > len(existing): insert_idx = len(existing) sort_base = insert_idx + 1 for i, p in enumerate(existing): new_sort = i + 1 if new_sort >= sort_base: p.sortierung = new_sort + len(originals) db.session.flush() else: max_sort = db.session.query(db.func.max(Position.sortierung)).filter_by(aufmass_id=aufmass_id).scalar() or 0 sort_base = max_sort + 1 added = [] added_details = [] for orig in originals: copy = Position( project_id=project_id, aufmass_id=aufmass_id, lv_position_id=orig.lv_position_id, pos_nr=orig.pos_nr, sortierung=sort_base, rsa=orig.rsa or '', abschnitt=orig.abschnitt or '', kurztext=orig.kurztext or '', langtext=orig.langtext or '', einheit=orig.einheit or '', einzelpreis=orig.einzelpreis or 0, faktor=orig.faktor, laenge=orig.laenge, breite=orig.breite, tiefe=orig.tiefe, menge=orig.menge, gesamtpreis=orig.gesamtpreis, formel_typ=orig.formel_typ or 'standard', formel=orig.formel or '', bemerkung=orig.bemerkung or '', menge_hinten=orig.menge_hinten or 0, ) db.session.add(copy) db.session.flush() added.append(copy.id) added_details.append({'old_id': orig.id, 'new_id': copy.id, 'pos_nr': copy.pos_nr}) sort_base += 1 hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='change', description=f'{len(added)} Position(en) kopiert', diff=json.dumps([{ 'position_id': d['new_id'], 'pos_nr': d['pos_nr'], 'old': {'id': d['old_id']}, 'new': {'_action': 'copy', 'old_id': d['old_id'], 'new_id': d['new_id']} } for d in added_details]) ) db.session.add(hist) db.session.commit() return jsonify({'status': 'ok', 'added': added}) @aufmass_bp.route('///positionen/reihenfolge', methods=['POST']) @login_required def positionen_reihenfolge(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 data = request.get_json() if not data or 'reihenfolge' not in data: return jsonify({'error': 'Keine Daten'}), 400 for item in data['reihenfolge']: pos = Position.query.get(item['id']) if pos and pos.aufmass_id == aufmass_id: pos.sortierung = item['sortierung'] db.session.commit() return jsonify({'status': 'ok'}) # === Projekt-Level Operationen (kein spezifisches Aufmaß) === @aufmass_bp.route('//status', methods=['POST']) @login_required def status_aendern(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return 'Zugriff verweigert', 403 project.status = request.form.get('status', project.status) db.session.commit() flash(f'Status geändert auf {project.status}.', 'success') return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) @aufmass_bp.route('//loeschen', methods=['POST']) @login_required def project_loeschen(project_id): project = Project.query.get_or_404(project_id) if project.company_id != current_user.company_id: return 'Zugriff verweigert', 403 if not _check_zugriff(project, 'schreiben'): return 'Zugriff verweigert', 403 db.session.delete(project) db.session.commit() flash('Projekt gelöscht.', 'success') return redirect(url_for('aufmass.index')) @aufmass_bp.route('//lv/search') @login_required def lv_search(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'lesen'): return jsonify([]), 403 q = request.args.get('q', '').strip() base = LVPosition.query.filter_by(company_id=current_user.company_id, lv_name=project.lv_name) if q: like = f'%{q}%' base = base.filter(db.or_(LVPosition.pos_nr.like(like), LVPosition.kurztext.like(like), LVPosition.langtext.like(like))) pos = base.order_by(LVPosition.order_index).limit(200).all() return jsonify([{ 'id': p.id, 'pos_nr': p.pos_nr, 'kurztext': p.kurztext, 'langtext': p.langtext, 'einheit': p.einheit, 'einzelpreis': p.einzelpreis, 'rsa': p.rsa or '', 'abschnitt': p.abschnitt or '', } for p in pos]) @aufmass_bp.route('///positionen/batch-add', methods=['POST']) @login_required def positionen_batch_add(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 err = _lock_error(aufmass_id) if err: return err data = request.get_json() or {} ids = data.get('ids', []) if not ids: return jsonify({'error': 'Keine IDs'}), 400 insert_idx = data.get('insert_idx') if insert_idx is not None: insert_idx = int(insert_idx) existing = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() if insert_idx < 0: insert_idx = 0 if insert_idx > len(existing): insert_idx = len(existing) sort_base = insert_idx + 1 for i, p in enumerate(existing): new_sort = i + 1 if new_sort >= sort_base: new_sort += len(ids) p.sortierung = new_sort db.session.flush() else: max_sort = db.session.query(db.func.max(Position.sortierung)).filter_by(aufmass_id=aufmass_id).scalar() or 0 sort_base = max_sort + 1 overrides = data.get('overrides', {}) added_objs = [] added = [] for lv_id in ids: lv_pos = LVPosition.query.get(int(lv_id)) if not lv_pos or lv_pos.company_id != current_user.company_id: continue pos = Position( project_id=project_id, aufmass_id=aufmass_id, lv_position_id=lv_pos.id, pos_nr=overrides.get('pos_nr', lv_pos.pos_nr), sortierung=sort_base, rsa=lv_pos.rsa or '', abschnitt=overrides.get('abschnitt', lv_pos.abschnitt or ''), kurztext=overrides.get('kurztext', lv_pos.kurztext or ''), langtext=lv_pos.langtext or '', einheit=lv_pos.einheit or '', einzelpreis=lv_pos.einzelpreis or 0, faktor=overrides.get('faktor', 1.0), laenge=overrides.get('laenge', 0), breite=overrides.get('breite', 0), tiefe=overrides.get('tiefe', 0), menge=overrides.get('menge', 0), gesamtpreis=overrides.get('gesamtpreis', 0), formel_typ=overrides.get('formel_typ', 'standard'), formel=overrides.get('formel', ''), bemerkung=overrides.get('bemerkung', ''), menge_hinten=overrides.get('menge_hinten', 0), ) if overrides: pos.berechne_menge() else: pos.menge_hinten = pos.faktor * pos.menge pos.gesamtpreis = pos.menge_hinten * pos.einzelpreis db.session.add(pos) added_objs.append(pos) added.append({'id': pos.id, 'pos_nr': pos.pos_nr, 'kurztext': pos.kurztext}) sort_base += 1 hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='change', description=f'{len(added_objs)} Position(en) aus LV hinzugefügt', diff=json.dumps([{ 'position_id': p.id, 'pos_nr': p.pos_nr, 'kurztext': p.kurztext or '', 'row_idx': added_objs.index(p), 'old': {'id': None}, 'new': {'_action': 'add', 'id': p.id} } for p in added_objs]) ) db.session.add(hist) db.session.commit() return jsonify({'status': 'ok', 'added': added}) @aufmass_bp.route('///positionen/batch-edit', methods=['POST']) @login_required def positionen_batch_edit(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 err = _lock_error(aufmass_id) if err: return err data = request.get_json(force=True, silent=True) if not data: return jsonify({'error': 'Keine Daten (data is None)', 'raw': request.data[:200].decode('utf-8', errors='replace')}), 400 if 'positions' not in data: return jsonify({'error': 'Keine positions in data', 'keys': list(data.keys())}), 400 FIELDS = {'kurztext','formel_typ','einheit','einzelpreis','faktor','laenge','breite','tiefe','abschnitt','bemerkung','menge_hinten','menge','formel'} changes = [] for pos_id, updates in data['positions'].items(): pos = Position.query.get(int(pos_id)) if not pos or pos.aufmass_id != aufmass_id or pos.project.company_id != current_user.company_id: continue old_values = {} for field, value in updates.items(): if field not in FIELDS: continue old_values[field] = getattr(pos, field) setattr(pos, field, value) if old_values: row_idx = pos.sortierung - 1 changes.append({ 'position_id': pos.id, 'pos_nr': pos.pos_nr, 'kurztext': pos.kurztext or '', 'row_idx': row_idx, 'old': old_values, 'new': updates }) ft = updates.get('formel_typ') or pos.formel_typ or 'standard' skip_me = ('menge' in updates) and ft != 'frei' pos.berechne_menge(recalc_hinten=('menge_hinten' not in updates), skip_menge_recalc=skip_me) if changes: field_labels = { 'kurztext': 'Kurztext', 'formel_typ': 'Z-Art', 'einheit': 'EH', 'einzelpreis': 'EP', 'faktor': 'Faktor', 'laenge': 'Länge', 'breite': 'Breite', 'tiefe': 'Tiefe', 'abschnitt': 'Abschnitt', 'bemerkung': 'Bemerkung', 'menge_hinten': 'Menge hinten', 'menge': 'Menge', 'formel': 'Formel' } desc_parts = [] long_parts = [] for c in changes: row_label = f"Zeile {c['row_idx']+1}" if c.get('row_idx') is not None else "?" pos_label = f"{row_label}: Pos {c['pos_nr'] or '#'+str(c['position_id'])}" kurztext = c.get('kurztext', '')[:20] if kurztext: kurztext = f' "{kurztext}"' else: kurztext = '' changes_detail = [] for field in c['new']: old_val = c['old'].get(field) new_val = c['new'].get(field) if old_val is None and new_val is None: continue old_str = str(old_val) if old_val is not None else 'leer' new_str = str(new_val) if new_val is not None else 'leer' if old_str != new_str: label = field_labels.get(field, field) changes_detail.append(f'{label}: {old_str} → {new_str}') if changes_detail: short = f"✎ {pos_label}: {', '.join(changes_detail[:2])}" if len(changes_detail) > 2: short += f' (+{len(changes_detail)-2})' desc_parts.append(short) long_parts.append(f"✎ {pos_label}{kurztext}\n " + '\n '.join(changes_detail)) description = ', '.join(desc_parts[:3]) if len(desc_parts) > 3: description += f' (+{len(desc_parts)-3} weitere)' hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='change', description=description, diff=json.dumps(changes) ) db.session.add(hist) db.session.commit() return jsonify({'ok': True}) @aufmass_bp.route('///position/leer', methods=['POST']) @login_required def position_leer(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 err = _lock_error(aufmass_id) if err: return err data = request.get_json(silent=True) or {} insert_idx = data.get('insert_idx') if insert_idx is not None: insert_idx = int(insert_idx) existing = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() if insert_idx < 0: insert_idx = 0 if insert_idx > len(existing): insert_idx = len(existing) sort_base = insert_idx + 1 for i, p in enumerate(existing): new_sort = i + 1 if new_sort >= sort_base: p.sortierung = new_sort + 1 db.session.flush() else: max_sort = db.session.query(db.func.max(Position.sortierung)).filter_by(aufmass_id=aufmass_id).scalar() or 0 sort_base = max_sort + 1 pos = Position( project_id=project_id, aufmass_id=aufmass_id, pos_nr='', sortierung=sort_base, einheit='', faktor=0, laenge=0, breite=0, tiefe=0, menge=0, einzelpreis=0, gesamtpreis=0, formel_typ='standard', menge_hinten=0, ) db.session.add(pos) db.session.flush() hist = AufmassHistory( aufmass_id=aufmass_id, position_id=pos.id, changed_by=current_user.id, action='change', description=f'Leere Zeile hinzugefügt (Pos #{pos.id})', diff=json.dumps([{ 'position_id': pos.id, 'pos_nr': '', 'old': {'id': None}, 'new': {'_action': 'add_empty', 'id': pos.id} }]) ) db.session.add(hist) db.session.commit() return jsonify({'status': 'ok', 'id': pos.id, 'pos_nr': pos.pos_nr}) @aufmass_bp.route('///positionen') @login_required def positionen_liste(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'lesen'): return jsonify([]), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify([]), 404 pos = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() return jsonify([{ 'id': p.id, 'pos_nr': p.pos_nr, 'sortierung': p.sortierung, 'rsa': p.rsa or '', 'abschnitt': p.abschnitt or '', 'kurztext': p.kurztext, 'langtext': p.langtext, 'einheit': p.einheit, 'einzelpreis': p.einzelpreis, 'menge': p.menge, 'gesamtpreis': p.gesamtpreis, 'faktor': p.faktor, 'laenge': p.laenge, 'breite': p.breite, 'tiefe': p.tiefe, 'bemerkung': p.bemerkung or '', 'formel_typ': p.formel_typ or 'standard', 'formel': p.formel or '', 'menge_hinten': p.menge_hinten or 0, } for p in pos]) @aufmass_bp.route('///positionen/farben') @login_required def positionen_farben(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'lesen'): return jsonify({}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({}), 404 pos = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() lv_pos_nrs = set() if project.lv_name: lvps = LVPosition.query.filter_by(company_id=current_user.company_id, lv_name=project.lv_name).all() lv_pos_nrs = {lp.pos_nr for lp in lvps} result = {} for p in pos: color_map = {} is_empty = not p.pos_nr and p.einheit in ('', None) if is_empty: color_map['all'] = '#F7CA14' else: e = p.einheit or '' m = p.menge or 0 f = p.faktor or 0 l = p.laenge or 0 b = p.breite or 0 t = p.tiefe or 0 if e in ('ST', 'LE', 'STD', 'h', 'Psch') and m == 0: color_map['menge'] = '#ED686B' if e == 'M' and l == 0: color_map['laenge'] = '#ED686B' if e == 'M2': if l == 0: color_map['laenge'] = '#ED686B' if b == 0: color_map['breite'] = '#ED686B' if e in ('M3', 't'): if l == 0: color_map['laenge'] = '#ED686B' if b == 0: color_map['breite'] = '#ED686B' if t == 0: color_map['tiefe'] = '#ED686B' if p.pos_nr and p.pos_nr not in lv_pos_nrs: color_map['pos_nr'] = '#3370AD' result[str(p.id)] = color_map return jsonify(result) @aufmass_bp.route('///positionen/batch-reorder', methods=['POST']) @login_required def positionen_batch_reorder(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 err = _lock_error(aufmass_id) if err: return err try: data = request.get_json() or {} except Exception: return jsonify({'error': 'Invalid JSON'}), 400 ids = data.get('ids', []) if not ids: return jsonify({'error': 'Keine IDs'}), 400 try: ids = [int(i) for i in ids] except (ValueError, TypeError): return jsonify({'error': 'Invalid IDs'}), 400 insert_idx = data.get('insert_idx') existing = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() if not existing: return jsonify({'status': 'ok', 'changes': []}) moved_set = set(ids) remaining = [p for p in existing if p.id not in moved_set] if insert_idx is None: new_order = remaining + [p for p in existing if p.id in moved_set] elif insert_idx < 0 or insert_idx >= len(remaining) + len(moved_set): new_order = remaining + [p for p in existing if p.id in moved_set] else: new_order = remaining[:insert_idx] + [p for p in existing if p.id in moved_set] + remaining[insert_idx:] changes = [] pos_list_for_desc = [] for i, p in enumerate(new_order): old_idx = p.sortierung new_idx = i + 1 if old_idx != new_idx: p.sortierung = new_idx changes.append({ 'position_id': p.id, 'pos_nr': p.pos_nr or '', 'row_idx': i, 'old_idx': old_idx, 'new_idx': new_idx, 'kurztext': p.kurztext or '', 'old': {'sort_order': old_idx}, 'new': {'_action': 'reorder', 'sort_order': new_idx, 'old_idx': old_idx} }) pos_list_for_desc.append({ 'position_id': p.id, 'pos_nr': p.pos_nr or '', 'kurztext': p.kurztext or '', 'row_idx': i, 'old_idx': old_idx, 'new_idx': new_idx }) else: p.sortierung = new_idx if changes: try: short_desc, long_desc = _build_history_description('move', pos_list_for_desc, current_user.name or current_user.email) if not short_desc: short_desc = f'{len(changes)} Position(en) verschoben' except Exception: short_desc = f'{len(changes)} Position(en) verschoben' hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='change', description=short_desc, diff=json.dumps(changes) ) db.session.add(hist) db.session.commit() return jsonify({'status': 'ok', 'changes': changes}) @aufmass_bp.route('//lv-set', methods=['POST']) @login_required def project_lv_set(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 project.lv_name = request.form.get('lv_name', '').strip() contract_id = request.form.get('contract_id', type=int) if contract_id: project.contract_id = contract_id contract = Contract.query.get(contract_id) if contract: project.vertrag = contract.name db.session.commit() first_aufmass = Aufmass.query.filter_by(project_id=project_id).order_by(Aufmass.sortierung).first() if first_aufmass: return redirect(url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=first_aufmass.id)) return redirect(url_for('aufmass.aufmass_list', project_id=project_id)) @aufmass_bp.route('//kopfdaten', methods=['POST']) @login_required def project_kopfdaten_save(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 project.vertrag = request.form.get('vertrag', '').strip() project.lv_name = request.form.get('lv_name', '').strip() project.bezeichnung = request.form.get('bezeichnung', '').strip() project.baustelle = request.form.get('baustelle', '').strip() project.bauabschnitt = request.form.get('bauabschnitt', '').strip() project.sm_nr = request.form.get('sm_nr', '').strip() project.abruf_nr = request.form.get('abruf_nr', '').strip() project.datum_start = _parse_date(request.form.get('datum_start')) project.datum_ende = _parse_date(request.form.get('datum_ende')) project.ansprechpartner_vorname = request.form.get('ansprechpartner_vorname', '').strip() project.ansprechpartner_nachname = request.form.get('ansprechpartner_nachname', '').strip() project.ansprechpartner_tel = request.form.get('ansprechpartner_tel', '').strip() project.ansprechpartner_email = request.form.get('ansprechpartner_email', '').strip() aufmass_id = request.form.get('aufmass_id', type=int) if aufmass_id: aufmass = Aufmass.query.get(aufmass_id) if aufmass and aufmass.project_id == project_id: aufmass.typ = request.form.get('typ', '').strip() aufmass.datum = _parse_date(request.form.get('datum')) if request.form.get('datum'): project.datum = _parse_date(request.form.get('datum')) db.session.commit() flash('Kopfdaten gespeichert.', 'success') return redirect(request.referrer or url_for('aufmass.bearbeiten', project_id=project_id, aufmass_id=aufmass_id or 0)) @aufmass_bp.route('//update-name', methods=['POST']) @login_required def project_update_name(project_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 name = request.form.get('name', '').strip() if not name: return jsonify({'error': 'Name darf nicht leer sein'}), 400 import re invalid_chars = r'[<>:"/\\|?*&#%{}~\[\]]' if re.search(invalid_chars, name): return jsonify({'error': 'Name enthält ungültige Zeichen'}), 400 project.bezeichnung = name db.session.commit() return jsonify({'name': name}) @aufmass_bp.route('/module-reorder', methods=['POST']) @login_required def module_reorder(): data = request.get_json(silent=True) if not data or 'order' not in data: return jsonify({'error': 'Keine Daten'}), 400 from app.models.module import Module from app.models.custom_module import CustomModule for i, item in enumerate(data['order']): if item.get('type') == 'module': mod = Module.query.get(int(item['id'])) if mod: mod.sortierung = i elif item.get('type') == 'custom': cm = CustomModule.query.get(int(item['id'])) if cm and cm.company_id == current_user.company_id: cm.sort_index = i db.session.commit() return jsonify({'ok': True}) @aufmass_bp.route('/module-toggle-hidden', methods=['POST']) @login_required def module_toggle_hidden(): data = request.get_json(silent=True) if not data or 'type' not in data or 'id' not in data: return jsonify({'error': 'Ungültige Daten'}), 400 hidden = current_user.get_hidden_modules() entry = {'type': data['type'], 'id': data['id']} found = False for h in hidden: if h['type'] == entry['type'] and h['id'] == entry['id']: hidden.remove(h) found = True break if not found: hidden.append(entry) current_user.set_hidden_modules(hidden) db.session.commit() return jsonify({'hidden': found, 'type': data['type'], 'id': data['id']}) @aufmass_bp.route('/module-toggle-hidden-batch', methods=['POST']) @login_required def module_toggle_hidden_batch(): data = request.get_json(silent=True) if not data or 'entries' not in data or 'action' not in data: return jsonify({'error': 'Ungültige Daten'}), 400 hidden = current_user.get_hidden_modules() action = data['action'] # 'hide' or 'show' for entry in data['entries']: key = {'type': entry['type'], 'id': str(entry['id'])} found = False for h in hidden: if h['type'] == key['type'] and h['id'] == key['id']: if action == 'show': hidden.remove(h) found = True break if not found and action == 'hide': hidden.append(key) current_user.set_hidden_modules(hidden) db.session.commit() return jsonify({'ok': True, 'action': action, 'count': len(data['entries'])}) def _parse_date(s): if not s: return None for fmt in ('%Y-%m-%d', '%d.%m.%Y', '%Y.%m.%d'): try: return datetime.strptime(s.strip(), fmt).date() except ValueError: continue return None @aufmass_bp.route('///undo', methods=['POST']) @login_required def undo(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 last_change = (AufmassHistory.query .filter_by(aufmass_id=aufmass_id, action='change') .order_by(AufmassHistory.id.desc()) .first()) if not last_change: return jsonify({'error': 'Nichts zum Rückgängig machen'}), 400 changes = json.loads(last_change.diff) for change in changes: pos = Position.query.get(change['position_id']) if not pos: continue new_data = change.get('new', {}) old_data = change.get('old', {}) if new_data.get('_action') in ('add', 'add_empty', 'add_form'): db.session.delete(pos) elif new_data.get('_action') == 'delete': pos_id = change.get('position_id') pos_deleted = Position.query.get(pos_id) if not pos_deleted: lv_id = old_data.get('lv_pos_id') if old_data.get('lv_pos_id') else None pos_deleted = Position( aufmass_id=aufmass_id, lv_pos_id=lv_id, pos_nr=old_data.get('pos_nr', ''), kurztext=old_data.get('kurztext', ''), faktor=old_data.get('faktor', 1.0), laenge=old_data.get('laenge', 0), breite=old_data.get('breite', 0), tiefe=old_data.get('tiefe', 0), menge=old_data.get('menge', 0), einheit=old_data.get('einheit', ''), formel_typ=old_data.get('formel_typ', 'standard'), formel=old_data.get('formel', ''), abschnitt=old_data.get('abschnitt', ''), bemerkung=old_data.get('bemerkung', ''), sort_order=old_data.get('sort_order', 0) ) db.session.add(pos_deleted) else: for field, val in old_data.items(): if field not in ('_action', 'id'): setattr(pos_deleted, field, val) db.session.add(pos_deleted) elif new_data.get('_action') == 'copy': new_pos = Position.query.get(new_data.get('new_id')) if new_pos: db.session.delete(new_pos) elif new_data.get('_action') == 'reorder': # For reorder undo, we need to restore the old order of ALL positions # The old order is stored in row_idx and order in new_data old_order = new_data.get('order', []) if old_order: # Get all positions for this aufmass and reorder them all_pos = Position.query.filter_by(aufmass_id=aufmass_id).order_by(Position.sortierung).all() for i, pos_id in enumerate(old_order): pos = Position.query.get(pos_id) if pos: pos.sortierung = i + 1 else: # Fallback: just restore sort_order from old data old_sort = old_data.get('sort_order') if old_sort: pos.sortierung = old_sort else: for field, old_val in old_data.items(): if field != '_action': setattr(pos, field, old_val) ft = pos.formel_typ or 'standard' skip_me = ('menge' in old_data) and ft != 'frei' pos.berechne_menge(recalc_hinten=('menge_hinten' not in old_data), skip_menge_recalc=skip_me) undo_hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='undo', description=f"Rückgängig: {last_change.description}", diff=json.dumps({'undo_of': last_change.id}) ) db.session.add(undo_hist) db.session.commit() return jsonify({'ok': True, 'message': f'Rückgängig: {last_change.description[:50]}'}) @aufmass_bp.route('///redo', methods=['POST']) @login_required def redo(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 last_undo = (AufmassHistory.query .filter_by(aufmass_id=aufmass_id, action='undo') .order_by(AufmassHistory.id.desc()) .first()) if not last_undo: return jsonify({'error': 'Nichts zum Wiederholen'}), 400 changes = json.loads(last_undo.diff) if 'undo_of' not in changes: return jsonify({'error': 'Ungültiger Undo-Eintrag'}), 400 original = AufmassHistory.query.get(changes['undo_of']) if not original: return jsonify({'error': 'Original nicht gefunden'}), 400 original_changes = json.loads(original.diff) for change in original_changes: pos = Position.query.get(change['position_id']) new_data = change.get('new', {}) old_data = change.get('old', {}) if new_data.get('_action') in ('add', 'add_empty', 'add_form'): # Redo add: Position wiederherstellen if not pos: pos = Position( aufmass_id=aufmass_id, lv_position_id=old_data.get('lv_pos_id'), pos_nr=old_data.get('pos_nr', ''), kurztext=old_data.get('kurztext', ''), faktor=old_data.get('faktor', 1.0), laenge=old_data.get('laenge', 0), breite=old_data.get('breite', 0), tiefe=old_data.get('tiefe', 0), menge=old_data.get('menge', 0), einheit=old_data.get('einheit', ''), formel_typ=old_data.get('formel_typ', 'standard'), formel=old_data.get('formel', ''), abschnitt=old_data.get('abschnitt', ''), bemerkung=old_data.get('bemerkung', ''), sort_order=old_data.get('sort_order', 0) ) db.session.add(pos) db.session.flush() elif new_data.get('_action') == 'delete': # Redo delete: Position löschen if pos: db.session.delete(pos) elif new_data.get('_action') == 'copy': # Redo copy: Kopie wiederherstellen old_id = new_data.get('old_id') orig = Position.query.get(old_id) if orig: copy = Position( project_id=project_id, aufmass_id=aufmass_id, lv_position_id=orig.lv_position_id, pos_nr=orig.pos_nr, sortierung=pos.sortierung if pos else old_data.get('sort_order', 0) + 1, rsa=orig.rsa or '', abschnitt=orig.abschnitt or '', kurztext=orig.kurztext or '', langtext=orig.langtext or '', einheit=orig.einheit or '', einzelpreis=orig.einzelpreis or 0, faktor=orig.faktor, laenge=orig.laenge, breite=orig.breite, tiefe=orig.tiefe, menge=orig.menge, gesamtpreis=orig.gesamtpreis, formel_typ=orig.formel_typ or 'standard', formel=orig.formel or '', bemerkung=orig.bemerkung or '', menge_hinten=orig.menge_hinten or 0, ) db.session.add(copy) db.session.flush() # Update the original position reference if pos: pos.sortierung = new_data.get('new_id') elif new_data.get('_action') == 'reorder': # Redo reorder: Neue Reihenfolge wiederherstellen new_order = new_data.get('order', []) if new_order: for i, pos_id in enumerate(new_order): p = Position.query.get(pos_id) if p: p.sortierung = i + 1 else: # Normal edit: new values wiederherstellen if pos: for field, new_val in new_data.items(): if field != '_action': setattr(pos, field, new_val) ft = pos.formel_typ or 'standard' skip_me = ('menge' in new_data) and ft != 'frei' pos.berechne_menge(recalc_hinten=('menge_hinten' not in new_data), skip_menge_recalc=skip_me) redo_hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='redo', description=f"Wiederholt: {original.description}", diff=json.dumps({'redo_of': original.id}) ) db.session.add(redo_hist) db.session.commit() return jsonify({'ok': True, 'message': f'Wiederholt: {original.description[:50]}'}) @aufmass_bp.route('///history', methods=['GET']) @login_required def history(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'lesen'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 limit = request.args.get('limit', 10, type=int) hist_list = (AufmassHistory.query .filter_by(aufmass_id=aufmass_id) .order_by(AufmassHistory.changed_at.desc()) .limit(limit) .all()) data = [] for h in hist_list: user = User.query.get(h.changed_by) user_name = f"{user.vorname} {user.nachname}".strip() if user else 'Unbekannt' try: changes = json.loads(h.diff) if h.diff else [] except Exception as e: changes = [] # Build long description long_desc = h.description or '' if h.action == 'undo' and changes.get('undo_of'): # For undo, get original action description and build long version orig = AufmassHistory.query.get(changes['undo_of']) if orig: try: orig_changes = json.loads(orig.diff) if orig.diff else [] long_desc = _build_long_description(orig_changes, user_name, h.changed_at, prefix='↩ Rückgängig: ') except: long_desc = f"↩ Rückgängig: {orig.description}" elif h.action == 'redo' and changes.get('undo_of'): # For redo, the diff contains undo_of pointing to the undo entry # We need to get the original change from that undo entry undo_entry = AufmassHistory.query.get(changes['undo_of']) if undo_entry and undo_entry.action == 'undo': undo_diff = json.loads(undo_entry.diff) if undo_entry.diff else {} if undo_diff.get('undo_of'): orig = AufmassHistory.query.get(undo_diff['undo_of']) if orig: try: orig_changes = json.loads(orig.diff) if orig.diff else [] long_desc = _build_long_description(orig_changes, user_name, h.changed_at, prefix='↪ Wiederholt: ') except: long_desc = f"↪ Wiederholt: {orig.description}" elif changes: # Normal case: build from changes try: long_desc = _build_long_description(changes, user_name, h.changed_at) except Exception as e: long_desc = h.description or '' data.append({ 'id': h.id, 'user': user.email if user else 'unbekannt', 'user_name': user_name, 'time': h.changed_at.strftime('%d.%m.%Y %H:%M:%S'), 'time_iso': h.changed_at.isoformat(), 'action': h.action, 'description': h.description or '', 'long_description': long_desc, 'diff': changes }) return jsonify(data) def _build_long_description(changes, user_name, dt, prefix=''): """Baut die lange History-Beschreibung.""" if not changes: return '' from datetime import datetime now = dt.strftime('%d.%m.%Y %H:%M') if dt else datetime.now().strftime('%d.%m.%Y %H:%M') lines = [f"[{now}] {user_name}"] field_labels = { 'faktor': 'Faktor', 'laenge': 'Länge', 'breite': 'Breite', 'tiefe': 'Tiefe', 'menge': 'Menge', 'menge_hinten': 'Menge hinten', 'einheit': 'EH', 'abschnitt': 'Abschnitt', 'bemerkung': 'Bemerkung', 'formel': 'Formel', 'formel_typ': 'Z-Art', 'kurztext': 'Kurztext', 'pos_nr': 'Pos-Nr' } for c in changes: pos_nr = c.get('pos_nr', c.get('position_id', '?')) kurztext = c.get('kurztext', '') row_idx = c.get('row_idx') line_num = f"Zeile {row_idx + 1}" if row_idx is not None else "?" kt = f' "{kurztext}"' if kurztext else '' old_data = c.get('old', {}) new_data = c.get('new', {}) action = new_data.get('_action', 'edit') if new_data else 'edit' if action in ('add', 'add_empty', 'add_form'): lines.append(f"+ {line_num}: Pos {pos_nr}{kt} - HINZUGEFÜGT") elif action == 'delete': lines.append(f"- {line_num}: Pos {pos_nr}{kt} - GELÖSCHT") if old_data: details = [] for k, v in old_data.items(): if k not in ('_action', 'id') and v: lbl = field_labels.get(k, k) details.append(f" {lbl}: {v}") if details: lines.extend(details[:5]) elif action == 'copy': lines.append(f"⎘ {line_num}: Pos {pos_nr}{kt} - KOPIERT") elif action == 'reorder': old_idx = old_data.get('sort_order', '?') new_idx = new_data.get('sort_order', '?') if old_idx != new_idx: direction = '↓' if new_idx > old_idx else '↑' lines.append(f"↔ {line_num}: Pos {pos_nr}{kt} - {old_idx} {direction} {new_idx}") else: # Edit diffs = [] for field in set(list(old_data.keys()) + list(new_data.keys())): if field.startswith('_') or field == 'id': continue old_v = old_data.get(field) new_v = new_data.get(field) if old_v is None and new_v is None: continue old_s = str(old_v) if old_v is not None else 'leer' new_s = str(new_v) if new_v is not None else 'leer' if old_s != new_s: lbl = field_labels.get(field, field) diffs.append(f" {lbl}: {old_s} → {new_s}") if diffs: lines.append(f"✎ {line_num}: Pos {pos_nr}{kt}") lines.extend(diffs[:4]) if len(diffs) > 4: lines.append(f" ... (+{len(diffs)-4} weitere)") if prefix and lines: lines[0] = prefix + lines[0] return '\n'.join(lines) @aufmass_bp.route('///history/count', methods=['GET']) @login_required def history_count(project_id, aufmass_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'lesen'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 change_count = AufmassHistory.query.filter_by(aufmass_id=aufmass_id, action='change').count() undo_count = AufmassHistory.query.filter_by(aufmass_id=aufmass_id, action='undo').count() return jsonify({'changes': change_count, 'undo_count': undo_count, 'can_undo': change_count > undo_count}) @aufmass_bp.route('///history//undo', methods=['POST']) @login_required def history_undo_specific(project_id, aufmass_id, history_id): project = Project.query.get_or_404(project_id) if not _check_zugriff(project, 'schreiben'): return jsonify({'error': 'Zugriff verweigert'}), 403 a = _get_aufmass_or_404(aufmass_id, project_id) if not a: return jsonify({'error': 'Aufmaß nicht gefunden'}), 404 h = AufmassHistory.query.get_or_404(history_id) if h.aufmass_id != aufmass_id: return jsonify({'error': 'History-Eintrag gehört nicht zu diesem Aufmaß'}), 400 if h.action != 'change': return jsonify({'error': 'Nur Änderungen können rückgängig gemacht werden'}), 400 try: changes = json.loads(h.diff) if h.diff else [] except: return jsonify({'error': 'Ungültige History-Daten'}), 400 ft_cache = {} for c in changes: pos_id = c.get('position_id') if not pos_id: continue pos = Position.query.get(pos_id) if not pos: continue old_data = c.get('old', {}) new_data = c.get('new', {}) action = new_data.get('_action', 'edit') if new_data else 'edit' if action in ('add', 'add_empty', 'add_form'): # Delete the position Position.query.filter_by(id=pos_id).delete() elif action == 'delete': # Restore the position from old_data rest_pos = Position( aufmass_id=aufmass_id, lv_position_id=old_data.get('lv_pos_id'), pos_nr=old_data.get('pos_nr', ''), kurztext=old_data.get('kurztext', ''), faktor=old_data.get('faktor', 1.0), laenge=old_data.get('laenge', 0), breite=old_data.get('breite', 0), tiefe=old_data.get('tiefe', 0), menge=old_data.get('menge', 0), einheit=old_data.get('einheit', ''), formel_typ=old_data.get('formel_typ', 'standard'), formel=old_data.get('formel', ''), abschnitt=old_data.get('abschnitt', ''), bemerkung=old_data.get('bemerkung', ''), sort_order=old_data.get('sort_order', 0) ) db.session.add(rest_pos) elif action == 'copy': # Delete the copied position Position.query.filter_by(id=pos_id).delete() elif action == 'reorder': # Restore old sort order old_order = old_data.get('sort_order') if old_order: pos.sortierung = old_order else: # Edit - restore old values for field, old_val in old_data.items(): if field != '_action' and field != 'id': setattr(pos, field, old_val) ft = pos.formel_typ or 'standard' skip_me = ('menge' in old_data) and ft != 'frei' pos.berechne_menge(recalc_hinten=('menge_hinten' not in old_data), skip_menge_recalc=skip_me) # Create undo history entry undo_hist = AufmassHistory( aufmass_id=aufmass_id, changed_by=current_user.id, action='undo', description=f"Rückgängig (aus History): {h.description[:50]}", diff=json.dumps({'undo_of': h.id}) ) db.session.add(undo_hist) db.session.commit() return jsonify({'ok': True, 'message': f'Rückgängig gemacht: {h.description[:50]}'})