Initial import of ExcelMatcher
This commit is contained in:
commit
fa9ae8ed6f
7 files changed
+2366
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
publish/
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Themes.Fluent;
|
||||||
|
|
||||||
|
namespace ExcelMatcher;
|
||||||
|
|
||||||
|
public class App : Application
|
||||||
|
{
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
Styles.Add(new FluentTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.MainWindow = new MainWindow();
|
||||||
|
}
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType Condition="'$(RuntimeIdentifier)' == 'win-x64'">WinExe</OutputType>
|
||||||
|
<OutputType Condition="'$(RuntimeIdentifier)' != 'win-x64'">Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<AssemblyName>Сопоставлятор3000</AssemblyName>
|
||||||
|
<Product>Сопоставлятор 3000</Product>
|
||||||
|
<AssemblyTitle>Сопоставлятор 3000</AssemblyTitle>
|
||||||
|
<Company>Dedicraf</Company>
|
||||||
|
<Copyright>© 2026 Dedicraf · Специально для Юрия Киселёва</Copyright>
|
||||||
|
<ApplicationIcon>Assets/app.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets/app.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="12.1.2" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.2" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.105.1" />
|
||||||
|
<PackageReference Include="NPOI" Version="2.7.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+970
@@ -0,0 +1,970 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using NPOI.HSSF.UserModel;
|
||||||
|
using NPOI.SS.UserModel;
|
||||||
|
using NPOI.SS.Util;
|
||||||
|
|
||||||
|
namespace ExcelMatcher;
|
||||||
|
|
||||||
|
public sealed class TableData
|
||||||
|
{
|
||||||
|
public string FilePath { get; init; } = "";
|
||||||
|
public string SheetName { get; init; } = "";
|
||||||
|
public List<string> Columns { get; init; } = new();
|
||||||
|
public List<string[]> Rows { get; init; } = new();
|
||||||
|
public int RowCount => Rows.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ExcelReader
|
||||||
|
{
|
||||||
|
public static List<TableData> LoadWorkbook(string path)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
return ext is ".xls" or ".xlt" ? LoadXls(path) : LoadXlsx(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TableData> LoadXlsx(string path)
|
||||||
|
{
|
||||||
|
var result = new List<TableData>();
|
||||||
|
using var wb = new XLWorkbook(path);
|
||||||
|
foreach (var ws in wb.Worksheets)
|
||||||
|
{
|
||||||
|
result.Add(LoadSheet(path, ws));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TableData> LoadXls(string path)
|
||||||
|
{
|
||||||
|
var result = new List<TableData>();
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
var wb = new HSSFWorkbook(fs);
|
||||||
|
var fmt = new DataFormatter();
|
||||||
|
for (int i = 0; i < wb.NumberOfSheets; i++)
|
||||||
|
{
|
||||||
|
var ws = wb.GetSheetAt(i);
|
||||||
|
if (ws is null) continue;
|
||||||
|
result.Add(LoadXlsSheet(path, ws, fmt));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CellText(ISheet ws, int r, int c, DataFormatter fmt)
|
||||||
|
{
|
||||||
|
var cell = ws.GetRow(r)?.GetCell(c);
|
||||||
|
return cell is null ? "" : (fmt.FormatCellValue(cell) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TableData LoadXlsSheet(string path, ISheet ws, DataFormatter fmt)
|
||||||
|
{
|
||||||
|
int firstRow = -1, lastRow = -1, firstCol = -1, lastCol = -1;
|
||||||
|
|
||||||
|
for (int r = 0; r <= ws.LastRowNum; r++)
|
||||||
|
{
|
||||||
|
var row = ws.GetRow(r);
|
||||||
|
if (row is null) continue;
|
||||||
|
int minCol = -1, maxCol = -1;
|
||||||
|
foreach (var cell in row.Cells)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fmt.FormatCellValue(cell))) continue;
|
||||||
|
int c = cell.ColumnIndex;
|
||||||
|
if (minCol == -1 || c < minCol) minCol = c;
|
||||||
|
if (maxCol == -1 || c > maxCol) maxCol = c;
|
||||||
|
}
|
||||||
|
if (minCol == -1) continue;
|
||||||
|
if (firstRow == -1) firstRow = r;
|
||||||
|
lastRow = r;
|
||||||
|
if (firstCol == -1 || minCol < firstCol) firstCol = minCol;
|
||||||
|
if (lastCol == -1 || maxCol > lastCol) lastCol = maxCol;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstRow == -1)
|
||||||
|
return new TableData { FilePath = path, SheetName = ws.SheetName };
|
||||||
|
|
||||||
|
int colCount = lastCol - firstCol + 1;
|
||||||
|
var cols = new List<string>();
|
||||||
|
for (int c = 0; c < colCount; c++)
|
||||||
|
{
|
||||||
|
var header = CellText(ws, firstRow, firstCol + c, fmt);
|
||||||
|
cols.Add(string.IsNullOrEmpty(header) ? $"Колонка {c + 1}" : header);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<string[]>();
|
||||||
|
for (int r = firstRow + 1; r <= lastRow; r++)
|
||||||
|
{
|
||||||
|
var row = new string[colCount];
|
||||||
|
bool any = false;
|
||||||
|
for (int c = 0; c < colCount; c++)
|
||||||
|
{
|
||||||
|
var cell = CellText(ws, r, firstCol + c, fmt);
|
||||||
|
row[c] = cell;
|
||||||
|
if (!string.IsNullOrWhiteSpace(cell)) any = true;
|
||||||
|
}
|
||||||
|
if (any) rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TableData
|
||||||
|
{
|
||||||
|
FilePath = path,
|
||||||
|
SheetName = ws.SheetName,
|
||||||
|
Columns = cols,
|
||||||
|
Rows = rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TableData LoadSheet(string path, IXLWorksheet ws)
|
||||||
|
{
|
||||||
|
var cols = new List<string>();
|
||||||
|
var rows = new List<string[]>();
|
||||||
|
|
||||||
|
var firstUsed = ws.FirstCellUsed();
|
||||||
|
var lastUsed = ws.LastCellUsed();
|
||||||
|
if (firstUsed == null || lastUsed == null)
|
||||||
|
return new TableData { FilePath = path, SheetName = ws.Name };
|
||||||
|
|
||||||
|
int firstRow = firstUsed.Address.RowNumber;
|
||||||
|
int lastRow = lastUsed.Address.RowNumber;
|
||||||
|
int firstCol = firstUsed.Address.ColumnNumber;
|
||||||
|
int lastCol = lastUsed.Address.ColumnNumber;
|
||||||
|
int colCount = lastCol - firstCol + 1;
|
||||||
|
|
||||||
|
for (int c = 0; c < colCount; c++)
|
||||||
|
{
|
||||||
|
var header = ws.Cell(firstRow, firstCol + c).GetString().Trim();
|
||||||
|
cols.Add(string.IsNullOrEmpty(header) ? $"Колонка {c + 1}" : header);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int r = firstRow + 1; r <= lastRow; r++)
|
||||||
|
{
|
||||||
|
var row = new string[colCount];
|
||||||
|
bool any = false;
|
||||||
|
for (int c = 0; c < colCount; c++)
|
||||||
|
{
|
||||||
|
var cell = ws.Cell(r, firstCol + c).GetString();
|
||||||
|
row[c] = cell;
|
||||||
|
if (!string.IsNullOrWhiteSpace(cell)) any = true;
|
||||||
|
}
|
||||||
|
if (any) rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TableData
|
||||||
|
{
|
||||||
|
FilePath = path,
|
||||||
|
SheetName = ws.Name,
|
||||||
|
Columns = cols,
|
||||||
|
Rows = rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MatchMode
|
||||||
|
{
|
||||||
|
KeepMatches,
|
||||||
|
KeepOnlyMain,
|
||||||
|
KeepOnlySecondary,
|
||||||
|
KeepBoth
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MatchKind
|
||||||
|
{
|
||||||
|
Exact,
|
||||||
|
Partial,
|
||||||
|
Fuzzy
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MatchOptions
|
||||||
|
{
|
||||||
|
public bool[] MainUsed = Array.Empty<bool>();
|
||||||
|
public bool[] SecondaryUsed = Array.Empty<bool>();
|
||||||
|
public bool IgnoreCaseAndSpaces = true;
|
||||||
|
public bool ExtendedNormalize = false;
|
||||||
|
public MatchKind Kind = MatchKind.Exact;
|
||||||
|
public int FuzzyThreshold = 85;
|
||||||
|
public bool UseOr = false;
|
||||||
|
public IReadOnlyDictionary<string, string>? Synonyms;
|
||||||
|
public CancellationToken Token = default;
|
||||||
|
|
||||||
|
public MatchOptions Clone() => (MatchOptions)MemberwiseClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MatchPair
|
||||||
|
{
|
||||||
|
public string[] RowA { get; init; } = Array.Empty<string>();
|
||||||
|
public string[] RowB { get; init; } = Array.Empty<string>();
|
||||||
|
public string Type { get; init; } = "";
|
||||||
|
public double Score { get; init; }
|
||||||
|
public int BCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MatchResult
|
||||||
|
{
|
||||||
|
public List<string[]> Matches { get; init; } = new();
|
||||||
|
public List<string[]> OnlyMain { get; init; } = new();
|
||||||
|
public List<string[]> OnlySecondary { get; init; } = new();
|
||||||
|
public List<MatchPair> Pairs { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MergeOutput
|
||||||
|
{
|
||||||
|
public List<string> Columns { get; init; } = new();
|
||||||
|
public List<string[]> Rows { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TableMatcher
|
||||||
|
{
|
||||||
|
private static List<int> KeyIndexes(bool[] used)
|
||||||
|
{
|
||||||
|
return used.Select((u, i) => (u, i)).Where(x => x.u).Select(x => x.i).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double Similarity(string a, string b)
|
||||||
|
{
|
||||||
|
if (a == b) return 1;
|
||||||
|
if (a.Length == 0) return 0;
|
||||||
|
if (b.Length == 0) return 0;
|
||||||
|
int dist = Levenshtein(a, b);
|
||||||
|
int maxLen = Math.Max(a.Length, b.Length);
|
||||||
|
return 1.0 - (double)dist / maxLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Levenshtein(string a, string b)
|
||||||
|
{
|
||||||
|
var prev = new int[b.Length + 1];
|
||||||
|
var curr = new int[b.Length + 1];
|
||||||
|
for (int j = 0; j <= b.Length; j++) prev[j] = j;
|
||||||
|
for (int i = 1; i <= a.Length; i++)
|
||||||
|
{
|
||||||
|
curr[0] = i;
|
||||||
|
for (int j = 1; j <= b.Length; j++)
|
||||||
|
{
|
||||||
|
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||||
|
curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
|
||||||
|
}
|
||||||
|
(prev, curr) = (curr, prev);
|
||||||
|
}
|
||||||
|
return prev[b.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeBase(string s, IReadOnlyDictionary<string, string>? synonyms)
|
||||||
|
{
|
||||||
|
var t = s;
|
||||||
|
if (synonyms is not null)
|
||||||
|
{
|
||||||
|
foreach (var kv in synonyms)
|
||||||
|
t = t.Replace(kv.Key, kv.Value, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
return t.Replace(" ", "").Replace("\t", "").ToLowerInvariant().Replace('ё', 'e');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeCore(string s)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(s.Length);
|
||||||
|
foreach (char ch in s.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
|
||||||
|
}
|
||||||
|
var clean = sb.ToString().Replace('ё', 'е');
|
||||||
|
foreach (var p in new[] { "ооо", "зао", "оао", "тоо", "пао" })
|
||||||
|
{
|
||||||
|
if (clean.StartsWith(p, StringComparison.Ordinal) && clean.Length > p.Length + 1)
|
||||||
|
{
|
||||||
|
clean = clean.Substring(p.Length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormValue(string s, bool ignore, MatchOptions o)
|
||||||
|
{
|
||||||
|
if (!ignore) return s;
|
||||||
|
return o.ExtendedNormalize ? NormalizeCore(s) : NormalizeBase(s, o.Synonyms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] RowParts(string[] row, List<int> keyIndexes, int take, MatchOptions o)
|
||||||
|
{
|
||||||
|
var parts = new string[take];
|
||||||
|
for (int i = 0; i < take; i++)
|
||||||
|
parts[i] = keyIndexes[i] < row.Length ? NormValue(row[keyIndexes[i]], o.IgnoreCaseAndSpaces, o) : "";
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmptyParts(string[] parts) => parts.All(string.IsNullOrEmpty);
|
||||||
|
|
||||||
|
private static string JoinKey(string[] parts) => string.Join("\u00a6", parts);
|
||||||
|
|
||||||
|
private static bool PartsContain(string[] a, string[] b)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < a.Length; i++)
|
||||||
|
{
|
||||||
|
var x = a[i];
|
||||||
|
var y = b[i];
|
||||||
|
if (string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y)) return false;
|
||||||
|
if (x.Contains(y, StringComparison.Ordinal) || y.Contains(x, StringComparison.Ordinal)) continue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool AnyColContains(string[] a, string[] b)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < a.Length; i++)
|
||||||
|
{
|
||||||
|
var x = a[i];
|
||||||
|
var y = b[i];
|
||||||
|
if (string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y)) continue;
|
||||||
|
if (x.Contains(y, StringComparison.Ordinal) || y.Contains(x, StringComparison.Ordinal)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ColumnSim(string[] a, string[] b, bool orMode)
|
||||||
|
{
|
||||||
|
double sum = 0;
|
||||||
|
int n = 0;
|
||||||
|
double best = 0;
|
||||||
|
for (int i = 0; i < a.Length; i++)
|
||||||
|
{
|
||||||
|
if (a[i].Length == 0 && b[i].Length == 0 && !orMode)
|
||||||
|
{
|
||||||
|
sum += 1;
|
||||||
|
n++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a[i].Length == 0 || b[i].Length == 0)
|
||||||
|
{
|
||||||
|
if (orMode) continue;
|
||||||
|
sum += 0;
|
||||||
|
n++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var s = Similarity(a[i], b[i]);
|
||||||
|
if (orMode)
|
||||||
|
{
|
||||||
|
if (s > best) best = s;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sum += s;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (orMode) return best;
|
||||||
|
return n == 0 ? 0 : sum / n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Candidate
|
||||||
|
{
|
||||||
|
public string[]? BRow;
|
||||||
|
public string Type = "";
|
||||||
|
public double Score;
|
||||||
|
public int BCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Candidate> MatchMain(TableData main, TableData secondary, MatchOptions o, out List<string[]> onlySecondary)
|
||||||
|
{
|
||||||
|
var mainIdx = KeyIndexes(o.MainUsed);
|
||||||
|
var secIdx = KeyIndexes(o.SecondaryUsed);
|
||||||
|
int keyCount = Math.Min(mainIdx.Count, secIdx.Count);
|
||||||
|
onlySecondary = new List<string[]>();
|
||||||
|
|
||||||
|
if (keyCount == 0)
|
||||||
|
return main.Rows.Select(_ => (Candidate?)null).Cast<Candidate>().ToList();
|
||||||
|
|
||||||
|
var token = o.Token;
|
||||||
|
bool or = o.UseOr;
|
||||||
|
var candidates = new List<Candidate>(main.RowCount);
|
||||||
|
|
||||||
|
if (o.Kind == MatchKind.Exact)
|
||||||
|
{
|
||||||
|
if (!or)
|
||||||
|
{
|
||||||
|
var mainKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in main.Rows)
|
||||||
|
{
|
||||||
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
|
if (!IsEmptyParts(parts)) mainKeys.Add(JoinKey(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
var bByKey = new Dictionary<string, (string[] Row, int Count)>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in secondary.Rows)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, secIdx, keyCount, o);
|
||||||
|
if (IsEmptyParts(parts)) continue;
|
||||||
|
var k = JoinKey(parts);
|
||||||
|
if (bByKey.TryGetValue(k, out var cur))
|
||||||
|
bByKey[k] = (cur.Row, cur.Count + 1);
|
||||||
|
else
|
||||||
|
bByKey[k] = (row, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in main.Rows)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
|
if (IsEmptyParts(parts))
|
||||||
|
{
|
||||||
|
candidates.Add(new Candidate());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bByKey.TryGetValue(JoinKey(parts), out var hit))
|
||||||
|
{
|
||||||
|
candidates.Add(new Candidate { BRow = hit.Row, Type = "точное", Score = 100, BCount = hit.Count });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
candidates.Add(new Candidate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in secondary.Rows)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, secIdx, keyCount, o);
|
||||||
|
if (!IsEmptyParts(parts) && !mainKeys.Contains(JoinKey(parts)))
|
||||||
|
onlySecondary.Add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var mainSets = new HashSet<string>[keyCount];
|
||||||
|
for (int j = 0; j < keyCount; j++) mainSets[j] = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in main.Rows)
|
||||||
|
{
|
||||||
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
|
if (IsEmptyParts(parts)) continue;
|
||||||
|
for (int j = 0; j < keyCount; j++)
|
||||||
|
if (parts[j].Length > 0) mainSets[j].Add(parts[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bByVal = new Dictionary<string, (string[] Row, int Count)>[keyCount];
|
||||||
|
for (int j = 0; j < keyCount; j++) bByVal[j] = new Dictionary<string, (string[] Row, int Count)>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in secondary.Rows)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, secIdx, keyCount, o);
|
||||||
|
if (IsEmptyParts(parts)) continue;
|
||||||
|
for (int j = 0; j < keyCount; j++)
|
||||||
|
{
|
||||||
|
if (parts[j].Length == 0) continue;
|
||||||
|
var v = parts[j];
|
||||||
|
if (bByVal[j].TryGetValue(v, out var cur))
|
||||||
|
bByVal[j][v] = (cur.Row, cur.Count + 1);
|
||||||
|
else
|
||||||
|
bByVal[j][v] = (row, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in main.Rows)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
|
if (IsEmptyParts(parts))
|
||||||
|
{
|
||||||
|
candidates.Add(new Candidate());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string[]? bestRow = null;
|
||||||
|
int bestCount = 0;
|
||||||
|
for (int j = 0; j < keyCount; j++)
|
||||||
|
{
|
||||||
|
if (parts[j].Length == 0) continue;
|
||||||
|
if (bByVal[j].TryGetValue(parts[j], out var hit) && hit.Count > bestCount)
|
||||||
|
{
|
||||||
|
bestRow = hit.Row;
|
||||||
|
bestCount = hit.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestRow is not null)
|
||||||
|
candidates.Add(new Candidate { BRow = bestRow, Type = "точное", Score = 100, BCount = bestCount });
|
||||||
|
else
|
||||||
|
candidates.Add(new Candidate());
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in secondary.Rows)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, secIdx, keyCount, o);
|
||||||
|
if (IsEmptyParts(parts)) continue;
|
||||||
|
bool matched = false;
|
||||||
|
for (int j = 0; j < keyCount; j++)
|
||||||
|
{
|
||||||
|
if (parts[j].Length > 0 && mainSets[j].Contains(parts[j]))
|
||||||
|
{
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) onlySecondary.Add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Частичное / Нечёткое: строим списки частей и сканируем строку за строкой.
|
||||||
|
var secRows = secondary.Rows
|
||||||
|
.Select(row => (Row: row, Parts: RowParts(row, secIdx, keyCount, o)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hits = new bool[secRows.Count];
|
||||||
|
|
||||||
|
foreach (var row in main.Rows)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
|
if (IsEmptyParts(parts))
|
||||||
|
{
|
||||||
|
candidates.Add(new Candidate());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[]? bestRow = null;
|
||||||
|
double bestScore = 0;
|
||||||
|
int hitCount = 0;
|
||||||
|
|
||||||
|
if (o.Kind == MatchKind.Partial)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < secRows.Count; i++)
|
||||||
|
{
|
||||||
|
var b = secRows[i];
|
||||||
|
bool ok = or ? AnyColContains(parts, b.Parts) : PartsContain(parts, b.Parts);
|
||||||
|
if (!ok) continue;
|
||||||
|
hits[i] = true;
|
||||||
|
hitCount++;
|
||||||
|
if (bestRow is null) bestRow = b.Row;
|
||||||
|
}
|
||||||
|
if (bestRow is not null)
|
||||||
|
candidates.Add(new Candidate { BRow = bestRow, BCount = hitCount, Score = 100, Type = "частичное" });
|
||||||
|
else
|
||||||
|
candidates.Add(new Candidate());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
double threshold = o.FuzzyThreshold / 100.0;
|
||||||
|
for (int i = 0; i < secRows.Count; i++)
|
||||||
|
{
|
||||||
|
var b = secRows[i];
|
||||||
|
double sim = ColumnSim(parts, b.Parts, or);
|
||||||
|
if (sim >= threshold)
|
||||||
|
{
|
||||||
|
hits[i] = true;
|
||||||
|
hitCount++;
|
||||||
|
if (sim > bestScore)
|
||||||
|
{
|
||||||
|
bestScore = sim;
|
||||||
|
bestRow = b.Row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestRow is not null)
|
||||||
|
candidates.Add(new Candidate
|
||||||
|
{
|
||||||
|
BRow = bestRow,
|
||||||
|
BCount = hitCount,
|
||||||
|
Score = Math.Round(bestScore * 100, 1),
|
||||||
|
Type = "нечёткое"
|
||||||
|
});
|
||||||
|
else
|
||||||
|
candidates.Add(new Candidate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < secRows.Count; i++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!hits[i]) onlySecondary.Add(secRows[i].Row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MatchResult RunAll(TableData main, TableData secondary, MatchOptions o)
|
||||||
|
{
|
||||||
|
var candidates = MatchMain(main, secondary, o, out var onlySecondary);
|
||||||
|
var matches = new List<string[]>();
|
||||||
|
var pairs = new List<MatchPair>();
|
||||||
|
var onlyMain = new List<string[]>();
|
||||||
|
|
||||||
|
int n = Math.Min(candidates.Count, main.RowCount);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
o.Token.ThrowIfCancellationRequested();
|
||||||
|
var c = candidates[i];
|
||||||
|
if (c.BRow is null)
|
||||||
|
{
|
||||||
|
onlyMain.Add(main.Rows[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matches.Add(main.Rows[i]);
|
||||||
|
pairs.Add(new MatchPair
|
||||||
|
{
|
||||||
|
RowA = main.Rows[i],
|
||||||
|
RowB = c.BRow,
|
||||||
|
Type = c.Type,
|
||||||
|
Score = c.Score,
|
||||||
|
BCount = c.BCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MatchResult
|
||||||
|
{
|
||||||
|
Matches = matches,
|
||||||
|
Pairs = pairs,
|
||||||
|
OnlyMain = onlyMain,
|
||||||
|
OnlySecondary = onlySecondary
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MatchResult RunAll(TableData main, TableData secondary, bool[] mainUsed, bool[] secondaryUsed, bool ignore)
|
||||||
|
=> RunAll(main, secondary, new MatchOptions { MainUsed = mainUsed, SecondaryUsed = secondaryUsed, IgnoreCaseAndSpaces = ignore });
|
||||||
|
|
||||||
|
public static MatchResult RunAll(TableData main, TableData secondary, bool[] mainUsed, bool[] secondaryUsed, bool ignore, bool partial)
|
||||||
|
=> RunAll(main, secondary, new MatchOptions
|
||||||
|
{
|
||||||
|
MainUsed = mainUsed,
|
||||||
|
SecondaryUsed = secondaryUsed,
|
||||||
|
IgnoreCaseAndSpaces = ignore,
|
||||||
|
Kind = partial ? MatchKind.Partial : MatchKind.Exact
|
||||||
|
});
|
||||||
|
|
||||||
|
public static MergeOutput Merge(TableData main, TableData secondary, MatchOptions o, bool removeIdenticalColumns = true)
|
||||||
|
{
|
||||||
|
var candidates = MatchMain(main, secondary, o, out _);
|
||||||
|
|
||||||
|
var columns = new List<string>(main.Columns);
|
||||||
|
var extraCols = new List<int>();
|
||||||
|
|
||||||
|
if (removeIdenticalColumns)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < secondary.Columns.Count; j++)
|
||||||
|
{
|
||||||
|
if (columns.Contains(secondary.Columns[j]))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var target = SortedColumnValues(secondary.Rows, j);
|
||||||
|
bool duplicate = false;
|
||||||
|
for (int c = 0; c < main.Columns.Count && !duplicate; c++)
|
||||||
|
duplicate = ColumnValuesEqual(target, SortedColumnValues(main.Rows, c));
|
||||||
|
for (int e = 0; e < extraCols.Count && !duplicate; e++)
|
||||||
|
duplicate = ColumnValuesEqual(target, SortedColumnValues(secondary.Rows, extraCols[e]));
|
||||||
|
if (duplicate) continue;
|
||||||
|
|
||||||
|
columns.Add(secondary.Columns[j]);
|
||||||
|
extraCols.Add(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (int j = 0; j < secondary.Columns.Count; j++)
|
||||||
|
{
|
||||||
|
columns.Add(secondary.Columns[j]);
|
||||||
|
extraCols.Add(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] BuildMerged(string[] row, string[] secRow)
|
||||||
|
{
|
||||||
|
var merged = new string[columns.Count];
|
||||||
|
for (int c = 0; c < main.Columns.Count; c++)
|
||||||
|
merged[c] = c < row.Length ? row[c] : "";
|
||||||
|
for (int e = 0; e < extraCols.Count; e++)
|
||||||
|
{
|
||||||
|
int j = extraCols[e];
|
||||||
|
merged[main.Columns.Count + e] = j < secRow.Length ? secRow[j] : "";
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<string[]>();
|
||||||
|
int n = Math.Min(candidates.Count, main.RowCount);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
o.Token.ThrowIfCancellationRequested();
|
||||||
|
var c = candidates[i];
|
||||||
|
if (c.BRow is not null)
|
||||||
|
rows.Add(BuildMerged(main.Rows[i], c.BRow));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MergeOutput { Columns = columns, Rows = rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MergeOutput Merge(
|
||||||
|
TableData main,
|
||||||
|
TableData secondary,
|
||||||
|
bool[] mainUsed,
|
||||||
|
bool[] secondaryUsed,
|
||||||
|
bool ignoreCaseAndSpaces,
|
||||||
|
bool partial = false,
|
||||||
|
bool removeIdenticalColumns = true)
|
||||||
|
=> Merge(main, secondary, new MatchOptions
|
||||||
|
{
|
||||||
|
MainUsed = mainUsed,
|
||||||
|
SecondaryUsed = secondaryUsed,
|
||||||
|
IgnoreCaseAndSpaces = ignoreCaseAndSpaces,
|
||||||
|
Kind = partial ? MatchKind.Partial : MatchKind.Exact
|
||||||
|
}, removeIdenticalColumns);
|
||||||
|
|
||||||
|
private static List<string> SortedColumnValues(List<string[]> rows, int colIndex)
|
||||||
|
{
|
||||||
|
var vals = new List<string>();
|
||||||
|
foreach (var r in rows)
|
||||||
|
vals.Add(colIndex < r.Length ? (r[colIndex] ?? "").Trim() : "");
|
||||||
|
vals.Sort(StringComparer.Ordinal);
|
||||||
|
return vals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ColumnValuesEqual(List<string> a, List<string> b)
|
||||||
|
{
|
||||||
|
if (a.Count != b.Count) return false;
|
||||||
|
for (int i = 0; i < a.Count; i++)
|
||||||
|
if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (TableData Table, int Removed) Dedupe(TableData table, bool[] used, bool ignore, MatchOptions? template = null)
|
||||||
|
{
|
||||||
|
var idx = KeyIndexes(used);
|
||||||
|
if (idx.Count == 0)
|
||||||
|
return (table, 0);
|
||||||
|
|
||||||
|
var o = template?.Clone() ?? new MatchOptions();
|
||||||
|
o.MainUsed = used;
|
||||||
|
o.SecondaryUsed = used;
|
||||||
|
o.IgnoreCaseAndSpaces = ignore;
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var rows = new List<string[]>();
|
||||||
|
int removed = 0;
|
||||||
|
foreach (var row in table.Rows)
|
||||||
|
{
|
||||||
|
o.Token.ThrowIfCancellationRequested();
|
||||||
|
var parts = RowParts(row, idx, idx.Count, o);
|
||||||
|
if (!IsEmptyParts(parts) && !seen.Add(JoinKey(parts)))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new TableData
|
||||||
|
{
|
||||||
|
FilePath = table.FilePath,
|
||||||
|
SheetName = table.SheetName,
|
||||||
|
Columns = table.Columns,
|
||||||
|
Rows = rows
|
||||||
|
}, removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (int EmptyKeys, int Total) KeyCoverage(TableData table, bool[] used, MatchOptions? o = null)
|
||||||
|
{
|
||||||
|
var idx = KeyIndexes(used);
|
||||||
|
int empty = 0;
|
||||||
|
foreach (var row in table.Rows)
|
||||||
|
{
|
||||||
|
bool allEmpty;
|
||||||
|
if (idx.Count == 0)
|
||||||
|
{
|
||||||
|
allEmpty = row.All(string.IsNullOrEmpty);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
allEmpty = true;
|
||||||
|
foreach (var j in idx)
|
||||||
|
{
|
||||||
|
if (j >= row.Length || string.IsNullOrWhiteSpace(row[j]))
|
||||||
|
continue;
|
||||||
|
allEmpty = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allEmpty) empty++;
|
||||||
|
}
|
||||||
|
return (empty, table.RowCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ExcelExporter
|
||||||
|
{
|
||||||
|
public static void ExportAll(string path, TableData main, TableData secondary, MatchResult result, MergeOutput? merged = null)
|
||||||
|
{
|
||||||
|
using var wb = new XLWorkbook();
|
||||||
|
|
||||||
|
var wsMerged = wb.Worksheets.Add("Итог (совмещено)");
|
||||||
|
var wsPairs = wb.Worksheets.Add("Пары A↔B");
|
||||||
|
var wsMatches = wb.Worksheets.Add("Совпадения");
|
||||||
|
var wsOnlyMain = wb.Worksheets.Add("Только в A");
|
||||||
|
var wsOnlySecondary = wb.Worksheets.Add("Только в B");
|
||||||
|
var wsAnalytics = wb.Worksheets.Add("Аналитика");
|
||||||
|
|
||||||
|
if (merged is null || merged.Rows.Count == 0)
|
||||||
|
merged = new MergeOutput { Columns = main.Columns, Rows = result.Matches };
|
||||||
|
|
||||||
|
WriteSheet(wsMerged, merged.Columns, merged.Rows);
|
||||||
|
|
||||||
|
WritePairs(wsPairs, main, secondary, result);
|
||||||
|
|
||||||
|
WriteMatches(wsMatches, main, result);
|
||||||
|
|
||||||
|
WriteSheet(wsOnlyMain, main.Columns, result.OnlyMain);
|
||||||
|
WriteSheet(wsOnlySecondary, secondary.Columns, result.OnlySecondary);
|
||||||
|
|
||||||
|
WriteAnalytics(wsAnalytics, main, secondary, result);
|
||||||
|
|
||||||
|
wb.SaveAs(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteMatches(IXLWorksheet ws, TableData main, MatchResult result)
|
||||||
|
{
|
||||||
|
var headers = new List<string>(main.Columns);
|
||||||
|
bool withMeta = result.Pairs.Count > 0 || main.Columns.Count == 0;
|
||||||
|
if (withMeta)
|
||||||
|
{
|
||||||
|
headers.Add("Тип");
|
||||||
|
headers.Add("Сходство %");
|
||||||
|
headers.Add("B-совпадений");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int c = 0; c < headers.Count; c++)
|
||||||
|
{
|
||||||
|
ws.Cell(1, c + 1).Value = headers[c];
|
||||||
|
ws.Cell(1, c + 1).Style.Font.Bold = true;
|
||||||
|
ws.Cell(1, c + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#DDEEFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
int rows = result.Matches.Count;
|
||||||
|
for (int i = 0; i < rows; i++)
|
||||||
|
{
|
||||||
|
var row = result.Matches[i];
|
||||||
|
int baseCols = main.Columns.Count;
|
||||||
|
for (int c = 0; c < baseCols; c++)
|
||||||
|
{
|
||||||
|
SetCell(ws, i + 2, c + 1, c < row.Length ? row[c] : "");
|
||||||
|
}
|
||||||
|
if (withMeta)
|
||||||
|
{
|
||||||
|
var type = result.Pairs.Count > i ? result.Pairs[i].Type : "";
|
||||||
|
var score = result.Pairs.Count > i ? result.Pairs[i].Score : 0;
|
||||||
|
var count = result.Pairs.Count > i ? result.Pairs[i].BCount : 0;
|
||||||
|
SetCell(ws, i + 2, baseCols + 1, type);
|
||||||
|
SetCell(ws, i + 2, baseCols + 2, type == "нечёткое" ? score.ToString("0.#", CultureInfo.InvariantCulture) : "");
|
||||||
|
SetCell(ws, i + 2, baseCols + 3, count == 0 ? "" : count.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Columns().AdjustToContents();
|
||||||
|
if (rows == 0) ws.Row(2).Cell(1).Value = "Нет данных";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePairs(IXLWorksheet ws, TableData main, TableData secondary, MatchResult result)
|
||||||
|
{
|
||||||
|
var headers = new List<string>();
|
||||||
|
foreach (var h in main.Columns) headers.Add("A: " + h);
|
||||||
|
headers.Add("Тип");
|
||||||
|
headers.Add("Сходство %");
|
||||||
|
headers.Add("B-совпадений");
|
||||||
|
foreach (var h in secondary.Columns) headers.Add("B: " + h);
|
||||||
|
|
||||||
|
for (int c = 0; c < headers.Count; c++)
|
||||||
|
{
|
||||||
|
ws.Cell(1, c + 1).Value = headers[c];
|
||||||
|
ws.Cell(1, c + 1).Style.Font.Bold = true;
|
||||||
|
ws.Cell(1, c + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#DDEEFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
int aCols = main.Columns.Count;
|
||||||
|
int bCols = secondary.Columns.Count;
|
||||||
|
for (int i = 0; i < result.Pairs.Count; i++)
|
||||||
|
{
|
||||||
|
var p = result.Pairs[i];
|
||||||
|
var rowA = p.RowA;
|
||||||
|
var rowB = p.RowB;
|
||||||
|
for (int c = 0; c < aCols; c++)
|
||||||
|
SetCell(ws, i + 2, c + 1, c < rowA.Length ? rowA[c] : "");
|
||||||
|
SetCell(ws, i + 2, aCols + 1, p.Type);
|
||||||
|
SetCell(ws, i + 2, aCols + 2, p.Type == "нечёткое" ? p.Score.ToString("0.#", CultureInfo.InvariantCulture) : "");
|
||||||
|
SetCell(ws, i + 2, aCols + 3, p.BCount == 0 ? "" : p.BCount.ToString(CultureInfo.InvariantCulture));
|
||||||
|
for (int c = 0; c < bCols; c++)
|
||||||
|
SetCell(ws, i + 2, aCols + 3 + c + 1, c < rowB.Length ? rowB[c] : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Columns().AdjustToContents();
|
||||||
|
if (result.Pairs.Count == 0) ws.Row(2).Cell(1).Value = "Нет данных";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAnalytics(IXLWorksheet ws, TableData main, TableData secondary, MatchResult result)
|
||||||
|
{
|
||||||
|
ws.Cell(1, 1).Value = "Показатель";
|
||||||
|
ws.Cell(1, 2).Value = "Значение";
|
||||||
|
ws.Cell(1, 1).Style.Font.Bold = true;
|
||||||
|
ws.Cell(1, 2).Style.Font.Bold = true;
|
||||||
|
|
||||||
|
int r = 2;
|
||||||
|
void Row(string name, object value)
|
||||||
|
{
|
||||||
|
ws.Cell(r, 1).Value = name;
|
||||||
|
ws.Cell(r, 2).Value = Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
|
||||||
|
r++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Row("Всего в A", main.RowCount);
|
||||||
|
Row("Всего в B", secondary.RowCount);
|
||||||
|
Row("Совпадений (строк A)", result.Matches.Count);
|
||||||
|
Row("Только в A", result.OnlyMain.Count);
|
||||||
|
Row("Только в B", result.OnlySecondary.Count);
|
||||||
|
Row("Пар A↔B", result.Pairs.Count);
|
||||||
|
|
||||||
|
var exact = result.Pairs.Count(p => p.Type == "точное");
|
||||||
|
var partial = result.Pairs.Count(p => p.Type == "частичное");
|
||||||
|
var fuzzy = result.Pairs.Count(p => p.Type == "нечёткое");
|
||||||
|
Row("Из них точных", exact);
|
||||||
|
Row("Из них частичных", partial);
|
||||||
|
Row("Из них нечётких", fuzzy);
|
||||||
|
|
||||||
|
double avg = result.Pairs.Count == 0 ? 0 : Math.Round(result.Pairs.Average(p => p.BCount), 2);
|
||||||
|
int maxBC = result.Pairs.Count == 0 ? 0 : result.Pairs.Max(p => p.BCount);
|
||||||
|
Row("Среднее B-совпадений (N→M)", avg);
|
||||||
|
Row("Максимум B-совпадений (N→M)", maxBC);
|
||||||
|
|
||||||
|
ws.Columns().AdjustToContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteSheet(IXLWorksheet ws, List<string> columns, List<string[]> rows)
|
||||||
|
{
|
||||||
|
int colCount = columns.Count;
|
||||||
|
for (int c = 0; c < colCount; c++)
|
||||||
|
{
|
||||||
|
ws.Cell(1, c + 1).Value = columns[c];
|
||||||
|
ws.Cell(1, c + 1).Style.Font.Bold = true;
|
||||||
|
ws.Cell(1, c + 1).Style.Fill.BackgroundColor = XLColor.FromHtml("#DDEEFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
var row = rows[i];
|
||||||
|
for (int c = 0; c < row.Length && c < colCount; c++)
|
||||||
|
{
|
||||||
|
SetCell(ws, i + 2, c + 1, row[c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Columns().AdjustToContents();
|
||||||
|
if (rows.Count == 0) ws.Row(2).Cell(1).Value = "Нет данных";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetCell(IXLWorksheet ws, int row, int col, string value)
|
||||||
|
{
|
||||||
|
var cell = ws.Cell(row, col);
|
||||||
|
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var d) &&
|
||||||
|
d.ToString(CultureInfo.InvariantCulture) == value)
|
||||||
|
cell.Value = d;
|
||||||
|
else
|
||||||
|
cell.Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1324
File diff suppressed because it is too large.
Load diff
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace ExcelMatcher;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||||
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user