using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using MiniExcelLibs; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; namespace ExcelKv.Core; public class RegionConfig { public int TopHeaderStartRow { get; set; } = 0; public int TopHeaderDepth { get; set; } = 3; public int LeftHeaderStartCol { get; set; } = 0; public int LeftHeaderWidth { get; set; } = 4; // Optional Parsing Limits (inclusive indices) public int? DataEndRow { get; set; } public int? DataEndCol { get; set; } } public interface IStorageWrapper { Task SetAsync(string key, string value); // New: Metadata overload for Traceability Task SetAsync(string key, string value, int row, int col); Task IncrementAsync(string key, double value); } // Live Aggregator Helper (Moved here for shared access) public class LiveAggregator { private static readonly System.Text.RegularExpressions.Regex MetricPattern = new System.Text.RegularExpressions.Regex(@"(수량|합계|Volume|Weight|Total|Usage)", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); public static bool IsMetric(string colName) => MetricPattern.IsMatch(colName); public static string GenerateGroupKey(string sheetName, string category, string spec, string metricName) => $"Stats:{Use_(category)}:{Use_(spec)}:{Use_(metricName)}"; private static string Use_(string s) => string.IsNullOrWhiteSpace(s) ? "Unknown" : s.Replace(" ", "_"); } public class ExcelLoader { public static async Task> GetSheetNamesAsync(string filePath) { return await Task.Run(() => MiniExcel.GetSheetNames(filePath).ToList()); } public static async Task> GetPreviewRowsAsync(string filePath, string sheetName, int count = 50) { return await Task.Run(() => { var rows = MiniExcel.Query(filePath, sheetName: sheetName, useHeaderRow: false).Take(count).ToList(); List result = new(); foreach(IDictionary r in rows) { result.Add(FlattenDictionaryRow(r)); } return result; }); } public static async Task ProcessFileAsync( string filePath, string sheetName, RegionConfig config, IStorageWrapper storage, SchemaRegistry registry) { Console.WriteLine($"[Core.Loader] Processing {filePath} Sheet: {sheetName}..."); var rows = MiniExcel.Query(filePath, sheetName: sheetName, useHeaderRow: false).ToList(); var mergedRanges = GetMergedRanges(filePath, sheetName); // Validation int dataStartRow = config.TopHeaderStartRow + config.TopHeaderDepth; if (rows.Count <= dataStartRow) return 0; int limitRow = config.DataEndRow.HasValue ? Math.Min(rows.Count, config.DataEndRow.Value + 1) : rows.Count; // Flatten all rows up to limit for consistent indexing var flattenedRows = new List(); int globalMaxCol = 0; for (int i = 0; i < limitRow; i++) { var flat = FlattenDictionaryRow((IDictionary)rows[i]); flattenedRows.Add(flat); if (flat.Length > globalMaxCol) globalMaxCol = flat.Length; } int maxMergeCol = mergedRanges.Any() ? mergedRanges.Max(m => m.EndCol + 1) : 0; int normalizedMaxCol = Math.Max(globalMaxCol, maxMergeCol); ApplyMergedValues(flattenedRows, mergedRanges, normalizedMaxCol); // 1. Analyze Top Headers (Data Columns) var topHeaderRows = flattenedRows .Skip(config.TopHeaderStartRow) .Take(config.TopHeaderDepth) .ToList(); int headerMaxCol = topHeaderRows.Any() ? Math.Max(normalizedMaxCol, topHeaderRows.Max(r => r.Length)) : normalizedMaxCol; var filledHeaders = topHeaderRows; // Flatten Data Headers (Right Side) var topAxisKeys = FlattenTopHeaders(filledHeaders, config.LeftHeaderStartCol + config.LeftHeaderWidth, headerMaxCol); // ... (Left Axis Header logic omitted for brevity, unchanged) ... // ** New: Extract Left Axis Headers (Corner Region) ** // These are the headers *above* the Left Key columns. var leftAxisHeaders = new List(); var bottomHeaderRow = flattenedRows[dataStartRow - 1]; for (int c = 0; c < config.LeftHeaderWidth; c++) { int absCol = config.LeftHeaderStartCol + c; string headerVal = (absCol < bottomHeaderRow.Length) ? bottomHeaderRow[absCol] : $"Col{c}"; leftAxisHeaders.Add(headerVal); } string ns = System.IO.Path.GetFileName(filePath) + "_" + sheetName; registry.RegisterSchema(ns, topAxisKeys); // 2. Process Data Rows string[] lastLeftValues = new string[config.LeftHeaderWidth]; int processedCount = 0; for (int i = dataStartRow; i < limitRow; i++) { var rowVals = flattenedRows[i]; // ... (Left Key Logic Unchanged) ... var currentLeftParts = new List(); bool rowHasContent = false; for (int c = 0; c < config.LeftHeaderWidth; c++) { int absCol = config.LeftHeaderStartCol + c; string val = (absCol < rowVals.Length) ? rowVals[absCol] : ""; if (string.IsNullOrWhiteSpace(val)) val = lastLeftValues[c]; else lastLeftValues[c] = val; if(!string.IsNullOrWhiteSpace(val)) { currentLeftParts.Add(val); rowHasContent = true; } } if (!rowHasContent) continue; string leftKey = string.Join("__", currentLeftParts); // B. Map Values int limitCol = config.DataEndCol.HasValue ? Math.Min(rowVals.Length, config.DataEndCol.Value + 1) : rowVals.Length; for (int c = config.LeftHeaderStartCol + config.LeftHeaderWidth; c < limitCol; c++) { int topIndex = c - (config.LeftHeaderStartCol + config.LeftHeaderWidth); if (topIndex < 0 || topIndex >= topAxisKeys.Count) continue; string topKey = topAxisKeys[topIndex]; if (string.IsNullOrWhiteSpace(topKey)) continue; // Skip if no header key string val = rowVals[c]; if (string.IsNullOrWhiteSpace(val)) continue; // ** Feature: Rounding to 6 Decimal Places ** // "All data should be formatted to 6 decimal places by default" if (double.TryParse(val, out double dVal)) { val = Math.Round(dVal, 6).ToString(); // Simple G-format or fixed? User said "Decimal 6 places". // Let's use standard string representation of the rounded 6-digit number to avoid trailing zeros "1.100000". // Math.Round(1.1, 6) -> 1.1. // If user meant "Fixed 6 places" (1.100000), use F6. Usually "representation" means significant digits. // Given SI context, significant precision matters. Let's use normalization (remove trailing zeros) // Update: User said "소수점 6자리까지 표현하는걸로 통일". Could mean truncate or round. Safe bet is Round. } string fullKey = $"{sheetName}:{leftKey}----{topKey}"; await storage.SetAsync(fullKey, val, i, c); if (LiveAggregator.IsMetric(topKey) && double.TryParse(val, out double statVal)) { await storage.IncrementAsync($"Stats:Global:{topKey}", statVal); } } processedCount++; } Console.WriteLine($"[Core.Loader] Finished. Processed: {processedCount} items."); return processedCount; } private static string[] FlattenDictionaryRow(IDictionary rowDict) { var sortedKeys = rowDict.Keys.OrderBy(k => k.Length).ThenBy(k => k).ToList(); var result = new string[sortedKeys.Count]; for (int i = 0; i < sortedKeys.Count; i++) { var val = rowDict[sortedKeys[i]]; result[i] = ExtractCellValue(val); } return result; } // Normalize cell value, preferring cached result for formulas. private static string ExtractCellValue(object val) { if (val == null) return ""; string ToStr(object? o) => o?.ToString() ?? ""; var type = val.GetType(); var typeName = type.FullName ?? ""; // MiniExcel may return an internal ExcelFormula type; try to read cached value. if (typeName.Contains("Formula", StringComparison.OrdinalIgnoreCase)) { var cached = type.GetProperty("Value")?.GetValue(val) ?? type.GetProperty("CachedValue")?.GetValue(val) ?? type.GetProperty("Result")?.GetValue(val); if (cached != null) return ToStr(cached); } var s = ToStr(val); if (s.StartsWith("=")) { var cached = type.GetProperty("CachedValue")?.GetValue(val) ?? type.GetProperty("Value")?.GetValue(val) ?? type.GetProperty("Result")?.GetValue(val); if (cached != null) return ToStr(cached); // If no cached value, avoid persisting the formula string. return ""; } return s; } private static List FlattenTopHeaders(List headerRows, int startCol, int globalMaxCol) { if (headerRows.Count == 0) return new List(); // Ensure we cover all data columns, even if headers are short int maxCol = Math.Max(headerRows.Max(r => r.Length), globalMaxCol); var flatHeaders = new List(); for (int c = startCol; c < maxCol; c++) { var parts = new List(); // Iterate Rows (Depth) for (int r = 0; r < headerRows.Count; r++) { string val = (c < headerRows[r].Length) ? headerRows[r][c] : ""; if (!string.IsNullOrWhiteSpace(val)) parts.Add(val); } // If empty, use "Col_Index" fallback or keep empty? // User schema usually requires keys. If empty, it's skipped in mapping. // Let's keep it empty, but if data exists, it won't map unless we have a key. // If parts is empty, let's leave valid empty string so mapping can decide. flatHeaders.Add(string.Join("__", parts)); } return flatHeaders; } private record MergeRange(int StartRow, int EndRow, int StartCol, int EndCol); private static List GetMergedRanges(string filePath, string sheetName) { var result = new List(); try { using var doc = SpreadsheetDocument.Open(filePath, false); var sheet = doc.WorkbookPart?.Workbook.Descendants().FirstOrDefault(s => s.Name == sheetName); if (sheet == null) return result; var wsPart = doc.WorkbookPart?.GetPartById(sheet.Id!) as WorksheetPart; var mergeCells = wsPart?.Worksheet.Elements().FirstOrDefault(); if (mergeCells == null) return result; foreach (var mc in mergeCells.Elements()) { var (sr, er, sc, ec) = ParseRange(mc.Reference?.Value ?? ""); result.Add(new MergeRange(sr, er, sc, ec)); } } catch { // If merge metadata cannot be read, fall back to no-op. } return result; } private static (int startRow, int endRow, int startCol, int endCol) ParseRange(string reference) { if (string.IsNullOrWhiteSpace(reference)) return (0, 0, 0, 0); if (!reference.Contains(":")) { var (r, c) = ParseCell(reference); return (r, r, c, c); } var parts = reference.Split(':'); var (r1, c1) = ParseCell(parts[0]); var (r2, c2) = ParseCell(parts[1]); return (Math.Min(r1, r2), Math.Max(r1, r2), Math.Min(c1, c2), Math.Max(c1, c2)); } private static (int row, int col) ParseCell(string cellRef) { int row = 0, col = 0; int i = 0; while (i < cellRef.Length && char.IsLetter(cellRef[i])) { col = col * 26 + (char.ToUpperInvariant(cellRef[i]) - 'A' + 1); i++; } string rowStr = cellRef[i..]; int.TryParse(rowStr, out row); // Convert to zero-based indices return (Math.Max(0, row - 1), Math.Max(0, col - 1)); } private static void ApplyMergedValues(List rows, List merges, int maxCols) { NormalizeRows(rows, maxCols); foreach (var merge in merges) { if (merge.StartRow >= rows.Count) continue; string anchor = (merge.StartCol < rows[merge.StartRow].Length) ? rows[merge.StartRow][merge.StartCol] : ""; for (int r = merge.StartRow; r <= merge.EndRow && r < rows.Count; r++) { var row = rows[r]; for (int c = merge.StartCol; c <= merge.EndCol && c < row.Length; c++) { row[c] = anchor; } } } } private static void NormalizeRows(List rows, int maxCols) { for (int i = 0; i < rows.Count; i++) { if (rows[i].Length < maxCols) { var padded = new string[maxCols]; Array.Copy(rows[i], padded, rows[i].Length); rows[i] = padded; } } } }