Untitled

ESS Script
 avatar
unknown
javascript
a month ago
21 kB
10
Indexable
// ==UserScript==
// @name         SAP Aktuell
// @namespace    tampermonkey
// @version      4.3
// @description  Produktivstunden automatisch buchen (kompletter Tag, Duplikatprüfung)
// @author       Du
// @match        *://*.cancom.de/*
// @match        *://*.jetsapphr*/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function(){
  'use strict';

  const C = { farbe: '#0070f2', debug: true };
  const log = (...a) => C.debug && console.log('[AutoBuchen]', ...a);
  const host = location.hostname;
  if (!host.includes('cancom.de') && !host.includes('jetsapphr')) return;
  log('v4.3 GESTARTET');

  const warte = ms => new Promise(r => setTimeout(r, ms));

  // ─── DOM-Hilfsfunktionen ────────────────────────────────────────────────────

  function alleDocs() {
    const docs = new Set();
    function sammle(win) {
      try {
        docs.add(win.document);
        for (let i = 0; i < win.frames.length; i++) {
          try { sammle(win.frames[i]); } catch(e) {}
        }
      } catch(e) {}
    }
    try { sammle(window.top); } catch(e) { sammle(window); }
    return [...docs];
  }

  function getSapDoc() {
    for (const doc of alleDocs()) {
      if (doc.getElementById('WD9D')) return doc;
    }
    return null;
  }

  // ─── Zeitstempel lesen ─────────────────────────────────────────────────────

  function leseStempel() {
    const liste = [], gesehen = new Set();
    const SELS = [
      '[class*="sapMListTbl"] td',
      '[class*="sapUiTable"] td',
      '[class*="sapMLIB"]',
      '[class*="sapMObjLAttr"]',
      '[class*="sapMText"]',
      '[class*="sapMLabel"]',
      'td, li, span, div, label'
    ];
    for (const doc of alleDocs()) {
      let hit = false;
      for (const sel of SELS) {
        let els = [];
        try { els = [...doc.querySelectorAll(sel)]; } catch(e) { continue; }
        for (const el of els) {
          if (el.children.length > 4) continue;
          const txt = (el.innerText || el.textContent || '').trim();
          if (!txt || txt.length > 80) continue;
          const m = txt.match(/\b(\d{2}:\d{2})\b/);
          if (!m) continue;
          const zeit = m[1], low = txt.toLowerCase();
          let typ = null;
          if      (low.includes('kommen'))                           typ = 'kommen';
          else if (low.includes('beginn') && low.includes('pause'))  typ = 'beginn_pause';
          else if (low.includes('ende')   && low.includes('pause'))  typ = 'ende_pause';
          else if (low.includes('gehen'))                            typ = 'gehen';
          if (!typ && el.closest('tr')) {
            const rt = (el.closest('tr').innerText || '').toLowerCase();
            if      (rt.includes('kommen'))                          typ = 'kommen';
            else if (rt.includes('beginn') && rt.includes('pause'))  typ = 'beginn_pause';
            else if (rt.includes('ende')   && rt.includes('pause'))  typ = 'ende_pause';
            else if (rt.includes('gehen'))                           typ = 'gehen';
          }
          if (typ) {
            const key = `${typ}:${zeit}`;
            if (!gesehen.has(key)) {
              gesehen.add(key);
              liste.push({ zeit, typ });
              log('✔ Stempel erkannt:', typ, zeit);
              hit = true;
            }
          }
        }
        if (hit && sel !== 'td, li, span, div, label') break;
      }
    }
    return liste;
  }

  // ─── Blöcke berechnen (gesamter Tag) ──────────────────────────────────────
  //
  // Erlaubte Paare (in dieser Reihenfolge):
  //   kommen       → beginn_pause   (Arbeitszeit vor Pause)
  //   ende_pause   → beginn_pause   (Arbeitszeit zwischen zwei Pausen)
  //   ende_pause   → gehen          (Arbeitszeit nach letzter Pause)
  //
  // NICHT erlaubt / wird ignoriert:
  //   kommen → gehen  (kein direktes Kommen→Gehen ohne Pause dazwischen)
  //   doppelte kommen / gehen ohne Gegenpart

  function berechneBloecke(stempel) {
    const bloecke = [];
    const s = [...stempel].sort((a, b) => a.zeit.localeCompare(b.zeit));

    log('📋 Sortierte Stempel:', s.map(x => `${x.typ}@${x.zeit}`).join(', '));

    // Zustandsmaschine: welcher Stempel darf als "von" stehen
    // von-Quellen:  kommen, ende_pause
    // bis-Quellen:  beginn_pause, gehen
    // Regel: nach kommen darf nur beginn_pause folgen (nicht gehen)
    //        nach ende_pause darf beginn_pause ODER gehen folgen

    let vonZeit = null;
    let vonTyp  = null;

    for (const x of s) {
      const { typ, zeit } = x;

      if (typ === 'kommen') {
        // Startet einen neuen Arbeitsblock
        if (vonZeit !== null) log(`⚠️ Neues 'kommen' (${zeit}) obwohl bereits von=${vonZeit} offen – verwerfe altes`);
        vonZeit = zeit;
        vonTyp  = 'kommen';

      } else if (typ === 'ende_pause') {
        // Startet Arbeitsblock nach einer Pause
        if (vonZeit !== null) log(`⚠️ 'ende_pause' (${zeit}) obwohl bereits von=${vonZeit} offen – überschreibe`);
        vonZeit = zeit;
        vonTyp  = 'ende_pause';

      } else if (typ === 'beginn_pause') {
        // Schließt einen Arbeitsblock — erlaubt nach kommen UND nach ende_pause
        if (vonZeit === null) {
          log(`⚠️ 'beginn_pause' (${zeit}) ohne offenes Von – überspringe`);
          continue;
        }
        bloecke.push({ von: vonZeit, bis: zeit });
        log(`📦 Block [${vonTyp} → beginn_pause]: ${vonZeit} – ${zeit}`);
        vonZeit = null;
        vonTyp  = null;

      } else if (typ === 'gehen') {
        // Schließt Tagesende — erlaubt NUR nach ende_pause, NICHT direkt nach kommen
        if (vonZeit === null) {
          log(`⚠️ 'gehen' (${zeit}) ohne offenes Von – überspringe`);
          continue;
        }
        if (vonTyp === 'kommen') {
          // kommen → gehen direkt: ungültig, da keine Pause erfasst wurde
          log(`⚠️ 'gehen' (${zeit}) direkt nach 'kommen' (${vonZeit}) – kein Pausenstempel vorhanden, Block wird NICHT gebucht`);
          vonZeit = null;
          vonTyp  = null;
          continue;
        }
        // vonTyp === 'ende_pause' → gültig
        bloecke.push({ von: vonZeit, bis: zeit });
        log(`📦 Block [ende_pause → gehen]: ${vonZeit} – ${zeit}`);
        vonZeit = null;
        vonTyp  = null;
      }
    }

    if (vonZeit !== null) {
      log(`⚠️ Offener Block ohne Ende: von=${vonZeit} (${vonTyp}) – wird nicht gebucht`);
    }

    log(`✅ ${bloecke.length} gültige Block(e) berechnet`);
    return bloecke;
  }

  // ─── Bestehende Aufträge / Buchungen lesen ─────────────────────────────────

  /**
   * Erkennt bereits gebuchte Einträge zuverlässig anhand der "Stunden"-Spalte.
   *
   * Logik aus der SAP-Tabelle (siehe Screenshot):
   *   - Jede Zeile mit einem gefüllten "Stunden"-Feld (z.B. "1,08") ist eine Buchung
   *   - Stempel-Zeilen (Kommen, Pause, Gehen) haben KEIN Stunden-Feld → werden ignoriert
   *   - Von/Bis wird aus den beiden Zeitspalten derselben Zeile gelesen
   *
   * Spaltenreihenfolge laut Screenshot: Tag | Datum | bis | von | Stunden | Art/Auftrag | ...
   */
  function leseBestehendeAuftraege() {
    const eintraege = [], gesehen = new Set();

    // HH:MM Zeitformat
    const zeitRegex = /^\d{2}:\d{2}$/;

    // Stunden-Erkennung: NUR "1,08" / "8,13" (Komma + 2 Stellen) ODER
    // Ganzzahl 1–24. Schließt explizit aus:
    //   - Auftragsnummern (7+ Stellen, z.B. "4504398")
    //   - Datumsangaben  (z.B. "02.03.2026")
    //   - Dezimalzahlen mit Punkt statt Komma (nicht SAP-Standard)
    const istStunden = w => {
      if (!w) return false;
      if (/^\d{1,2},\d{2}$/.test(w)) return true;          // z.B. 1,08 / 8,13
      if (/^\d{1,2}$/.test(w)) { const n = parseInt(w,10); return n >= 1 && n <= 24; }
      return false;
    };

    for (const doc of alleDocs()) {
      // Selektoren von spezifisch → generisch, damit SAP-Varianten alle abgedeckt sind
      // Wichtig: generisches "tr" als letzter Fallback, aber Stempel-Filterung
      // erfolgt ausschließlich über das Fehlen der Stunden-Zelle
      const selektoren = [
        '[class*="sapUiTableRow"]',
        '[class*="sapMListTbl"] tr',
        '[class*="sapMLIB"]',
        'tr',  // generischer Fallback
      ];

      // Alle Zeilen aus allen Selektoren, Duplikate per Set entfernen
      const zeilenSet = new Set();
      for (const sel of selektoren) {
        try { doc.querySelectorAll(sel).forEach(el => zeilenSet.add(el)); } catch(e) {}
      }
      log(`🔍 Zeilen gefunden: ${zeilenSet.size} (Doc: ${doc.title || doc.URL || 'unbekannt'})`);

      for (const zeile of zeilenSet) {
        const zellen = [...zeile.querySelectorAll('td')];
        if (zellen.length < 4) continue;

        const werte = zellen.map(td => (td.innerText || td.textContent || '').trim());

        // Stunden-Zelle MUSS vorhanden sein — das ist das einzige zuverlässige
        // Unterscheidungsmerkmal zwischen Buchungszeile und Stempel-Zeile
        const stundenIdx = werte.findIndex(w => istStunden(w));
        if (stundenIdx === -1) {
          log('⏭ Stempel-Zeile (kein Stunden-Feld):', werte.filter(Boolean).slice(0,5).join(' | '));
          continue;
        }

        // Alle HH:MM Zeiten der Zeile, sortiert → kleinste = von, größte = bis
        const zeiten = werte.filter(w => zeitRegex.test(w)).sort((a,b) => a.localeCompare(b));
        if (zeiten.length < 2) {
          log('⚠️ Stunden-Zeile hat < 2 Zeiten:', werte.filter(Boolean).slice(0,5).join(' | '));
          continue;
        }

        const von = zeiten[0], bis = zeiten[zeiten.length - 1];
        if (von === bis) continue;

        const key = `${von}:${bis}`;
        if (!gesehen.has(key)) {
          gesehen.add(key);
          eintraege.push({ von, bis });
          log('📌 Gebuchter Auftrag (Stunden=' + werte[stundenIdx] + '):', von, '\u2013', bis);
        }
      }
    }

    log(`📌 Bestehende Aufträge gesamt: ${eintraege.length}`);
    return eintraege;
  }

  /**
   * Prüft ob ein Block bereits als Auftrag vorhanden ist.
   * Toleranz: ±1 Minute
   */
  function istBereitsGebucht(block, bestehende) {
    const parse = z => { const [h,m] = z.split(':').map(Number); return h*60+m; };
    const bVon = parse(block.von), bBis = parse(block.bis);
    for (const e of bestehende) {
      const eVon = parse(e.von), eBis = parse(e.bis);
      if (Math.abs(eVon - bVon) <= 1 && Math.abs(eBis - bBis) <= 1) {
        return true;
      }
    }
    return false;
  }

  // ─── Zeitfeld befüllen ─────────────────────────────────────────────────────

  async function fuelleZeitfeld(doc, feldId, wert) {
    log(`  ⌨️ Fülle #${feldId} = "${wert}"`);
    const el = doc.getElementById(feldId);
    if (!el) { log(`  ❌ #${feldId} nicht gefunden`); return false; }

    const ns = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
    const setVal = v => { if (ns?.set) ns.set.call(el, v); else el.value = v; };

    el.focus();
    await warte(100);

    el.select();
    await warte(100);
    setVal('');
    el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', keyCode: 46, bubbles: true }));
    el.dispatchEvent(new Event('input', { bubbles: true }));
    await warte(100);

    for (const char of wert) {
      const cur = el.value;
      setVal(cur + char);
      el.dispatchEvent(new KeyboardEvent('keydown',  { key: char, keyCode: char.charCodeAt(0), bubbles: true, cancelable: true }));
      el.dispatchEvent(new KeyboardEvent('keypress', { key: char, keyCode: char.charCodeAt(0), bubbles: true, cancelable: true }));
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new KeyboardEvent('keyup',    { key: char, keyCode: char.charCodeAt(0), bubbles: true, cancelable: true }));
      await warte(100);
    }

    el.dispatchEvent(new Event('change', { bubbles: true }));
    await warte(100);
    el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, bubbles: true, cancelable: true }));
    el.dispatchEvent(new KeyboardEvent('keyup',   { key: 'Tab', keyCode: 9, bubbles: true, cancelable: true }));
    el.blur();

    log(`  ⏳ Warte auf SAP-Validierung...`);
    await warte(2500);
    log(`  ✅ #${feldId} = "${el.value}"`);
    return true;
  }

  // ─── Einzelnen Block buchen ────────────────────────────────────────────────

  async function buche(block) {
    const sapDoc = getSapDoc();
    if (!sapDoc) { alert('❌ SAP-Dokument nicht gefunden.'); return false; }

    let vonFeld = sapDoc.getElementById('WD6E');
    const formularBereitsOffen = vonFeld && vonFeld.offsetParent !== null;

    if (!formularBereitsOffen) {
      const oeffner = sapDoc.getElementById('WD9D');
      if (!oeffner) { alert('❌ WD9D nicht gefunden.'); return false; }
      log('▶ Öffne Formular (WD9D klicken)...');
      oeffner.click();
      await warte(1000);

      vonFeld = sapDoc.getElementById('WD6E');
      if (!vonFeld || vonFeld.offsetParent === null) {
        await warte(1000);
        vonFeld = sapDoc.getElementById('WD6E');
      }
    } else {
      log('ℹ️ Formular war bereits offen');
    }

    if (!vonFeld) { log('❌ WD6E nicht gefunden'); return false; }
    log('✅ Formular offen, WD6E =', vonFeld.value);

    await fuelleZeitfeld(sapDoc, 'WD6E', block.von);
    await fuelleZeitfeld(sapDoc, 'WD70', block.bis);

    await warte(500);
    const von = sapDoc.getElementById('WD6E')?.value;
    const bis = sapDoc.getElementById('WD70')?.value;
    log(`🔍 Vor Submit: WD6E="${von}" WD70="${bis}"`);

    if (von !== block.von) await fuelleZeitfeld(sapDoc, 'WD6E', block.von);
    if (bis !== block.bis) await fuelleZeitfeld(sapDoc, 'WD70', block.bis);
    await warte(500);

    const submitBtn = sapDoc.getElementById('WD9D');
    if (!submitBtn) { log('❌ WD9D für Submit nicht gefunden'); return false; }
    log('💾 Speichern (WD9D klicken)...');
    submitBtn.click();

    await warte(2000);
    log('✅ Block gespeichert:', block.von, '–', block.bis);
    return true;
  }

  // ─── Hauptlogik: Gesamten Tag prüfen und buchen ────────────────────────────

  async function bucheGesamtenTag(btn) {
    btn.disabled = true;
    btn.innerText = '⏳ Analysiere Tag...';

    try {
      // 1. Zeitstempel lesen
      const stempel = leseStempel();
      if (!stempel.length) {
        alert('⚠️ Keine Zeitstempel erkannt!\n\nBitte stelle sicher, dass die Tagesansicht mit Kommen/Gehen-Stempeln geöffnet ist.');
        return;
      }
      log(`📊 ${stempel.length} Stempel erkannt`);

      // 2. Soll-Blöcke berechnen (ganzer Tag)
      const alleBloecke = berechneBloecke(stempel);
      if (!alleBloecke.length) {
        alert('⚠️ Keine buchbaren Blöcke aus den Stempeln berechenbar.\n\nPrüfe ob Kommen/Gehen-Stempel vorhanden sind.');
        return;
      }

      // 3. Bestehende Aufträge prüfen
      btn.innerText = '⏳ Prüfe bestehende Aufträge...';
      const bestehende = leseBestehendeAuftraege();
      log(`📌 ${bestehende.length} bestehende Aufträge gefunden`);

      // 4. Duplikate herausfiltern
      const zuBuchen   = alleBloecke.filter(b => !istBereitsGebucht(b, bestehende));
      const uebersprungen = alleBloecke.filter(b =>  istBereitsGebucht(b, bestehende));

      log(`✅ Zu buchen: ${zuBuchen.length}, übersprungen (bereits vorhanden): ${uebersprungen.length}`);

      // 5. Zusammenfassung anzeigen
      let zusammenfassung = `📋 Tagesanalyse abgeschlossen\n`;
      zusammenfassung    += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
      zusammenfassung    += `Erkannte Blöcke gesamt: ${alleBloecke.length}\n\n`;

      if (uebersprungen.length) {
        zusammenfassung += `✅ Bereits gebucht (${uebersprungen.length}):\n`;
        uebersprungen.forEach(b => { zusammenfassung += `  • ${b.von} – ${b.bis}\n`; });
        zusammenfassung += '\n';
      }

      if (!zuBuchen.length) {
        zusammenfassung += `🎉 Alle Blöcke sind bereits als Auftrag vorhanden.\nNichts zu tun!`;
        alert(zusammenfassung);
        return;
      }

      zusammenfassung += `⏳ Noch zu buchen (${zuBuchen.length}):\n`;
      zuBuchen.forEach(b => { zusammenfassung += `  • ${b.von} – ${b.bis}\n`; });
      zusammenfassung += `\nJetzt alle ${zuBuchen.length} Block(e) buchen?`;

      if (!confirm(zusammenfassung)) return;

      // 6. Alle ausstehenden Blöcke nacheinander buchen
      let erfolgreich = 0, fehlgeschlagen = 0;

      for (let i = 0; i < zuBuchen.length; i++) {
        const b = zuBuchen[i];
        btn.innerText = `⏳ Buche ${i+1}/${zuBuchen.length}: ${b.von}–${b.bis}`;
        log(`▶ Buche Block ${i+1}/${zuBuchen.length}: ${b.von} – ${b.bis}`);

        const ok = await buche(b);
        if (ok) {
          erfolgreich++;
          log(`✅ Block ${i+1} erfolgreich gebucht`);
        } else {
          fehlgeschlagen++;
          log(`❌ Block ${i+1} fehlgeschlagen`);
          const weiter = confirm(
            `❌ Block ${i+1} (${b.von}–${b.bis}) fehlgeschlagen.\n\n` +
            `${zuBuchen.length - i - 1} Block(e) verbleiben.\nMit nächstem Block fortfahren?`
          );
          if (!weiter) break;
        }

        // Kurze Pause zwischen Buchungen
        if (i < zuBuchen.length - 1) await warte(500);
      }

      // 7. Abschlussbericht
      let bericht = `📊 Buchung abgeschlossen\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
      bericht += `✅ Erfolgreich gebucht: ${erfolgreich}\n`;
      if (fehlgeschlagen) bericht += `❌ Fehlgeschlagen:      ${fehlgeschlagen}\n`;
      if (uebersprungen.length) bericht += `⏭ Bereits vorhanden:  ${uebersprungen.length}\n`;
      alert(bericht);

    } catch(e) {
      alert('❌ Fehler: ' + e.message);
      log(e);
    } finally {
      btn.disabled = false;
      btn.innerText = '⏱ Produktivstunden buchen';
    }
  }

  // ─── Button einfügen ───────────────────────────────────────────────────────

  function zeigeButton() {
    const targetDoc  = (window === window.top) ? document : window.top.document;
    const targetBody = targetDoc.body;
    if (!targetBody || targetDoc.getElementById('ab-btn')) return;

    const btn = targetDoc.createElement('button');
    btn.id = 'ab-btn';
    btn.innerText = '⏱ Produktivstunden buchen';
    Object.assign(btn.style, {
      position:     'fixed',
      bottom:       '20px',
      right:        '20px',
      zIndex:       '2147483647',
      background:   C.farbe,
      color:        '#fff',
      border:       '2px solid #fff',
      borderRadius: '10px',
      padding:      '13px 22px',
      fontSize:     '15px',
      fontWeight:   'bold',
      cursor:       'pointer',
      fontFamily:   'Arial,sans-serif',
      boxShadow:    '0 6px 20px rgba(0,0,0,0.45)',
    });
    btn.onmouseenter = () => btn.style.background = '#004fa3';
    btn.onmouseleave = () => btn.style.background = C.farbe;
    btn.onclick = () => bucheGesamtenTag(btn);

    targetBody.appendChild(btn);
    log('✅ Button eingefügt');
  }

  // ─── Polling & SPA-Support ─────────────────────────────────────────────────

  let n = 0;
  const poll = setInterval(() => {
    zeigeButton();
    const d = (window === window.top) ? document : window.top.document;
    if (d.getElementById('ab-btn') || ++n > 100) clearInterval(poll);
  }, 400);

  ['pushState', 'replaceState'].forEach(m => {
    const o = history[m].bind(history);
    history[m] = (...a) => { o(...a); n = 0; };
  });
  window.addEventListener('hashchange', () => { n = 0; });

})();
Editor is loading...
Leave a Comment