using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using MiniExcelLibs; 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(); // Validation int dataStartRow = config.TopHeaderStartRow + config.TopHeaderDepth; if (rows.Count <= dataStartRow) return 0; // 1. Analyze Top Headers (Data Columns) var topHeaderRows = new List(); for (int i = config.TopHeaderStartRow; i < dataStartRow; i++) { topHeaderRows.Add(FlattenDictionaryRow((IDictionary)rows[i])); } // Calculate Global Max Col from Data Rows to ensure we don't truncate data // Optimization: check a sample or assume logical limit. For correctness, check valid rows. int globalMaxCol = 0; int limitRow = config.DataEndRow.HasValue ? Math.Min(rows.Count, config.DataEndRow.Value + 1) : rows.Count; for(int i = dataStartRow; i < limitRow; i++) { var d = (IDictionary)rows[i]; if(d.Count > globalMaxCol) globalMaxCol = d.Count; // Approximate // FlattenDictionaryRow is cleaner but expensive to call just for count. // d.Keys.Count is effectively the column count for that row. } // Flatten Data Headers (Right Side) var topAxisKeys = FlattenTopHeaders(topHeaderRows, config.LeftHeaderStartCol + config.LeftHeaderWidth, globalMaxCol); // ... (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 = FlattenDictionaryRow((IDictionary)rows[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 rowDict = (IDictionary)rows[i]; var rowVals = FlattenDictionaryRow(rowDict); // ... (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] = val?.ToString() ?? ""; } return result; } 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(); // Iterate Columns var lastValues = new string[headerRows.Count]; 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] : ""; // Horizontal Forward Fill (Re-enabled for Merged Headers) if (string.IsNullOrWhiteSpace(val)) val = lastValues[r]; else lastValues[r] = val; 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; } }