/* global Excel, console */ import { SheetInfo, SheetMappingStatus, TARGET_COLUMNS, ConsolidateSettings } from "./models"; /** * Holt alle sichtbaren Arbeitsblätter des aktuellen Workbooks, außer "Gesamtliste". */ export async function getAvailableSheets(): Promise { return Excel.run(async (context) => { const sheets = context.workbook.worksheets; sheets.load("items/name, items/visibility"); await context.sync(); return sheets.items .filter((sheet) => sheet.name !== "Gesamtliste" && sheet.visibility === Excel.SheetVisibility.visible) .map((sheet) => ({ id: sheet.name, name: sheet.name, })); }); } /** * Extrahiert für ausgewählte Blätter die Kopfzeile und das Mapping-Ergebnis. * Die Funktion durchsucht die ersten 50 Zeilen (inkl. leerer Zeilen), * um die eigentlichen Spaltennamen zu finden. */ export async function detectHeadersAndColumns(sheets: SheetInfo[]): Promise { return Excel.run(async (context) => { const results: SheetMappingStatus[] = []; for (const sheetInfo of sheets) { let values: any[][] = []; if (sheetInfo.isExternal && sheetInfo.externalData) { // Bei externen Dateien nehmen wir einfach die ersten 50 Zeilen aus den geparsten Daten values = sheetInfo.externalData.slice(0, 50); } else { // Bei internen Dateien laden wir die Daten über die Excel API const sheet = context.workbook.worksheets.getItem(sheetInfo.name); const range = sheet.getRange("A1:AX50"); range.load("values"); await context.sync(); values = range.values; } let bestRowIndex = 0; let maxMatches = -1; // Finde die Zeile, die die meisten Übereinstimmungen mit unseren Zielspalten hat for (let r = 0; r < values.length; r++) { const rowData = values[r]; if (!rowData) continue; // Safety check in case external sheet has sparse arrays let matches = 0; for (let c = 0; c < rowData.length; c++) { const cellStr = String(rowData[c]).trim().toLowerCase(); if (TARGET_COLUMNS.some((tc) => tc.aliases.includes(cellStr))) { matches++; } } if (matches > maxMatches) { maxMatches = matches; bestRowIndex = r; } } // Sicherstellen, dass values[bestRowIndex] existiert (falls Blatt komplett leer ist) // Wenn maxMatches immer noch -1 ist (oder 0), dann wurde absolut nichts gefunden const headerRow = (maxMatches > 0 && values[bestRowIndex]) ? values[bestRowIndex] : []; const status = buildSheetMappingStatus(sheetInfo, headerRow, maxMatches > 0 ? bestRowIndex : 0); results.push(status); } return results; }); } /** * Erlaubt das Neuladen für eine spezifische (vom Nutzer geänderte) Kopfzeile. */ export async function detectHeadersForSingleSheetRow(sheetInfo: SheetInfo, rowIndex: number): Promise { if (sheetInfo.isExternal && sheetInfo.externalData) { // Externe Datei const headerRow = sheetInfo.externalData[rowIndex] || []; return buildSheetMappingStatus(sheetInfo, headerRow, rowIndex); } else { // Interne Datei return Excel.run(async (context) => { const sheet = context.workbook.worksheets.getItem(sheetInfo.name); // rowIndex ist 0-basiert, Range ist 1-basiert const range = sheet.getRangeByIndexes(rowIndex, 0, 1, 50); // Lese Spalten 0-49 in der gewünschten Zeile range.load("values"); await context.sync(); return buildSheetMappingStatus(sheetInfo, range.values[0], rowIndex); }); } } /** * Hilfsfunktion zum Erstellen des State-Objekts für das Mapping. */ function buildSheetMappingStatus(sheetInfo: SheetInfo, headerRow: any[], rowIndex: number): SheetMappingStatus { const availableColumns = headerRow.map((val, idx) => ({ name: String(val).trim() || `(Leere Spalte ${idx + 1})`, index: idx, })).filter(col => col.name !== `(Leere Spalte ${col.index + 1})`); const mappings = TARGET_COLUMNS.map((tc) => { const foundIdx = headerRow.findIndex((cell) => tc.aliases.includes(String(cell).trim().toLowerCase())); return { targetColumn: tc.id, sourceColumnIndex: foundIdx, }; }); return { sheetId: sheetInfo.id, sheetName: sheetInfo.name, // Für Anzeige headerRowIndex: rowIndex, availableColumns, mappings, isExternal: sheetInfo.isExternal, fileName: sheetInfo.fileName, externalData: sheetInfo.externalData }; } /** * Führt die eigentliche Konsolidierung aus allen Arbeitsblättern durch und schreibt * das Ergebnis in das Blatt "Gesamtliste" (jetzt "Kabelliste"). */ export async function consolidateData(mappings: SheetMappingStatus[], settings: ConsolidateSettings): Promise { return Excel.run(async (context) => { let rowsConsolidated = 0; const finalData: any[][] = []; // Daten auslesen und mergen for (const mapping of mappings) { if (mapping.mappings.length === 0) continue; let textValues: any[][] = []; let dataStartRowOffset = 0; const targetIndicesMap = mapping.mappings.map(m => m.sourceColumnIndex); if (mapping.isExternal && mapping.externalData) { // Bei externen Dateien textValues = mapping.externalData; dataStartRowOffset = mapping.headerRowIndex + 1; } else { // Bei internen Dateien const sheet = context.workbook.worksheets.getItem(mapping.sheetName); const usedRange = sheet.getUsedRange(); usedRange.load(["rowIndex", "rowCount", "columnCount", "text"]); await context.sync(); const startRowIdx = usedRange.rowIndex; // Offset berechnen: Wie viele Zeilen nach dem Start des UsedRange liegt die erste Datenzeile? dataStartRowOffset = (mapping.headerRowIndex + 1) - startRowIdx; // Wenn die Daten unterhalb des UsedRange beginnen oder das Blatt leer ist if (dataStartRowOffset >= usedRange.rowCount || usedRange.rowCount === 0) { continue; } textValues = usedRange.text; // Text returns the exact formatted strings } // Schleife ab Zeile nach Header bis Ende for (let r = Math.max(0, dataStartRowOffset); r < textValues.length; r++) { const textRow = textValues[r] || []; // Prüfen ob Row komplett leer ist (oder zumindest in den Mapped Columns leer) const isRowEmpty = targetIndicesMap.every(srcColIdx => { if (srcColIdx === -1) return true; const val = textRow[srcColIdx]; return val === null || val === undefined || String(val).trim() === ""; }); if (isRowEmpty) continue; const consolidatedRow: any[] = []; // Die 6 Standard Target Columns for (const srcColIdx of targetIndicesMap) { if (srcColIdx !== -1 && srcColIdx < textRow.length) { consolidatedRow.push(String(textRow[srcColIdx])); } else { consolidatedRow.push(""); } } // Zusatzfelder für die Kabelliste: Länge, gezogen am, von (Monteur), Bemerkung consolidatedRow.push(""); consolidatedRow.push(""); consolidatedRow.push(""); // Wir speichern temporär die Quelle in der letzten Spalte, um sie später für Duplikate zu nutzen const sourceInfo = mapping.isExternal ? `${mapping.fileName || 'Externe Datei'} - ${mapping.sheetName}` : mapping.sheetName; consolidatedRow.push(sourceInfo); finalData.push(consolidatedRow); rowsConsolidated++; } } // --- DUPLIKAT PRÜFUNG --- const cableCountMap = new Map(); // Finde heraus, wie oft jede Kabelnummer vorkommt for (const row of finalData) { const kNr = String(row[0] || "").trim(); if (kNr) { cableCountMap.set(kNr, (cableCountMap.get(kNr) || 0) + 1); } } // Schreibe "Duplikat" inkl. Quelle in die Bemerkungs-Spalte, ansonsten leere die Spalte wieder for (const row of finalData) { const kNr = String(row[0] || "").trim(); const sourceInfo = row[row.length - 1]; // Temporär gespeicherte Info abrufen if (kNr && (cableCountMap.get(kNr) || 0) > 1) { row[row.length - 1] = `Duplikat (aus: ${sourceInfo})`; } else { row[row.length - 1] = ""; // Nur Duplikate erhalten einen Eintrag in "Bemerkung" } } // --- ENDE DUPLIKAT PRÜFUNG --- // Prüfen, ob "Kabelliste" existiert let targetSheet: Excel.Worksheet; let listExists = false; try { targetSheet = context.workbook.worksheets.getItem("Kabelliste"); targetSheet.load("name"); await context.sync(); listExists = true; } catch (e) { listExists = false; } const fullHeaders = [...TARGET_COLUMNS.map(tc => tc.id), "Länge", "gezogen am", "von (Monteur)", "Bemerkung"]; if (listExists) { try { // Existierende Liste aktualisieren // Wir nutzen getItemAt(0) statt eines festen Namens, da der Name "KonsolidierteKabel" bei umbenannten Sicherungskopien blockiert sein kann. const table = targetSheet.tables.getItemAt(0); const bodyRange = table.getDataBodyRange(); bodyRange.load("values, rowCount, columnCount"); await context.sync(); const existingValues = bodyRange.values; const formatChangedQueue: { row: number, col: number }[] = []; const formatDeletedQueue: number[] = []; const formatDuplicateQueue: number[] = []; // NEU: Queue für Duplikate const incomingMap = new Map(); for (const row of finalData) { const kNr = String(row[0] || "").trim(); if (kNr) { if (!incomingMap.has(kNr)) { incomingMap.set(kNr, []); } incomingMap.get(kNr)!.push(row); } } const newRows: any[][] = []; // Update existing & mark deleted for (let r = 0; r < existingValues.length; r++) { const row = existingValues[r]; const kNr = String(row[0] || "").trim(); if (!kNr) continue; if (incomingMap.has(kNr)) { const inValsArray = incomingMap.get(kNr)!; if (inValsArray.length > 0) { const inVals = inValsArray.shift()!; // pop the first one // Geändert prüfen for (let c = 0; c < TARGET_COLUMNS.length; c++) { const oldVal = String(row[c] || "").trim(); const newVal = String(inVals[c] || "").trim(); if (oldVal !== newVal) { existingValues[r][c] = newVal; formatChangedQueue.push({ row: r, col: c }); } } // Prüfen ob Bemerkung aktualisiert werden muss (z.B. neues Duplikat) const bemerkungColIndex = fullHeaders.length - 1; const inBemerkung = String(inVals[bemerkungColIndex] || "").trim(); existingValues[r][bemerkungColIndex] = inBemerkung; if (inBemerkung.startsWith("Duplikat")) { formatDuplicateQueue.push(r); } if (inValsArray.length === 0) { incomingMap.delete(kNr); } } else { // Should theoretically not happen if we delete when 0, but safe fallback formatDeletedQueue.push(r); } } else { // Entfallen formatDeletedQueue.push(r); } } // Verbleibend = Neue Kabel (und überschüssige Duplikate) incomingMap.forEach((newRowsArray) => { newRowsArray.forEach((newRow) => { newRows.push(newRow); }); }); // Werte in die bestehende Tabelle zurückschreiben if (existingValues.length > 0) { bodyRange.values = existingValues; } // Neue Zeilen anhängen let newRowsStartIndex = existingValues.length; if (newRows.length > 0) { table.rows.add(null as any, newRows); } await context.sync(); // Formate anwenden const currentBody = table.getDataBodyRange(); // Alte Formatierungen zurücksetzen, damit behobene Konflikte wieder normal aussehen currentBody.format.fill.clear(); currentBody.format.font.strikethrough = false; // Geänderte Zellen markieren for (const cell of formatChangedQueue) { currentBody.getCell(cell.row, cell.col).format.fill.color = settings.colorChanged; } // Entfallene Zeilen markieren for (const rowIndex of formatDeletedQueue) { currentBody.getRow(rowIndex).format.fill.color = settings.colorDeleted; //Zellen durchgestrichen currentBody.getRow(rowIndex).format.font.strikethrough = true; } // Neue Zeilen markieren for (let i = 0; i < newRows.length; i++) { const rowIndex = newRowsStartIndex + i; const rowData = newRows[i]; if (String(rowData[fullHeaders.length - 1]).trim().startsWith("Duplikat")) { currentBody.getRow(rowIndex).format.fill.color = settings.colorDuplicate; } else { currentBody.getRow(rowIndex).format.fill.color = settings.colorNew; } } // Bestehende Duplikat-Zeilen markieren for (const rowIndex of formatDuplicateQueue) { currentBody.getRow(rowIndex).format.fill.color = settings.colorDuplicate; } table.getRange().format.autofitColumns(); await context.sync(); targetSheet.activate(); return rowsConsolidated; } catch (error) { console.error("Fehler beim Aktualisieren der Kabelliste:", error); throw new Error("Fehler beim Aktualisieren der 'Kabelliste'."); } } else { try { // Neu erstellen (wie bisher) targetSheet = context.workbook.worksheets.add("Kabelliste"); const totalRowsCount = finalData.length + 1; // +1 für Header const totalColsCount = fullHeaders.length; const targetRange = targetSheet.getRangeByIndexes(0, 0, totalRowsCount, totalColsCount); const allValues = [fullHeaders, ...finalData]; const formatArray: string[][] = []; for (let i = 0; i < totalRowsCount; i++) { formatArray.push(new Array(totalColsCount).fill("@")); } targetRange.numberFormat = formatArray; targetRange.values = allValues; await context.sync(); const table = targetSheet.tables.add(targetRange, true /* hasHeaders */); table.style = "TableStyleLight9"; table.showFilterButton = true; // WICHTIG: Excel überschreibt manuelle Formate, wenn wir den TableStyle nicht zuerst synchronisieren! await context.sync(); // Duplikate beim Neu-Erstellen einfärben: const bodyRange = table.getDataBodyRange(); for (let i = 0; i < finalData.length; i++) { if (String(finalData[i][fullHeaders.length - 1]).trim().startsWith("Duplikat")) { bodyRange.getRow(i).format.fill.color = settings.colorDuplicate; } } targetRange.format.autofitColumns(); await context.sync(); targetSheet.activate(); return rowsConsolidated; } catch (error) { console.error("Fehler beim Erstellen der Kabelliste:", error); throw new Error("Fehler beim Erstellen der 'Kabelliste'. Möglicherweise ist die Arbeitsmappe schreibgeschützt.\n" + error); } } }); } /** * Neue Funktion für den Vergleich von zwei Listen */ export async function compareData( oldMapping: SheetMappingStatus, newMapping: SheetMappingStatus, settings: ConsolidateSettings ): Promise { return Excel.run(async (context) => { // Hilfsfunktion zum Laden von Daten aus einem Mapping const loadData = async (mapping: SheetMappingStatus): Promise => { if (mapping.isExternal && mapping.externalData) { // Return data starting after the header row return mapping.externalData.slice(mapping.headerRowIndex + 1); } else { const sheet = context.workbook.worksheets.getItem(mapping.sheetName); const usedRange = sheet.getUsedRange(); usedRange.load(["rowIndex", "rowCount", "text"]); await context.sync(); const startRowIdx = usedRange.rowIndex; const dataStartRowOffset = (mapping.headerRowIndex + 1) - startRowIdx; if (dataStartRowOffset >= usedRange.rowCount || usedRange.rowCount === 0) { return []; } // Return data starting from the calculated offset return usedRange.text.slice(dataStartRowOffset); } }; const oldData = await loadData(oldMapping); const newData = await loadData(newMapping); // K-Nr Indices finden const getColIndex = (mapping: SheetMappingStatus, targetId: string) => { const m = mapping.mappings.find(m => m.targetColumn === targetId); return m ? m.sourceColumnIndex : -1; }; const oldKNrIdx = getColIndex(oldMapping, "K-Nr."); const newKNrIdx = getColIndex(newMapping, "K-Nr."); if (oldKNrIdx === -1 || newKNrIdx === -1) { throw new Error("K-Nr. Spalte muss in beiden Listen zugeordnet sein, um vergleichen zu können."); } // Zu vergleichende Spalten (ohne die Ausnahmen) const columnsToCompare = TARGET_COLUMNS.filter( tc => tc.id !== "K-Nr." && tc.id !== "Länge" && tc.id !== "gezogen am" && tc.id !== "von" // 'von' hier ist Monteur in Gesamtliste, aber TARGET_COLUMNS['von'] ist Start! ); // Wir vergleichen ALLE gemappten Target-Columns, aber ignorieren (wie gefordert) Lnge/gezogen am/gezogen von, // welche gar nicht in TARGET_COLUMNS sind! "von" und "nach" in TARGET_COLUMNS sind die Räume/Geräte. // Das heißt wir ignorieren hier nichts extra aus TARGET_COLUMNS, da die spezifischen "gezogen am" etc Felder // erst in der Consolidate Funktion hardcoded angehängt werden und gar nicht gemappt sind! const allTargetCols = TARGET_COLUMNS.map(tc => tc.id); // "von Raum" und "nach Raum" Indizes für Umverlegt-Check const vonRaumId = "von Raum"; const nachRaumId = "nach Raum"; // Mapped Header für die Ausgabetabelle const outputHeaders = ["K-Nr.", "Status", "Änderungsdetails", ...allTargetCols.filter(id => id !== "K-Nr.")]; // Maps erstellen const oldMap = new Map(); const oldDuplicates = new Set(); for (const row of oldData) { const knr = String(row[oldKNrIdx] || "").trim(); if (!knr) continue; if (oldMap.has(knr)) { oldDuplicates.add(knr); } oldMap.set(knr, row); } const newMap = new Map(); const newDuplicates = new Set(); for (const row of newData) { const knr = String(row[newKNrIdx] || "").trim(); if (!knr) continue; if (newMap.has(knr)) { newDuplicates.add(knr); } newMap.set(knr, row); } const finalOutput: any[][] = []; const formatQueue: { row: number, state: "added" | "removed" | "changed" | "moved", formatColRanges?: number[] }[] = []; let currentRowIndex = 1; // 1-based, header is 0 // 1. Check old list against new list (Removed / Changed / Moved) oldMap.forEach((oldRow, knr) => { if (!newMap.has(knr)) { // Entfernt const outRow = [knr, "Entfernt", "Kabel fehlt in der neuen Liste"]; for (const colId of allTargetCols) { if (colId === "K-Nr.") continue; const idx = getColIndex(oldMapping, colId); outRow.push(idx !== -1 ? oldRow[idx] : ""); } if (oldDuplicates.has(knr)) outRow[2] += " (Duplikat in alter Liste)"; finalOutput.push(outRow); formatQueue.push({ row: currentRowIndex, state: "removed" }); currentRowIndex++; } else { // Existiert in beiden -> Vergleichen const newRow = newMap.get(knr)!; let isChanged = false; let isMoved = false; const changes: string[] = []; const changedCols: number[] = []; for (const colId of allTargetCols) { if (colId === "K-Nr.") continue; const oIdx = getColIndex(oldMapping, colId); const nIdx = getColIndex(newMapping, colId); const oVal = oIdx !== -1 ? String(oldRow[oIdx] || "").trim() : ""; const nVal = nIdx !== -1 ? String(newRow[nIdx] || "").trim() : ""; if (oVal !== nVal) { isChanged = true; changes.push(`${colId}: ${oVal || "Leer"} -> ${nVal || "Leer"}`); // Output Header mapping: 0=KNr, 1=Status, 2=Details, 3... = the rest (offset by 3) const outputColIdx = outputHeaders.indexOf(colId); if (outputColIdx !== -1) { changedCols.push(outputColIdx); } if (colId === vonRaumId || colId === nachRaumId) { isMoved = true; } } } if (isChanged) { const status = isMoved ? "Umverlegt" : "Geändert"; let details = changes.join(" | "); if (oldDuplicates.has(knr) || newDuplicates.has(knr)) { details += " (Duplikat gefunden!)"; } const outRow = [knr, status, details]; for (const colId of allTargetCols) { if (colId === "K-Nr.") continue; const idx = getColIndex(newMapping, colId); // Zeigen die NEUEN Werte! outRow.push(idx !== -1 ? newRow[idx] : ""); } finalOutput.push(outRow); formatQueue.push({ row: currentRowIndex, state: isMoved ? "moved" : "changed", formatColRanges: changedCols }); currentRowIndex++; } } }); // 2. Check new list against old list (Added) newMap.forEach((newRow, knr) => { if (!oldMap.has(knr)) { // Hinzugefügt const outRow = [knr, "Neu", "Kabel neu hinzugefügt"]; for (const colId of allTargetCols) { if (colId === "K-Nr.") continue; const idx = getColIndex(newMapping, colId); outRow.push(idx !== -1 ? newRow[idx] : ""); } if (newDuplicates.has(knr)) outRow[2] += " (Duplikat in neuer Liste)"; finalOutput.push(outRow); formatQueue.push({ row: currentRowIndex, state: "added" }); currentRowIndex++; } }); // 3. Wenn keine Änderungen if (finalOutput.length === 0) { return 0; } // 4. In "Änderungsdokumentation" schreiben const sheetName = "Änderungsdokumentation"; let targetSheet: Excel.Worksheet; try { targetSheet = context.workbook.worksheets.getItem(sheetName); targetSheet.delete(); // Delete if exists to create a fresh one await context.sync(); } catch (e) { // Does not exist } targetSheet = context.workbook.worksheets.add(sheetName); const totalRowsCount = finalOutput.length + 1; // +1 für Header const totalColsCount = outputHeaders.length; const targetRange = targetSheet.getRangeByIndexes(0, 0, totalRowsCount, totalColsCount); const allValues = [outputHeaders, ...finalOutput]; const formatArray: string[][] = []; for (let i = 0; i < totalRowsCount; i++) { formatArray.push(new Array(totalColsCount).fill("@")); } targetRange.numberFormat = formatArray; targetRange.values = allValues; await context.sync(); const table = targetSheet.tables.add(targetRange, true /* hasHeaders */); table.style = "TableStyleLight9"; table.showFilterButton = true; await context.sync(); const bodyRange = table.getDataBodyRange(); // 5. Formate anwenden for (const fmt of formatQueue) { // row is 1-based (from data), bodyRange getRow is 0-based const excelRow = bodyRange.getRow(fmt.row - 1); if (fmt.state === "removed") { excelRow.format.fill.color = settings.colorDeleted; excelRow.format.font.strikethrough = true; excelRow.format.font.color = "#990000"; // Dunkelrot } else if (fmt.state === "added") { excelRow.format.fill.color = settings.colorNew; } else if (fmt.state === "moved") { // Umverlegt z.B. hellblau oder lila (wir nehmen ein vordefiniertes Gelb oder passen settings an, hardcoded für jetzt) excelRow.format.fill.color = "#d9edf7"; // Light Blue // Einzelne Zellen markieren if (fmt.formatColRanges) { for (const col of fmt.formatColRanges) { bodyRange.getCell(fmt.row - 1, col).format.fill.color = "#bce8f1"; // Stronger Blue } } } else if (fmt.state === "changed") { excelRow.format.fill.color = settings.colorChanged; // Einzelne Zellen markieren if (fmt.formatColRanges) { for (const col of fmt.formatColRanges) { bodyRange.getCell(fmt.row - 1, col).format.fill.color = "#ffe699"; // Stronger Yellow } } } } targetRange.format.autofitColumns(); await context.sync(); targetSheet.activate(); return finalOutput.length; }); }