Files
bim-dogma-poc/ExcelKv.Core/ExcelLoader.cs
2026-01-08 18:09:21 +09:00

356 lines
14 KiB
C#

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<List<string>> GetSheetNamesAsync(string filePath)
{
return await Task.Run(() => MiniExcel.GetSheetNames(filePath).ToList());
}
public static async Task<List<string[]>> 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<string[]> result = new();
foreach(IDictionary<string, object> r in rows)
{
result.Add(FlattenDictionaryRow(r));
}
return result;
});
}
public static async Task<int> 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<string[]>();
int globalMaxCol = 0;
for (int i = 0; i < limitRow; i++)
{
var flat = FlattenDictionaryRow((IDictionary<string, object>)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<string>();
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<string>();
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<string, object> 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<string> FlattenTopHeaders(List<string[]> headerRows, int startCol, int globalMaxCol)
{
if (headerRows.Count == 0) return new List<string>();
// 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<string>();
for (int c = startCol; c < maxCol; c++)
{
var parts = new List<string>();
// 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<MergeRange> GetMergedRanges(string filePath, string sheetName)
{
var result = new List<MergeRange>();
try
{
using var doc = SpreadsheetDocument.Open(filePath, false);
var sheet = doc.WorkbookPart?.Workbook.Descendants<Sheet>().FirstOrDefault(s => s.Name == sheetName);
if (sheet == null) return result;
var wsPart = doc.WorkbookPart?.GetPartById(sheet.Id!) as WorksheetPart;
var mergeCells = wsPart?.Worksheet.Elements<MergeCells>().FirstOrDefault();
if (mergeCells == null) return result;
foreach (var mc in mergeCells.Elements<MergeCell>())
{
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<string[]> rows, List<MergeRange> 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<string[]> 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;
}
}
}
}